Compose Animation:animate*AsState

投稿日:  更新日:

Jetpack Composeが提供するアニメーションAPIは非常に充実しています。

「どのAPIを使えば最適なのか?」と、その選択を迷うくらいに数が多いです。ドキュメントは、「〇〇占い」に登場するようなYes/Noの設問ツリーを掲載(アニメーションAPIを選択する)して、選択の手助け行っています。

ここでは、アニメーションAPIの中から「animate*AsState(*は型名が入る)」を取りあげて、まとめます。

animate*AsStateは、APIの中で最も汎用性があります。まず始めに抑えておくべきアニメーションAPIです。

※環境:Android Studio Iguana | 2023.2.1
    Kotlin 1.9.0
    Compose Compiler 1.5.1
    androidx.compose.animation:* 1.5.0

スポンサーリンク

animate*AsStateとは

Composable関数(UI要素)の状態を時々刻々と変更することで、表示されるコンテンツをアニメーション化できます。

animation*AsStateは、このアニメーション化するための状態(value)を作り出します。

例えば、下図はアイコンのx座標を変更することで、左から右へ移動するアニメーションです。

AnimateXAsStateの概要

スポンサーリンク

関数の動作

初回ComposeでtargetValueへ値(サンプルは20.dp)を新規設定しても、アニメーションは行われません。

イベント(サンプルはクリック)等によりtargetValueの値(サンプルは260.dp)が変更されると、前targetValueと現targetvalueの間でアニメーションが行われます。

そして、targetValueが変更される毎に、これを繰り返します。

AnimateXAsStateの動作

private val StartX = 20.dp
private val EndX = 260.dp

@Preview_mdpi
@Composable
private fun AnimateDpAsStateSample() {
    Box(
        modifier = Modifier
            .size(width = 320.dp, height = 100.dp)
            .background(color = Color(0xFFF0F0FF)),
        contentAlignment = Alignment.CenterStart
    ) {
        val _toggle = remember { mutableStateOf(false) }
        val _posX = animateDpAsState(
            targetValue = if(_toggle.value) EndX else StartX,
            animationSpec = tween(durationMillis = 1000)
        )
        Image(
            painter = painterResource(R.drawable.baseline_toys_black_36),
            contentDescription = null,
            modifier = Modifier
                .size(36.dp)
                .offset(x = _posX.value)
                .clickable { _toggle.value = !_toggle.value }
        )
    }
}

アニメーション中は状態(value:サンプルは_posX)が時々刻々と変更されるで、再Composeが行われて表示が更新されます。

つまり、アニメーション中は最小間隔(約1/60[s])で再Composeが連続実行される点に注意してください。

スポンサーリンク

関数の種類

animate*AsState関数は制御する値のタイプにより、次のような種類が準備されています。※サンプルは「Compose Animation:animate*AsState関数のサンプル」を参照

アニメーション関数タイプコメント
animateFloatAsStateFloat
animateIntAsStateInt
animateDpAsStateDp
animateOffsetAsStateOffsetx、yはFloat型
x、yは独立して制御可能
animateIntOffsetAsStateIntOffsetx、yはInt型
x、yは独立して制御可能
animateSizeAsStateSizewidth、heightはFloat型
width、heightは独立して制御可能
animateIntSizeAsStateIntSizewidth、heightはInt型
width、heightは独立して制御可能
animateColorAsStateColora、r、g、bは独立して制御可能
animateRectAsStateRectleft、top、right、bottomはFloat型
left、top、right、bottomは独立して制御可能
animateValueAsStateValue上記9つの原型になる関数

この中で、animateValueAsStateは原型になる関数です。他の関数はanimateValueAsStateのラッパー関数になります。

FloatIntDpOffsetIntOffsetSizeIntSizeColorRect
@Composable
fun animateFloatAsState(
    targetValue: Float,
    animationSpec: AnimationSpec<Float> = defaultAnimation,
    visibilityThreshold: Float = 0.01f,
    label: String = "FloatAnimation",
    finishedListener: ((Float) -> Unit)? = null
): State<Float> {
    val resolvedAnimSpec =
        if (animationSpec === defaultAnimation) {
            remember(visibilityThreshold) { spring(visibilityThreshold = visibilityThreshold) }
        } else {
            animationSpec
        }
    return animateValueAsState(
        targetValue,
        Float.VectorConverter,
        resolvedAnimSpec,
        visibilityThreshold,
        label,
        finishedListener
    )
}
@Composable
fun animateIntAsState(
    targetValue: Int,
    animationSpec: AnimationSpec<Int> = intDefaultSpring,
    label: String = "IntAnimation",
    finishedListener: ((Int) -> Unit)? = null
): State<Int> {
    return animateValueAsState(
        targetValue,
        Int.VectorConverter,
        animationSpec,
        label = label,
        finishedListener = finishedListener
    )
}

private val intDefaultSpring = spring(visibilityThreshold = Int.VisibilityThreshold)
@Composable
fun animateDpAsState(
    targetValue: Dp,
    animationSpec: AnimationSpec<Dp> = dpDefaultSpring,
    label: String = "DpAnimation",
    finishedListener: ((Dp) -> Unit)? = null
): State<Dp> {
    return animateValueAsState(
        targetValue,
        Dp.VectorConverter,
        animationSpec,
        label = label,
        finishedListener = finishedListener
    )
}

private val dpDefaultSpring = spring<Dp>(visibilityThreshold = Dp.VisibilityThreshold)
@Composable
fun animateOffsetAsState(
    targetValue: Offset,
    animationSpec: AnimationSpec<Offset> = offsetDefaultSpring,
    label: String = "OffsetAnimation",
    finishedListener: ((Offset) -> Unit)? = null
): State<Offset> {
    return animateValueAsState(
        targetValue,
        Offset.VectorConverter,
        animationSpec,
        label = label,
        finishedListener = finishedListener
    )
}

private val offsetDefaultSpring = spring(visibilityThreshold = Offset.VisibilityThreshold)
@Composable
fun animateIntOffsetAsState(
    targetValue: IntOffset,
    animationSpec: AnimationSpec<IntOffset> = intOffsetDefaultSpring,
    label: String = "IntOffsetAnimation",
    finishedListener: ((IntOffset) -> Unit)? = null
): State<IntOffset> {
    return animateValueAsState(
        targetValue,
        IntOffset.VectorConverter,
        animationSpec,
        label = label,
        finishedListener = finishedListener
    )
}

private val intOffsetDefaultSpring = spring(visibilityThreshold = IntOffset.VisibilityThreshold)
@Composable
fun animateSizeAsState(
    targetValue: Size,
    animationSpec: AnimationSpec<Size> = sizeDefaultSpring,
    label: String = "SizeAnimation",
    finishedListener: ((Size) -> Unit)? = null
): State<Size> {
    return animateValueAsState(
        targetValue,
        Size.VectorConverter,
        animationSpec,
        label = label,
        finishedListener = finishedListener
    )
}

private val sizeDefaultSpring = spring(visibilityThreshold = Size.VisibilityThreshold)
@Composable
fun animateIntSizeAsState(
    targetValue: IntSize,
    animationSpec: AnimationSpec<IntSize> = intSizeDefaultSpring,
    label: String = "IntSizeAnimation",
    finishedListener: ((IntSize) -> Unit)? = null
): State<IntSize> {
    return animateValueAsState(
        targetValue,
        IntSize.VectorConverter,
        animationSpec,
        label = label,
        finishedListener = finishedListener
    )
}

private val intSizeDefaultSpring = spring(visibilityThreshold = IntSize.VisibilityThreshold)
@Composable
fun animateColorAsState(
    targetValue: Color,
    animationSpec: AnimationSpec<Color> = colorDefaultSpring,
    label: String = "ColorAnimation",
    finishedListener: ((Color) -> Unit)? = null
): State<Color> {
    val converter = remember(targetValue.colorSpace) {
        (Color.VectorConverter)(targetValue.colorSpace)
    }
    return animateValueAsState(
        targetValue, converter, animationSpec, label = label, finishedListener = finishedListener
    )
}

private val colorDefaultSpring = spring<Color>()
@Composable
fun animateRectAsState(
    targetValue: Rect,
    animationSpec: AnimationSpec<Rect> = rectDefaultSpring,
    label: String = "RectAnimation",
    finishedListener: ((Rect) -> Unit)? = null
): State<Rect> {
    return animateValueAsState(
        targetValue,
        Rect.VectorConverter,
        animationSpec,
        label = label,
        finishedListener = finishedListener
    )
}

private val rectDefaultSpring = spring(visibilityThreshold = Rect.VisibilityThreshold)
@Composable
fun <T, V : AnimationVector> animateValueAsState(
    targetValue: T,
    typeConverter: TwoWayConverter<T, V>,
    animationSpec: AnimationSpec<T> = remember { spring() },
    visibilityThreshold: T? = null,
    label: String = "ValueAnimation",
    finishedListener: ((T) -> Unit)? = null
): State<T> {
    ...
    val channel = remember { Channel<T>(Channel.CONFLATED) }
    SideEffect {
        channel.trySend(targetValue)
    }
    LaunchedEffect(channel) {
        for (target in channel) {
            ...
            val newTarget = channel.tryReceive().getOrNull() ?: target
            launch {
                if (newTarget != animatable.targetValue) {
                    animatable.animateTo(newTarget, animSpec)
                    listener?.invoke(animatable.value)
                }
            }
        }
    }
    return toolingOverride.value ?: animatable.asState()
}
スポンサーリンク

関数の引数

animate*AsState関数は次のような引数を持ちます。

引数概要
targetValueT最終の到達値
animationSpecAnimationSpecアニメーションの形状
labelString
finishedListener(Int) -> Unit)? = nullアニメーション終了時に実行される
関数オブジェクト(ラムダ式)
visibilityThreshold(※1)T?アニメーション終了の閾値
typeConverter(※2)TwoWayConverterアニメエンジン出力(Float)
 ⇒制御する値のタイプ(T)の変換器
※T:Float/Int/Dp/Offset/IntOffset/Size/IntSize/Color/Rect
※1:animateFloatAsStateとanimateValueAsStateのみ
※2:animateValueAsStateのみ
スポンサーリンク

アニメーションの形状(animationSpec)

animationSpecはアニメーションの形状を指定します。

形状の種類

4種類の形状が準備されています。

形状概要補足
tween滑らかな曲線を描いて変化3次のベジェ曲線
springバネが振動するように変化バネの減衰比(振動の振幅)を選択可能
バネの硬さ(振動の速さ)を選択可能
keyframes折れ線を描いて変化
snap指定時間に即座に切り替え
tweenspringkeyframessnap
tweenの変化
springの変化
keyframesの変化
snapの変化

※詳細はリンクを参照
tween:「AnimationSpecでアニメの形状を指定(tween編)
spring:「AnimationSpecでアニメの形状を指定(spring編)
keyframes:「AnimationSpecでアニメの形状を指定(keyframes編)
snap:「AnimationSpecでアニメの形状を指定(snap編)

形状の違い(サンプル)

animationSpecのサンプルコード
@Preview
@Composable
private fun AnimationSpecSample() {
    Column {
        val _toggle = remember { mutableStateOf(false) }
        Spacer(modifier = Modifier.height(20.dp))
        // ----------------------------------------------------------
        Text(text = "[ tween ]")
        Box(
            modifier = Modifier
                .size(width = 320.dp, height = 50.dp)
                .background(color = Color(0xFFF0F0FF)),
            contentAlignment = Alignment.CenterStart
        ) {
            val _posX = animateDpAsState(
                targetValue = if(_toggle.value) EndX else StartX,
                animationSpec = tween(durationMillis = 1000)
            )
            Image(
                painter = painterResource(R.drawable.baseline_toys_black_36),
                contentDescription = null,
                modifier = Modifier
                    .size(36.dp)
                    .offset(x = _posX.value)
            )
        }
        // ----------------------------------------------------------
        Text(text = "[ spring ]")
        Box(
            modifier = Modifier
                .size(width = 320.dp, height = 50.dp)
                .background(color = Color(0xFFF0F0FF)),
            contentAlignment = Alignment.CenterStart
        ) {
            val _posX = animateDpAsState(
                targetValue = if(_toggle.value) EndX else StartX,
                animationSpec = spring(
                    dampingRatio = Spring.DampingRatioHighBouncy,
                    stiffness = Spring.StiffnessMediumLow
                )
            )
            Image(
                painter = painterResource(R.drawable.baseline_toys_black_36),
                contentDescription = null,
                modifier = Modifier
                    .size(36.dp)
                    .offset(x = _posX.value)
            )
        }
        // ----------------------------------------------------------
        Text(text = "[ keyframes ]")
        Box(
            modifier = Modifier
                .size(width = 320.dp, height = 50.dp)
                .background(color = Color(0xFFF0F0FF)),
            contentAlignment = Alignment.CenterStart
        ) {
            val _posX = animateDpAsState(
                targetValue = if(_toggle.value) EndX else StartX,
                animationSpec = keyframes {
                    durationMillis = 1000
                    140.dp.at(100)
                    140.dp.at(200)
                    40.dp.at(400)
                    260.dp.at(1000)
                }
            )
            Image(
                painter = painterResource(R.drawable.baseline_toys_black_36),
                contentDescription = null,
                modifier = Modifier
                    .size(36.dp)
                    .offset(x = _posX.value)
            )
        }
        // ----------------------------------------------------------
        Text(text = "[ snap ]")
        Box(
            modifier = Modifier
                .size(width = 320.dp, height = 50.dp)
                .background(color = Color(0xFFF0F0FF)),
            contentAlignment = Alignment.CenterStart
        ) {
            val _posX = animateDpAsState(
                targetValue = if(_toggle.value) EndX else StartX,
                animationSpec = snap(delayMillis = 300),
            )
            Image(
                painter = painterResource(R.drawable.baseline_toys_black_36),
                contentDescription = null,
                modifier = Modifier
                    .size(36.dp)
                    .offset(x = _posX.value)
            )
        }
        // ----------------------------------------------------------
        Spacer(modifier = Modifier.height(20.dp))
        Button(onClick = { _toggle.value = !_toggle.value }) {
            Text(text = "Toggle")
        }
    }
}
スポンサーリンク

アニメーション終了の閾値(visibilityThreshold)

visibilityThresholdはアニメーションの終了を判断するための閾値です。

AnimationSpecがspringの時のみ有効です。

閾値の役割

値(value)が|value|≦visibilityThresholdになると確定した時点で、アニメエンジンの動作をキャンセルし、value出力を停止させます。

visibilityThresholdの働き

プラスα?
キャンセル後にプラスαのvalue出力あります。個数はvisibilityThresholdに関係なく、まちまちです。

アニメエンジンは別スレッドで動作しています。ですので、キャンセルにより直ちに止められないためであると考えられます。

#コードが複雑で、解析を断念しました。申し訳ありません!#

閾値の違い(サンプル)

visibilityThresholdのサンプル

visibilityThresholdのサンプルコード
private val StartX = 20.dp
private val EndX = 260.dp

@Preview_mdpi320x480
@Composable
private fun VisibilityThresholdSample() {
    Column {
        val _toggle = remember { mutableStateOf(false) }
        Spacer(modifier = Modifier.height(10.dp))
        // ----------------------------------------------------------
        Text(text = "[ th = 0.01 ]")
        Box(
            modifier = Modifier
                .size(width = 320.dp, height = 40.dp)
                .background(color = Color(0xFFF0F0FF)),
            contentAlignment = Alignment.CenterStart
        ) {
            val _ratioX = animateFloatAsState(
                targetValue = if(_toggle.value) 1.0f else 0.0f,
                animationSpec = spring(
                    dampingRatio = Spring.DampingRatioHighBouncy,
                    stiffness = Spring.StiffnessMediumLow
                ),
                visibilityThreshold = 0.01f
            )
            Image(
                painter = painterResource(R.drawable.baseline_toys_black_36),
                contentDescription = null,
                modifier = Modifier
                    .size(36.dp)
                    .offset(x = StartX + (EndX - StartX) * _ratioX.value)
            )
        }
        // ----------------------------------------------------------
        Text(text = "[ th = 0.02 ]")
        Box(
            modifier = Modifier
                .size(width = 320.dp, height = 40.dp)
                .background(color = Color(0xFFF0F0FF)),
            contentAlignment = Alignment.CenterStart
        ) {
            val _ratioX = animateFloatAsState(
                targetValue = if(_toggle.value) 1.0f else 0.0f,
                animationSpec = spring(
                    dampingRatio = Spring.DampingRatioHighBouncy,
                    stiffness = Spring.StiffnessMediumLow
                ),
                visibilityThreshold = 0.02f
            )
            Image(
                painter = painterResource(R.drawable.baseline_toys_black_36),
                contentDescription = null,
                modifier = Modifier
                    .size(36.dp)
                    .offset(x = StartX + (EndX - StartX) * _ratioX.value)
            )
        }
        // ----------------------------------------------------------
        Text(text = "[ th = 0.05 ]")
        Box(
            modifier = Modifier
                .size(width = 320.dp, height = 40.dp)
                .background(color = Color(0xFFF0F0FF)),
            contentAlignment = Alignment.CenterStart
        ) {
            val _ratioX = animateFloatAsState(
                targetValue = if(_toggle.value) 1.0f else 0.0f,
                animationSpec = spring(
                    dampingRatio = Spring.DampingRatioHighBouncy,
                    stiffness = Spring.StiffnessMediumLow
                ),
                visibilityThreshold = 0.05f
            )
            Image(
                painter = painterResource(R.drawable.baseline_toys_black_36),
                contentDescription = null,
                modifier = Modifier
                    .size(36.dp)
                    .offset(x = StartX + (EndX - StartX) * _ratioX.value)
            )
        }
        // ----------------------------------------------------------
        Text(text = "[ th = 0.10 ]")
        Box(
            modifier = Modifier
                .size(width = 320.dp, height = 40.dp)
                .background(color = Color(0xFFF0F0FF)),
            contentAlignment = Alignment.CenterStart
        ) {
            val _ratioX = animateFloatAsState(
                targetValue = if(_toggle.value) 1.0f else 0.0f,
                animationSpec = spring(
                    dampingRatio = Spring.DampingRatioHighBouncy,
                    stiffness = Spring.StiffnessMediumLow
                ),
                visibilityThreshold = 0.10f
            )
            Image(
                painter = painterResource(R.drawable.baseline_toys_black_36),
                contentDescription = null,
                modifier = Modifier
                    .size(36.dp)
                    .offset(x = StartX + (EndX - StartX) * _ratioX.value)
            )
        }
        // ----------------------------------------------------------
        Text(text = "[ th = 0.20 ]")
        Box(
            modifier = Modifier
                .size(width = 320.dp, height = 40.dp)
                .background(color = Color(0xFFF0F0FF)),
            contentAlignment = Alignment.CenterStart
        ) {
            val _ratioX = animateFloatAsState(
                targetValue = if(_toggle.value) 1.0f else 0.0f,
                animationSpec = spring(
                    dampingRatio = Spring.DampingRatioHighBouncy,
                    stiffness = Spring.StiffnessMediumLow
                ),
                visibilityThreshold = 0.20f
            )
            Image(
                painter = painterResource(R.drawable.baseline_toys_black_36),
                contentDescription = null,
                modifier = Modifier
                    .size(36.dp)
                    .offset(x = StartX + (EndX - StartX) * _ratioX.value)
            )
        }
        // ----------------------------------------------------------
        Text(text = "[ th = 0.50 ]")
        Box(
            modifier = Modifier
                .size(width = 320.dp, height = 40.dp)
                .background(color = Color(0xFFF0F0FF)),
            contentAlignment = Alignment.CenterStart
        ) {
            val _ratioX = animateFloatAsState(
                targetValue = if(_toggle.value) 1.0f else 0.0f,
                animationSpec = spring(
                    dampingRatio = Spring.DampingRatioHighBouncy,
                    stiffness = Spring.StiffnessMediumLow
                ),
                visibilityThreshold = 0.50f
            )
            Image(
                painter = painterResource(R.drawable.baseline_toys_black_36),
                contentDescription = null,
                modifier = Modifier
                    .size(36.dp)
                    .offset(x = StartX + (EndX - StartX) * _ratioX.value)
            )
        }
        // ----------------------------------------------------------
        Spacer(modifier = Modifier.height(10.dp))
        Button(onClick = { _toggle.value = !_toggle.value }) {
            Text(text = "Toggle")
        }
    }
}

targetValueの大きさ

targetValueの大きさを2倍にすると、それに伴ってspringの振幅も2倍になります。つまり、両者は比例します。

また、振幅が2倍になると、1倍の場合よりも、振動の収束(targetValueに落ち着く)に時間がかかります。

これに対して、visibilityThreshold(th)は絶対的な値なので、引数に与えた値のままです。

閾値とtargetValueの関係

ですので、visibilityThresholdが同じである場合、targetValueが大きくなれば打ち切りの時刻は遅れます。

下図はその例です。

フレーム数とtargetValueの関係

打ち切りの遅れは、アニメーション期間の拡大を意味します。

アニメーションは端末のリソース(CPU能力、メモリー、消費電力など)を多く消費する処理です。

リソースを浪費しないように、適切なvisibilityThresholdを設定して、アニメーション期間は必要最小にすべきです。

スポンサーリンク

関連記事:

animate*AsState関数は制御する値のタイプにより、数種類が準備されています。 どの関数も、animateValueAsStateが原型のラッパー関数であり、動作は同じです。 ここでは、各々の関数について、サンプルを示します。 また、animateValueAsStateのサンプルは、独自の関数を作る方法を紹介しています。 ※環境:Android Studio Iguana | 2023.2.1     Kotlin 1.9.0     Compose Compiler 1.5.1     androidx.compose.animation:* 1.5.0 ...
アニメーションの動きに特殊な効果を加えることができます。 特殊な効果とは、動きが加速したり、減速したり、弾んだり、または向きを変えたり、などです。 上記のような特殊な効果は、動きの軌道がそれぞれ異なる形になるため、ここでは「アニメーションの形状」と表現しています。 AnimationSpecはアニメーションの形状を定義するインターフェースです。そして、Jetpack Composeは実装済みの形状を4つ提供しています。 この中から、「spring」と取りあげて、まとめます。 ※環境:Android Studio Iguana | 2023.2.1 Patch 1     Kotlin 1.9.0     Compose Compiler 1.5.1     androidx.compose.animation:* 1.5.0 ...
アニメーションの動きに特殊な効果を加えることができます。 特殊な効果とは、動きが加速したり、減速したり、弾んだり、または向きを変えたり、などです。 上記のような特殊な効果は、動きの軌道がそれぞれ異なる形になるため、ここでは「アニメーションの形状」と表現しています。 AnimationSpecはアニメーションの形状を定義するインターフェースです。そして、Jetpack Composeは実装済みの形状を4つ提供しています。 この中から、「tween」と取りあげて、まとめます。 ※環境:Android Studio Iguana | 2023.2.1 Patch 1     Kotlin 1.9.0     Compose Compiler 1.5.1     androidx.compose.animation:* 1.5.0 ...
アニメーションの動きに特殊な効果を加えることができます。 特殊な効果とは、動きが加速したり、減速したり、弾んだり、または向きを変えたり、などです。 上記のような特殊な効果は、動きの軌道がそれぞれ異なる形になるため、ここでは「アニメーションの形状」と表現しています。 AnimationSpecはアニメーションの形状を定義するインターフェースです。そして、Jetpack Composeは実装済みの形状を4つ提供しています。 この中から、「keyframes」と取りあげて、まとめます。 ※環境:Android Studio Iguana | 2023.2.1 Patch 1     Kotlin 1.9.0     Compose Compiler 1.5.1     androidx.compose.animation:* 1.5.0 ...
アニメーションの動きに特殊な効果を加えることができます。 特殊な効果とは、動きが加速したり、減速したり、弾んだり、または向きを変えたり、などです。 上記のような特殊な効果は、動きの軌道がそれぞれ異なる形になるため、ここでは「アニメーションの形状」と表現しています。 AnimationSpecはアニメーションの形状を定義するインターフェースです。そして、Jetpack Composeは実装済みの形状を4つ提供しています。 この中から、「snap」と取りあげて、まとめます。 ※環境:Android Studio Iguana | 2023.2.1 Patch 1     Kotlin 1.9.0     Compose Compiler 1.5.1     androidx.compose.animation:* 1.5.0 ...
スポンサーリンク