0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

List to Detail(with Pager)に画面遷移時のアニメーションを追加する。

Posted at

目標

下の動画は、JetpackComposeで作られたよくあるListToDetailの画面です。
こちらを画面遷移時に項目間のアニメーションが行われるようにしていきます。

ListToDetail_001-ezgif.com-video-to-gif-converter.gif

アニメーションの追加

ListToDetailに、アニメーションを追加する手順を説明します。
基本的には下記の公式サイトの手順通りです。

Compose の共有要素遷移

  1. アニメーションを行うComposableSharedTransitionLayout {}で囲う
  2. 設定に必要なSharedTransitionScope(SharedTransitionLayoutthis)とAnimatedContentScope(composablethis)をComposableへ渡す
  3. Modifier.sharedElement()SharedTransitionScopeスコープの拡張関数なので、使用できるよう、共有アニメーションを行うComposeblewith(sharedTransitionScope) {}で囲う
  4. アニメーションを行うComposebleModifiersharedElement()を追加し、共有する要素間で同一の一意な値をkeyに指定してrememberSharedContentStateを使って作成した値をstateに設定する
  5. SharedTransitionApiExperimentalなので、使用箇所に@OptIn(ExperimentalSharedTransitionApi::class)アノテーションを追加する
MainContent.kt
// 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
                )
            }
        }
+    }
}
ListView.kt
// 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",
                        )
+                    }
                }
            }
        }
    }
}
DetailView.kt
// 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"
                    )
+                }
            }
        }
    }
}

画面遷移時、アニメーションが行われるようになりました。

ListToDetail_002-ezgif.com-video-to-gif-converter.gif

問題点

公式サイトの手順に従って実装し、画面遷移時にアニメーションが行われるようになりましたが、実際に操作を行っていると、いくつかの問題点が見つかりました。

  1. 詳細画面で、一覧画面のLazyVerticalGridで画面内に表示されていない要素まで移動をしてから画面遷移すると、アニメーションが行われない
  2. 詳細画面へのアニメーション中にページ送り、詳細画面でページ送り中に元の画面への遷移、を行うとアニメーションが乱れる

これらの問題への対応を行います。

問題点1への対応

詳細画面から一覧画面へ画面遷移する際、詳細画面で表示していたページを一覧画面へ渡し、画面内に表示されていなかった場合は、画面内に表示されるようLazyVerticalGridをスクロールさせます。

popBackStackで、元の画面に値を渡す

前のデスティネーションに結果を返す

こちらの手順に従って、元の画面に値を渡す処理を追加します。公式サイトの例ではLiveDataを使っていますが、StateFlowでも取得できるので、今回はそちらを使っていきます。

  1. 詳細画面のBackHandlerで、呼び出し元のNavBackStackEntrySavedStateHandleに現在のページを設定し、画面遷移を行う
  2. NavHostcomposable内で、savedStateHandleから値を取得し、一覧画面へ渡す
DetailView.kt
@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()
    }
}
MainContent.kt
@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内に表示されるようスクロールさせる

  1. 色々やっていますが、指定された項目がLazyVerticalGrid内に表示されるよう、LazyGridStateを使ってスクロールさせています
    ※固定ヘッダーやフッターを使っていると、上手く動作しません、適宜修正してください
  2. 再コンポーズ時に再度スクロールしないようデータを削除します
ListView.kt
@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
    ) {
        // ...
    }
}

一覧画面へ戻った時、表示対象の項目が画面内に無かった場合、画面内に表示される位置まで自動的にスクロールし、アニメーションも行われるようになりました。

ListToDetail_003-ezgif.com-video-to-gif-converter.gif

問題点2への対応

Modifier.sharedElementWithCallerManagedVisibility()を使って、アニメーションを行う項目の表示非表示を制御してみたのですが、行きと帰りでアニメーションを行う項目が異なる場合もあって、思ったような動作をさせることができませんでした。
今回は、アニメーション中はページ送りを行えないようにする、ページ送り中は元画面へ遷移できないようにする、という方法で対応します。

アニメーション中はHorizontalPagerをページ送りできないようにする

アニメーション中かどうかは、SharedTransitionScope.isTransitionActiveで判定できます。

DetailView.kt
+    val userScrollEnabled = !sharedTransitionScope.isTransitionActive

    HorizontalPager(
        state = pagerState,
+        userScrollEnabled = userScrollEnabled
    ) {
        // ...
    }

HorizontalPagerでページ送り中は元画面へ遷移できないようにする

ページ送り中かどうかは、PagerState.currentPageOffsetFractionで判定できます。

DetailView.kt
+    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を使って、もっと上手い対応ができないか、調査を行いたいと思います。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?