Compose Animation 1.8で追加されたModifier.animateBoundsを使ってみる


Compose1.8がリリースされました。TextのAutoSizeが追加されて話題となっていますが、本記事では`Modifier.animateBounds()`について紹介します。
レイアウト変更のアニメーションが簡単にできるようになり、アニメーションの幅が広がりました。

本記事では、`Modifier.animateBounds()`の基本概念と、実際に使用したサンプルコードを紹介します。

## Modifier.animateBoundsとは

`Modifier.animateBounds()`はコンポーネントのサイズと位置の変化をアニメーションさせることができます。

これまではサイズの変化をアニメーションするだけなら`Modifier.animateContentSize()`を使えば良かったのですが、位置の変化のアニメーションは簡単にはできませんでした。
[LookaheadScopeをつかて実装する例](https://proandroiddev.com/animations-with-lookahead-in-jetpack-compose-60423fe0d1a7)もありましたが、カスタムレイアウトで出てくる`placeable`とか`mesureable`みたいなAPIを使う必要があり、サクッ作れる感じではなかったです。

しかし`Modifier.animateBounds()`を使えば簡単にできるようになります。

下記例は画像の位置とサイズをアニメーションさせています。(画像にはAI生成画像を使っています。)

![test4 (1).gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1165483/9ba5ac0f-e3e4-486d-9d49-00243a0b98bc.gif)


```kotlin
LookaheadScope {
    var expanded by remember { mutableStateOf(false) }
    Column(
        modifier = Modifier
            .fillMaxSize()
            .padding(paddingValues)
            .padding(16.dp),
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.spacedBy(16.dp)
    ) {
        Image(
            modifier = Modifier
                .offset(
                    x = if (expanded) 0.dp else 55.dp,
                    y = if (expanded) 0.dp else 50.dp
                )
                .size(if (expanded) 200.dp else 30.dp)
                .animateBounds(
                    this@LookaheadScope,
                    boundsTransform = BoundsTransform { _, _ ->
                        tween(2000)
                    }),
            painter = painterResource(id = R.drawable.shimaenaga),
            contentDescription = null
        )

        Button(onClick = { expanded = !expanded }) {
            Text(
                text = stringResource(
                    id = if (expanded) R.string.quiz_button_reset
                    else R.string.quiz_button_initial
                )
            )
        }

        if (expanded) {
            Text(text = stringResource(id = R.string.quiz_answer))
        }
    }
}
```

完全なコードは[こちら](https://github.com/hiroaki404/PublicPlayground/blob/main/app/src/main/java/com/example/publicplayground/shared_element_transition_and_animateBounds/QuizScreen.kt)です。

## LookaheadScopeの解説

あまり聞き馴染みのない言葉だと思いますが、*lookahead*とは、「先読み」みたいな意味です。
[ドキュメント](https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/layout/LookaheadScope.kt;l=284?q=file:androidx%2Fcompose%2Fui%2Flayout%2FLookaheadScope.kt%20class:androidx.compose.ui.layout.LookaheadScope#:~:text=%5BLookaheadScope%5D%20creates%20a,reach%20the%20destination.)には以下のような説明があります。

> [LookaheadScope] creates a scope in which all layouts will first determine their destination layout through a lookahead pass, followed by an _approach_ pass to run the measurement and placement approach defined in [approachLayout] or [ApproachLayoutModifierNode], in order to gradually reach the destination.

要するに、`LookaheadScope`はまず変化前と変化後のレイアウトを決定し、その間のパスを計算して、アニメーションを実現しているという理解です。

`Modifier.animateBounds()`は、`LookaheadScope`の上に構築されています。

`LookaheadScope`をそのまま使うのは上に書いたように少し難しさがあったのですが、`Modifier.animateBounds()`は簡単にレイアウト変更のアニメーションを実現できます。

## movableContentOfまたはmovableContentWithReceiverOf<>と組み合わせていろいろなレイアウト変更をアニメーションする

`Modifier.animateBounds()`を使っていろいろなレイアウト間の変更をアニメーションすることができます。
しかしここで抑えておきたいのは、`movableContentOf`または`movableContentWithReceiverOf<>`です。

上記APIが必要になってくるのは、`Modifier.animateBounds()`を使っている要素が、RowやColumnなどのレイアウトの中にある場合で、レイアウトが切り替わる場合です。

たとえばColumnを使ったレイアウトをRowのレイアウトに置き換える場合です。
普通に切り替えた場合、ColumnからRowに切り替わるとき、子の要素が再Compositionされてしまうので、うまくアニメーションできません。
そのため、`movableContentOf`または`movableContentWithReceiverOf<>`を使うことで、子の要素を再Compositionせずに維持します。


(画像にはAI生成画像を使っています。)

![test5.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1165483/b7d0d6b6-ddc0-430e-b22d-058e878be850.gif)


```kotlin
LookaheadScope {
    var expanded by remember { mutableStateOf(false) }
    val toggleExpanded = { expanded = !expanded }
    val imageContent = remember {
        movableContentWithReceiverOf<LookaheadScope, Boolean> { expanded ->
            AsyncImage(
                model = R.drawable.camera,
                contentDescription = "カメラ画像",
                modifier = Modifier
                    .size(if (expanded) 400.dp else 200.dp)
                    .animateBounds(lookaheadScope = this)
                    .clickable {
                        toggleExpanded()
                    }
            )
        }
    }

    val cardContent = remember {
        movableContentWithReceiverOf<LookaheadScope> {
            Card(
                modifier = Modifier
                    .fillMaxWidth()
                    .animateBounds(lookaheadScope = this),
            ) {
                SpecText()
            }
        }
    }

    if (expanded) {
        Column(
            horizontalAlignment = Alignment.CenterHorizontally,
            verticalArrangement = Arrangement.spacedBy(16.dp)
        ) {
            imageContent(true)
            cardContent()
        }
    } else {
        Row(
            horizontalArrangement = Arrangement.spacedBy(16.dp),
        ) {
            imageContent(false)
            cardContent()
        }
    }
}
```

完全なコードは[こちら](https://github.com/hiroaki404/PublicPlayground/blob/main/app/src/main/java/com/example/publicplayground/shared_element_transition_and_animateBounds/CameraSpecScreen.kt)です。

レイアウト変更のアニメーションがとても簡単にできるようになりました。
アプリのいろいろなUIで使い所がありそうですね。