Modifier.animateBoundsを使って実用的なレイアウト変更をアニメーションする


Jetpack Compose 1.8 で追加された`Modifier.animateBounds`は、レイアウト変更を滑らかにアニメーションできる便利な API です。
前回の記事([Compose Animation 1.8 で追加された Modifier.animateBounds を使ってみる](https://qiita.com/hiro404/items/b6c31341ef1f8d9e0e1f))では、基本的な使い方を紹介しましたが、ただ使ってみたというだけの、どこで使うUIなんだというものでした。今回はより実践的な UI 例と、実装時の工夫・注意点をまとめます。
なお、前回同様、画像にはAI生成画像を使っています。


# 目次
- [実践的な UI 実装例](#実践的な-ui-実装例)
- [サンプルコード](#サンプルコード)
- [Modifier.animateBoundsの概要](#modifieranimateboundsの概要)
- [実装時の注意点](#実装時の注意点)
  - [アニメーションの適用対象を考える](#アニメーションの適用対象を考える)
  - [開発向けオプションの Animator再生時間スケールを変える](#開発向けオプションの-animator再生時間スケールを変える)
- [まとめ](#まとめ)

## 実践的な UI 実装例

例えば、アプリでよく見かける「リスト表示とグリッド表示の切り替え」をアニメーション付きで実現したい場合、`Modifier.animateBounds`が非常に役立ちます。
アニメーションはありませんが、[DailyArt](https://play.google.com/store/apps/details?id=com.moiseum.dailyart2&hl=ja)というアプリでこのUIを見かけたので、興味があればご覧ください。

- List 表示時は画像＋テキスト、Grid 表示時は画像のみを表示
- レイアウト切り替え時に、画像やテキストの位置・サイズが滑らかに変化

実際の動きは以下の GIF で確認できます。


![test6.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1165483/c311abc5-a723-4007-87e3-13eed7c6ae32.gif)


## サンプルコード

完全なサンプルは[こちら](https://github.com/hiroaki404/PublicPlayground/blob/main/app/src/main/java/com/example/publicplayground/shared_element_transition_and_animateBounds/GridAndListBirdScreen.kt)にあります。
以下は、Grid/List 切り替え部分の抜粋です（切り替えボタン等は省略しています）。

```kotlin
var isGrid by remember { mutableStateOf(true) }

val items = remember {
    movableContentWithReceiverOf<LookaheadScope, Boolean> { isGrid ->
        val imageWidth = (LocalConfiguration.current.screenWidthDp - 48) / 2
        birds.forEach { bird ->
            Row(
                modifier = Modifier
                    .animateBounds(lookaheadScope = this@movableContentWithReceiverOf)
            ) {
                AsyncImage(
                    model = ImageRequest.Builder(LocalContext.current)
                        .data(bird.imageResId)
                        .build(),
                    contentDescription = bird.name,
                    modifier = Modifier
                        .size(if (isGrid) imageWidth.dp else 128.dp)
                        .animateBounds(lookaheadScope = this@movableContentWithReceiverOf)
                        .clip(RoundedCornerShape(8.dp))
                )
                AnimatedVisibility(
                    !isGrid,
                ) {
                    Column(
                        modifier = Modifier
                            .padding(8.dp)
                            .animateBounds(lookaheadScope = this@movableContentWithReceiverOf),
                    ) {
                        Text(
                            text = bird.id.toString(),
                            style = MaterialTheme.typography.bodyMedium,
                            modifier = Modifier.padding(start = 8.dp),
                        )
                        Text(
                            text = bird.name,
                            style = MaterialTheme.typography.titleMedium,
                            modifier = Modifier.padding(start = 8.dp),
                        )
                    }
                }
            }
        }
    }
}

LookaheadScope {
    if (isGrid) {
        FlowRow(
            modifier = Modifier
                .fillMaxSize()
                .padding(innerPadding)
                .padding(top = 16.dp, start = 16.dp),
            horizontalArrangement = Arrangement.spacedBy(16.dp),
            verticalArrangement = Arrangement.spacedBy(16.dp),
            maxItemsInEachRow = 2
        ) {
            items(isGrid)
        }
    } else {
        Column(
            modifier = Modifier
                .fillMaxSize()
                .padding(innerPadding)
                .padding(16.dp),
            verticalArrangement = Arrangement.spacedBy(16.dp)
        ) {
            items(isGrid)
        }
    }
}
```

- `LookaheadScope`内で`Modifier.animateBounds`を使うことで、レイアウト変更時のアニメーションが可能になります。
- 子要素の再構成を避けるために`movableContentWithReceiverOf`を利用しています。
- テキスト部分の表示/非表示は`AnimatedVisibility`で切り替えています。

`movableContentWithReceiverOf`では、その中でisGridのフラグを受け取れるようにジェネリクスを指定しています。
もう少しコードに改善の余地があるかもですが、とりあえずこれで違和感なく動きます。

## `Modifier.animateBounds`の概要

さて、実装上の注意に入る前に簡単に`Modifier.animateBounds`の概要を説明します。
本格的なしくみの説明は別の記事を書こうかなと思っています。

`Modifier.animateBounds`を使うには`LookaheadScope`が必要でした。
`LookaheadScope`は、レイアウトの変更を事前に検知し、アニメーションを適用するためのスコープです。

lookaheadとは、先読み、の意味です。これから起こり得るレイアウト変更を先読みして、`animateBounds`がついたコンポーネントのサイズや位置を徐々にアニメーションで変化させる、という理解です。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1165483/15b8c157-6ddd-419d-b06f-c893804d4c1f.png)

`Modifier.animateBounds`をつけると、その直接の子にレイアウト変更のアニメーションが適用されます。
また`Modifier.animateBounds`をつけたコンポーネントに、別のModifierチェーンでサイズ変更などをしてたりすると、その変化もアニメーションされます。
その場合適用順番は大事で、`animateBounds`をつけた要素にはその前につけていたModifierチェーンの変更しかアニメーションが適用されなかったり、親のコンポーネントの同じ階層にあるコンポーネントへの影響があったりするので、注意が必要です。

(適用順に関しては[こちらの公式ページ](https://developer.android.com/reference/kotlin/androidx/compose/animation/package-summary#(androidx.compose.ui.Modifier).animateBounds(androidx.compose.ui.layout.LookaheadScope,androidx.compose.ui.Modifier,androidx.compose.animation.BoundsTransform,kotlin.Boolean))に記載がありますが、また別記事で書こうと思います。)

## 実装時の注意点

### アニメーションの適用対象を考える

アニメーションの適用対象について考えます。
サンプルではList表示の時に表示されるテキストをラップするColumnに、`animateBounds`が指定されています。
これを外してみましょう。

```kotlin
AnimatedVisibility(
    !isGrid,
) {
    Column(
        modifier = Modifier
            .padding(8.dp) // ここにanimateBoundsを指定していない
    ) {
        Text(
            text = bird.id.toString(),
            style = MaterialTheme.typography.bodyMedium,
            modifier = Modifier.padding(start = 8.dp),
        )
        Text(
            text = bird.name,
            style = MaterialTheme.typography.titleMedium,
            modifier = Modifier.padding(start = 8.dp),
        )
    }
```

Grid->List表示に変わるときのテキストの表示に注目してください。
ここで、レイアウト変更の際、テキストが短い幅で表示しきれないときに2行になろうとして、変な感じになってしまいます。
以下のようなアニメーションになります。一瞬2行表示されているのが分かるでしょうか？
(アニメーションのスピードを、後述の開発向けオプションで変更しています。)

![test9.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1165483/f6ffa3d2-45a9-469f-8a3b-c3cf38c24a85.gif)

なぜこうなるかというと、Columnの子であるテキストがアニメーションの対象となっていないからです。

順を追って説明します。Grid -> List切替時にテキストがアニメーションで徐々に現れますが、
アニメーション方法としては、デフォルトの`fadeIn() + expandHorizontally()`が適用されます。

expandHorizontallyはコンポーネントのサイズが変わります。
Android Developer公式からの引用ですが、以下のような感じです。(参考->https://developer.android.com/develop/ui/compose/animation/composables-modifiers?hl=ja)

![Animation Example](https://developer.android.com/static/develop/ui/compose/images/animation-expandhorizontally.gif?hl=ja)

これはColumnの親であるRowに`Modifier.animateBounds`を指定しているので、その子であるColumnはアニメーションが適用され、サイズが徐々に変化します。
一方Columnには`Modifier.animateBounds`を指定していないので、テキスト自体はアニメーションの対象となっておらず、非表示状態から瞬時に幅いっぱいになります。
狭い幅のColumnの領域内に幅いっぱいのテキストを入れようとしていたため、テキストが2行になろうとしていました。

ポイントは、`Modifier.animateBounds`をLayoutに指定することで、その直接の子の配置やサイズがアニメーションの対象になることです。
なのでここは、Textを包含するColumnに`animateBounds()`を指定することで、テキストのComposableの部分のレイアウト変更もアニメーションの対象になります。

アニメーションはビジュアル的なものであり、動かしてみないとわからないと思うので、
いろいろ動かしながら調整するのが良いと思います。
ある程度動かすくらいなら、頭で理解するよりも、そちらの方が速いと思います。
ちょっと動かしてみて、その上でどういう理屈でそうなっているか考えると良いのではないかと思います。

ちょっとこの辺自分も迷うところがあるので、もう少し整理させてもらって、改めて記事を書こうと思います。

### 開発向けオプションの Animator再生時間スケールを変える

さて、アニメーションは早くて、デバッグしても違いが分かりづらい、ということがあると思います。
そういうときは、開発者向けオプションのAnimator再生時間スケールを変えるといいです。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1165483/d6a8c7c7-1920-4dba-af21-71356604b325.png" alt="7.png" width="300px">

ゆっくりアニメーションされるので、デバッグしやすくなります。

スケールはオフ、0.5x、1x、1.5x、2x、5x、10xの7種類から選べます。

## まとめ

`Modifier.animateBounds`を使うことで、リストやグリッドなど複数レイアウト間の切り替えを滑らかにアニメーションできます。
実際のアプリ要件でも活用できる場面が多いので、ぜひ試してみてください。