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

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

![ListToDetail_001-ezgif.com-video-to-gif-converter.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/115846/06766db2-8328-29b1-39e2-95f370dec146.gif)

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

[Compose の共有要素遷移](https://developer.android.com/develop/ui/compose/animation/shared-elements?hl=ja)

1. アニメーションを行う`Composable`を`SharedTransitionLayout {}`で囲う
2. 設定に必要な`SharedTransitionScope`(`SharedTransitionLayout`の`this`)と`AnimatedContentScope`(`composable`の`this`)を`Composable`へ渡す
3. `Modifier.sharedElement()`は`SharedTransitionScope`スコープの拡張関数なので、使用できるよう、共有アニメーションを行う`Composeble`を`with(sharedTransitionScope) {}`で囲う
4. アニメーションを行う`Composeble`の`Modifier`に`sharedElement()`を追加し、共有する要素間で同一の一意な値を`key`に指定して`rememberSharedContentState`を使って作成した値を`state`に設定する
5. `SharedTransitionApi`は`Experimental`なので、使用箇所に`@OptIn(ExperimentalSharedTransitionApi::class)`アノテーションを追加する

```diff_kotlin: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
                )
            }
        }
+    }
}
```
```diff_kotlin: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",
                        )
+                    }
                }
            }
        }
    }
}
```
```diff_kotlin: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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/115846/a9d5e4a1-d7d4-22f0-0416-a64c262614b2.gif)


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

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

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

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

## popBackStackで、元の画面に値を渡す
[前のデスティネーションに結果を返す](https://developer.android.com/guide/navigation/use-graph/programmatic?hl=ja#returning_a_result)

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

1. 詳細画面の`BackHandler`で、呼び出し元の`NavBackStackEntry`の`SavedStateHandle`に現在のページを設定し、画面遷移を行う
2. `NavHost`の`composable`内で、`savedStateHandle`から値を取得し、一覧画面へ渡す

```diff_kotlin: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()
    }
}
```
```diff_kotlin: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. 再コンポーズ時に再度スクロールしないようデータを削除します
```diff_kotlin: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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/115846/9e39ca40-d4ef-5683-1c07-0e46969ad1bc.gif)



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

## アニメーション中は`HorizontalPager`をページ送りできないようにする
アニメーション中かどうかは、`SharedTransitionScope.isTransitionActive`で判定できます。
```diff_kotlin:DetailView.kt
+    val userScrollEnabled = !sharedTransitionScope.isTransitionActive

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

## `HorizontalPager`でページ送り中は元画面へ遷移できないようにする
ページ送り中かどうかは、`PagerState.currentPageOffsetFraction`で判定できます。
```diff_kotlin: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`を使って、もっと上手い対応ができないか、調査を行いたいと思います。