目標
下の動画は、JetpackCompose
で作られたよくあるListToDetailの画面です。
こちらを画面遷移時に項目間のアニメーションが行われるようにしていきます。
アニメーションの追加
ListToDetailに、アニメーションを追加する手順を説明します。
基本的には下記の公式サイトの手順通りです。
- アニメーションを行う
Composable
をSharedTransitionLayout {}
で囲う - 設定に必要な
SharedTransitionScope
(SharedTransitionLayout
のthis
)とAnimatedContentScope
(composable
のthis
)をComposable
へ渡す -
Modifier.sharedElement()
はSharedTransitionScope
スコープの拡張関数なので、使用できるよう、共有アニメーションを行うComposeble
をwith(sharedTransitionScope) {}
で囲う - アニメーションを行う
Composeble
のModifier
にsharedElement()
を追加し、共有する要素間で同一の一意な値をkey
に指定してrememberSharedContentState
を使って作成した値をstate
に設定する -
SharedTransitionApi
はExperimental
なので、使用箇所に@OptIn(ExperimentalSharedTransitionApi::class)
アノテーションを追加する
// 5
+ @OptIn(ExperimentalSharedTransitionApi::class)
@Composable
fun MainContentA() {
// 1
+ SharedTransitionLayout {
val navController = rememberNavController()
NavHost(
navController = navController,
startDestination = "list"
) {
composable("list") {
ListView(
navController = navController,
// 2
+ sharedTransitionScope = this@SharedTransitionLayout,
+ animatedContentScope = this@composable,
)
}
composable(
"details/{index}",
arguments = listOf(navArgument("index") { type = NavType.IntType })
) {
val index = it.arguments?.getInt("index")!!
DetailView(
navController = navController,
// 2
+ sharedTransitionScope = this@SharedTransitionLayout,
+ animatedContentScope = this@composable,
index = index
)
}
}
+ }
}
// 5
+ @OptIn(ExperimentalSharedTransitionApi::class)
@Composable
fun ListView(
navController: NavHostController,
// 2
+ sharedTransitionScope: SharedTransitionScope,
+ animatedContentScope: AnimatedContentScope,
) {
LazyVerticalGrid(
modifier = Modifier.fillMaxSize(),
columns = GridCells.Adaptive(120.dp)
) {
repeat(ItemCount) {
item {
Box(modifier = Modifier
.clickable {
navController.navigate("details/$it")
}
.aspectRatio(1f)
.padding(all = 2.dp)
) {
// 3
+ with(sharedTransitionScope) {
Image(
modifier = Modifier
// 4
+ .sharedElement(
+ state = sharedTransitionScope.rememberSharedContentState(key = "image-$it"),
+ animatedVisibilityScope = animatedContentScope
+ ),
painter = ColorPainter(color = colors[it % colors.size]),
contentDescription = ""
)
Text(
modifier = Modifier
// 4
+ .sharedElement(
+ state = sharedTransitionScope.rememberSharedContentState(key = "text-$it"),
+ animatedVisibilityScope = animatedContentScope
+ )
.align(Alignment.Center),
text = "No $it",
)
+ }
}
}
}
}
}
// 5
+ @OptIn(ExperimentalSharedTransitionApi::class)
@Composable
fun DetailView(
navController: NavHostController,
// 2
+ sharedTransitionScope: SharedTransitionScope,
+ animatedContentScope: AnimatedContentScope,
index: Int
) {
val pagerState = rememberPagerState(
pageCount = { ItemCount },
initialPage = index
)
HorizontalPager(state = pagerState) {
Box(modifier = Modifier.fillMaxSize()) {
Box(
modifier = Modifier
.aspectRatio(1f)
.align(Alignment.Center)
.padding(all = 2.dp)
) {
// 3
+ with(sharedTransitionScope) {
Image(
modifier = Modifier
// 4
+ .sharedElement(
+ state = sharedTransitionScope.rememberSharedContentState(key = "image-$it"),
+ animatedVisibilityScope = animatedContentScope
+ ),
painter = ColorPainter(color = colors[it % colors.size]),
contentDescription = ""
)
Text(
modifier = Modifier
// 4
+ .sharedElement(
+ state = sharedTransitionScope.rememberSharedContentState(key = "text-$it"),
+ animatedVisibilityScope = animatedContentScope
+ )
.align(Alignment.TopStart),
text = "No $it"
)
+ }
}
}
}
}
画面遷移時、アニメーションが行われるようになりました。
問題点
公式サイトの手順に従って実装し、画面遷移時にアニメーションが行われるようになりましたが、実際に操作を行っていると、いくつかの問題点が見つかりました。
- 詳細画面で、一覧画面の
LazyVerticalGrid
で画面内に表示されていない要素まで移動をしてから画面遷移すると、アニメーションが行われない - 詳細画面へのアニメーション中にページ送り、詳細画面でページ送り中に元の画面への遷移、を行うとアニメーションが乱れる
これらの問題への対応を行います。
問題点1への対応
詳細画面から一覧画面へ画面遷移する際、詳細画面で表示していたページを一覧画面へ渡し、画面内に表示されていなかった場合は、画面内に表示されるようLazyVerticalGrid
をスクロールさせます。
popBackStackで、元の画面に値を渡す
こちらの手順に従って、元の画面に値を渡す処理を追加します。公式サイトの例ではLiveData
を使っていますが、StateFlow
でも取得できるので、今回はそちらを使っていきます。
- 詳細画面の
BackHandler
で、呼び出し元のNavBackStackEntry
のSavedStateHandle
に現在のページを設定し、画面遷移を行う -
NavHost
のcomposable
内で、savedStateHandle
から値を取得し、一覧画面へ渡す
@OptIn(ExperimentalSharedTransitionApi::class)
@Composable
fun DetailView(
navController: NavHostController,
sharedTransitionScope: SharedTransitionScope,
animatedContentScope: AnimatedContentScope,
index: Int
) {
// ...
// 1
+ BackHandler(enabled = true) {
+ navController.previousBackStackEntry?.savedStateHandle?.set(
+ "lastDisplayedPage",
+ pagerState.currentPage,
+ )
+ navController.popBackStack()
}
}
@OptIn(ExperimentalSharedTransitionApi::class)
@Composable
fun MainContent() {
SharedTransitionLayout {
val navController = rememberNavController()
NavHost(navController, startDestination = "list") {
composable("list") {
// 2
+ val lastDisplayedPage =
+ it.savedStateHandle.getStateFlow("lastDisplayedPage", null as Int?)
+ .collectAsState().value
ListView(
navController,
this@SharedTransitionLayout,
this@composable,
// 2
+ lastDisplayedPage
)
}
// ...
}
}
}
指定された項目がLazyVerticalGrid
内に表示されるようスクロールさせる
- 色々やっていますが、指定された項目が
LazyVerticalGrid
内に表示されるよう、LazyGridState
を使ってスクロールさせています
※固定ヘッダーやフッターを使っていると、上手く動作しません、適宜修正してください - 再コンポーズ時に再度スクロールしないようデータを削除します
@OptIn(ExperimentalSharedTransitionApi::class)
@Composable
fun ListView(
navController: NavHostController,
sharedTransitionScope: SharedTransitionScope,
animatedContentScope: AnimatedContentScope,
// 1
+ lastDisplayedPage: Int?
) {
val lazyGridState: LazyGridState = rememberLazyGridState()
// 1
+ LaunchedEffect(lastDisplayedPage) {
+ lastDisplayedPage?.let {
+ // LazyVerticalGridの高さ
+ val viewPortHeight =
+ lazyGridState.layoutInfo.viewportEndOffset - lazyGridState.layoutInfo.viewportStartOffset
+ // 全体が見えてる先頭の項目のIndex
+ val firstVisibleItemIndex = lazyGridState.layoutInfo.visibleItemsInfo.run {
+ first { lazyGridItemInfo ->
+ lazyGridItemInfo.offset.y >= 0
+ }.index
+ }
+ // 全体が見えている最後尾の項目のIndex
+ val lastVisibleItemIndex = lazyGridState.layoutInfo.visibleItemsInfo.run {
+ reversed().first { lazyGridItemInfo ->
+ val itemBottom = lazyGridItemInfo.offset.y + lazyGridItemInfo.size.height
+ (itemBottom <= viewPortHeight)
+ }.index
+ }
+ // LazyVerticalGridの列数
+ val firstItemIndex = lazyGridState.layoutInfo.visibleItemsInfo.first().index
+ val firstItemOffset = lazyGridState.layoutInfo.visibleItemsInfo.first().offset.y
+ val columCount = lazyGridState.layoutInfo.visibleItemsInfo.run {
+ first { lazyGridItemInfo ->
+ lazyGridItemInfo.offset.y != firstItemOffset
+ }.index - firstItemIndex
+ }
+ if (it < firstVisibleItemIndex) {
+ // 先頭の項目よりも前の項目が表示されていた場合
+ lazyGridState.scrollToItem(
+ it, 0
+ )
+ } else if (it > lastVisibleItemIndex) {
+ // 最後尾の項目よりも後の項目が表示されていた場合
+ lazyGridState.scrollToItem(
+ lazyGridState.firstVisibleItemIndex + it - lastVisibleItemIndex + columCount - 1,
+ lazyGridState.firstVisibleItemScrollOffset
+ )
+ }
+ // 2
+ // 保存されていた値を削除する
+ navController.currentBackStackEntry?.savedStateHandle?.set("lastDisplayedPage", null as Int?)
+ }
+ }
LazyVerticalGrid(
modifier = Modifier.fillMaxSize(),
columns = GridCells.Adaptive(120.dp),
state = lazyGridState
) {
// ...
}
}
一覧画面へ戻った時、表示対象の項目が画面内に無かった場合、画面内に表示される位置まで自動的にスクロールし、アニメーションも行われるようになりました。
問題点2への対応
Modifier.sharedElementWithCallerManagedVisibility()
を使って、アニメーションを行う項目の表示非表示を制御してみたのですが、行きと帰りでアニメーションを行う項目が異なる場合もあって、思ったような動作をさせることができませんでした。
今回は、アニメーション中はページ送りを行えないようにする、ページ送り中は元画面へ遷移できないようにする、という方法で対応します。
アニメーション中はHorizontalPager
をページ送りできないようにする
アニメーション中かどうかは、SharedTransitionScope.isTransitionActive
で判定できます。
+ val userScrollEnabled = !sharedTransitionScope.isTransitionActive
HorizontalPager(
state = pagerState,
+ userScrollEnabled = userScrollEnabled
) {
// ...
}
HorizontalPager
でページ送り中は元画面へ遷移できないようにする
ページ送り中かどうかは、PagerState.currentPageOffsetFraction
で判定できます。
+ val isPagingRunning by remember(pagerState.currentPageOffsetFraction) {
+ derivedStateOf {
+ pagerState.currentPageOffsetFraction != 0f
+ }
+ }
// ...
BackHandler(enabled = true) {
+ if (!isPagingRunning) {
navController.previousBackStackEntry?.savedStateHandle?.set(
"lastDisplayedPage",
pagerState.currentPage,
)
navController.popBackStack()
+ }
}
終わりに
ListToDetailに項目間のアニメーションを追加してみました。公式サイトにある内容だけでは、実際に使ってみると問題が発生する場合もあり、それらの対応も行ってみました。ただし、問題点2の対応に関しては、操作不能時間が発生したり、最善の対応とは言えません。sharedElementWithCallerManagedVisibility
を使って、もっと上手い対応ができないか、調査を行いたいと思います。