🏃

Jetpack ComposeのAnimatableでタイムラインアニメーションを実装する

2025/01/27に公開

Jetpack Composeでちょっとしたアニメーションを実装する機会があったので、そちらについて簡単にまとめてみます。

アニメーションをアプリに組み込む場合、手実装する以外にもLottieやAPNGなど、より工数のかからない方法もありますが、以下のような理由から手実装を選ぶこともあると思います。

  • 少しでもAPKのサイズを削りたい
  • 他のViewとの高度な連携が必要
  • テキストにパラメータを入れ込むなどの動的な要素が多い

Jetpack Composeにはいくつものアニメーション関連APIがありますが、表示時のトランジションや単一のプロパティのアニメーションにフォーカスしたものが多く、少し込み入ったアニメーションを実装しようと思った場合、より低レベルなAPIを選択する必要があります。
Flashアニメーションのような、複数の要素をタイムラインに沿って複数同時に制御するアニメーションの実装を目的とする場合はAnimatableを選ぶのが良いと思います。

以下のユースケースに該当します。
https://developer.android.com/develop/ui/compose/animation/choose-api?hl=ja#:~:text=いいえ%3A animateTo が異なるタイミングで呼び出される Animatable(suspend 関数を使用)

アニメーションするComposable関数の定義

@Composable
fun Logo(
    animationId: Int,
    onClick: () -> Unit,
) {
    val logoMaxWidth = 96.dp
    val iconRotation = remember { Animatable(0f) }
    val logoAlpha = remember { Animatable(1f) }
    val logoWidth = remember { Animatable(0f) }

    Effect(
        animationId,
        iconRotation,
        logoAlpha,
        logoWidth,
        logoMaxWidth.value,
    )

    Row(
        verticalAlignment = Alignment.CenterVertically,
    ) {
        Image(
            painter = painterResource(id = R.drawable.ic_android_black_24dp),
            contentDescription = "logo icon",
            modifier = Modifier
                .size(36.dp)
                .graphicsLayer(
                    rotationZ = iconRotation.value,
                )
                .clickable { onClick() }
        )
        Image(
            painter = painterResource(id = R.drawable.andlogo),
            contentDescription = "logo text",
            contentScale = ContentScale.FillHeight,
            modifier = Modifier
                .alpha(logoAlpha.value)
                .width(logoWidth.value.dp)
        )
    }
}

アニメーションさせたいプロパティごとにAnimatableを初期化し、modifierなどにそれぞれ渡します。
今回はgraphiscsLayerのrotationZ、alpha、widthに渡しています。
停止制御が必要な場合は、LaunchedEffectのキーで分岐してstopを呼ぶなどするといいと思います。

アニメーションの定義

@Composable
fun Effect(
    animationId: Int,
    iconRotation: Animatable<Float, AnimationVector1D>,
    logoAlpha: Animatable<Float, AnimationVector1D>,
    logoWidth: Animatable<Float, AnimationVector1D>,
    logoMaxWidth: Float,
) {
    LaunchedEffect(animationId) {
        if (animationId > 0) {
            launch {
                iconRotation.run {
                    snapTo(0f)
                    animateTo(
                        targetValue = 360f,
                        animationSpec = tween(
                            delayMillis = 100,
                            durationMillis = 800,
                            easing = FastOutSlowInEasing,
                        ),
                    )
                }
            }
            launch {
                logoAlpha.run {
                    snapTo(1f)
                    animateTo(
                        targetValue = 0f,
                        animationSpec = tween(
                            delayMillis = 1200,
                            durationMillis = 800,
                            easing = FastOutSlowInEasing,
                        ),
                    )
                }
            }
            launch {
                logoWidth.run {
                    snapTo(0f)
                    animateTo(
                        targetValue = logoMaxWidth,
                        animationSpec = tween(
                            delayMillis = 200,
                            durationMillis = 400,
                            easing = FastOutSlowInEasing,
                        ),
                    )
                    animateTo(
                        targetValue = 0f,
                        animationSpec = tween(
                            delayMillis = 1999,
                            durationMillis = 1,
                        ),
                    )
                }
            }
        }
    }
}

LaunchedEffectからAnimatableを起動します。Animatableは使用感としてはValueAnimatorに近いです。
値の即時変更にはsnapTo、段階的な値の更新にはanimateToを使います。
animateToはアニメーションが完了するまでsuspendするので、複数タイムラインを同時に制御する場合はタイムラインごとに個別にlaunchしてやる必要があります。

使用

@Composable
@Preview(showBackground = true)
fun LogoPreview() {
    var animationId: Int by remember { mutableStateOf(0) }

    Box(
        modifier = Modifier
            .fillMaxWidth()
    ) {
        Logo(
            animationId,
            onClick = {
                animationId += 1
            },
        )
    }
}

アニメーションのLaunchedEffectに渡しているキーを更新することでアニメーションを開始します。

結果

株式会社ソニックムーブ

Discussion