LoginSignup
1

Material design 3 のContainerTransitionをComposeで再現する

Last updated at Posted at 2023-05-20

Container Transition

ezgif-5-7fbe0f647e.gif

記憶違いでなければdesign 2 の頃からあったこのアニメーション。ComposeがPlannedのステータスのまま、今年のIOでも(モーション拘るっていう趣旨の発言がありつつも)特に更新がなかったやつです。

作ってみた

animate_container.gif

結論

細かいところはあまり考えてないです。
transition後のスクリーンの要件次第ですが、意外と基本的なアニメーション部品の組み合わせでできちゃってます。 が公式待った方がよいかなって所感です。

やろうと思えば汎用的にできそうですが、必要になったらやります。(ちゃんと探せばいいライブラリとかありそう。。)

コードは以下。

ざっくり解説

アプローチ

Rowのなかの1個のComposableから、どうやって画面全体に広げるか考えたところ以下が浮かびました。

  1. タップされたComposableの座標(offset)とサイズ()を取得
  2. 1をつかって、同じ箇所に同じLayoutのComposableをまず作る
  3. AnimatedVisibilityとTransitionでそいつをひろげてく

単純に画像だけ広げてみたのが以下です。

animate_container2.gif

コード(抜粋

data class TargetViewInfo(
    val topLeft: DpOffset = DpOffset(0.dp,0.dp),
    val width: Dp = 0.dp,
    val height: Dp = 0.dp,
    val data: SampleData = SampleData()
)
...
// Image or Box
.onGloballyPositioned {
    with(density) {
        targetViewInfo = targetViewInfo.copy(
            topLeft = DpOffset(
                // TODO Fix : Rootからだと、親Composeの影響を受けそう
                x = it.positionInRoot().x.toDp(),
                y = it.positionInRoot().y.toDp()
            ),
            width = it.size.width.toDp(),
            height = it.size.height.toDp()
        )
    }
}

onGloballyPositionedはサクッとComposableの情報取ってくるのに便利。リコンポーズが走るので多用はNGなきがしますが、、
とりあえず上記で1 をやってます。

@Composable
fun SampleContainerAnimationBox(
    data : TargetViewInfo,
    isVisible: Boolean,
    onBackPressed: () -> Unit = {},
    content: @Composable BoxScope.() -> Unit = {},
    transitionDuration: Int = 500
) {
    val screenWidth = LocalConfiguration.current.screenWidthDp
    val screenHeight = LocalConfiguration.current.screenHeightDp
    AnimatedVisibility(
        visible = isVisible,
        enter = EnterTransition.None,
        exit = ExitTransition.None
    ) {
        /*
        各アニメーション値をAnimatedVisibilityScopeのtransitionで定義することで
        animatedVisibilityの表示非表示のタイミングをアニメーション値の生存期間と一致させる
         */
        val width by transition.animateDp(
            transitionSpec = { tween(durationMillis = transitionDuration) },
            label = "content width"
        ) { state -> if (state == EnterExitState.Visible) screenWidth.dp else data.width }
        val height by transition.animateDp(
            transitionSpec = { tween(durationMillis = transitionDuration) },
            label = "content height"
        ) { state -> if (state == EnterExitState.Visible) screenHeight.dp else data.height }
        val offsetX by transition.animateDp(
            transitionSpec = { tween(durationMillis = transitionDuration) },
            label = "content offset x"
        ) { state -> if (state == EnterExitState.Visible) 0.dp else data.topLeft.x }
        val offsetY by transition.animateDp(
            transitionSpec = { tween(durationMillis = transitionDuration) },
            label = "content offset y"
        ) { state -> if (state == EnterExitState.Visible) 0.dp else data.topLeft.y }
        Box(
            modifier = Modifier
                .size(width = width, height = height)
                .offset(offsetX, offsetY)
                .background(Color.White)
        ) {
            ImageContainerScreen(
                res = data.data.img,
                transitionDuration = transitionDuration
            )
        }
    }

    BackHandler {
        onBackPressed()
    }
}

あとは取得したサイズや座標をアニメーションでラップしてBoxにそれぞれ指定するだけです。 2,3 。

AnimatedVisibilityScope.transitionからアニメーション値を設定してAnimatedVisibility自体にはNoneを設定してます。
これは元画面に戻る時にComposableをアニメーションしつつ消すって時に便利で、こうしておくとAnimatedVisivility配下で設定したtransition.animate*asStateのアニメーションが全て終了した時にComposableが削除されるように動いてくれるので、よけいな表示非表示の状態管理を考えなくて済むのがよところです。

ImageContainerScreen
この中身はいくらでも変えられるので、そこそこ柔軟な部品になりそうではある。

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
1