Compose Animation:AnimationSpecでアニメの形状を指定(spring編)

投稿日:  更新日:

アニメーションの動きに特殊な効果を加えることができます。

特殊な効果とは、動きが加速したり、減速したり、弾んだり、または向きを変えたり、などです。

上記のような特殊な効果は、動きの軌道がそれぞれ異なる形になるため、ここでは「アニメーションの形状」と表現しています。

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はアニメーションの形状を定義するインターフェースです。

interface AnimationSpec<T> {
    fun <V : AnimationVector> vectorize(
        converter: TwoWayConverter<T, V>
    ): VectorizedAnimationSpec<V>
}

Jetpack Composeは実装済みの形状を4つ提供しています。

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

この中のspringは、バネガ振動するように変化するアニメーションを提供します。springの実態はAnimationSpecを実装したSpringSpecクラスです。

@Stable
fun <T> spring(
    dampingRatio: Float = Spring.DampingRatioNoBouncy,
    stiffness: Float = Spring.StiffnessMedium,
    visibilityThreshold: T? = null
): SpringSpec<T> =
    SpringSpec(dampingRatio, stiffness, visibilityThreshold)

springの変化

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

@Preview
@Composable
private fun AnimationSpecSample() {
    Column {
        val _toggle = remember { mutableStateOf(false) }
        Spacer(modifier = Modifier.height(20.dp))
        // ----------------------------------------------------------
		...
        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)
            )
        }
		...
        // ----------------------------------------------------------
        Spacer(modifier = Modifier.height(20.dp))
        Button(onClick = { _toggle.value = !_toggle.value }) {
            Text(text = "Toggle")
        }
    }
}
スポンサーリンク

関数の引数

spring関数は次のような引数を持ち、形状を調整できます。

引数概要
dampingRatioFloatバネの減衰比(振動の振幅)
・定義済みパラメータ
  DampingRatioHighBouncy(0.2f)
  DampingRatioMediumBouncy(0.5f)
  DampingRatioLowBouncy(0.75f)
  DampingRatioNoBouncy(1.0f)
  ※()内は設定されている値
stiffnessFloatバネの硬さ(振動の速さ)
・設定済みパラメータ
  StiffnessHigh(10_000f)
  StiffnessMedium(1500f)
  StiffnessMediumLow(400f)
  StiffnessLow(200f)
  StiffnessVeryLow(50f)
  ※()内は設定されている値
visibilityThresholdT?アニメーション終了の閾値
スポンサーリンク

バネの減衰比(dampingRatio)

dampingRatioはバネの減衰比を指定します。

ドキュメントには「減衰比」と記載されているのですが、「振動の振幅」と言った方が的を得ていると思います。

Valueが途切れているのは、visibilityThreshold(デフォルト:0.01f)により、出力が打ち切られているためです。

dampingRatioの違い

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

@Preview_mdpi320x480
@Composable
private fun DampingRatioSample() {
    Column {
        val _toggle = remember { mutableStateOf(false) }
        Spacer(modifier = Modifier.height(20.dp))
        // ----------------------------------------------------------
        Text(text = "[ HighBouncy ]")
        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 = "[ MediumBouncy ]")
        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.DampingRatioMediumBouncy,
                    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 = "[ LowBouncy ]")
        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.DampingRatioLowBouncy,
                    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 = "[ NoBouncy ]")
        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.DampingRatioNoBouncy,
                    stiffness = Spring.StiffnessMediumLow
                )
            )
            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")
        }
    }
}
スポンサーリンク

バネの硬さ(stiffness)

stiffnessはバネの硬さを指定します。

ドキュメントには「硬さ」と記載されているのですが、「振動の速さ」と言った方が的を得ていると思います。

振幅は変わりませんが、 Valueの変化が速ければ、早くtartgetValueへ収束します。

stiffnessの違い

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

@Preview_mdpi320x480
@Composable
private fun StiffnessSample() {
    Column {
        val _toggle = remember { mutableStateOf(false) }
        Spacer(modifier = Modifier.height(20.dp))
        // ----------------------------------------------------------
        Text(text = "[ StiffnessHigh ]")
        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.StiffnessHigh
                )
            )
            Image(
                painter = painterResource(R.drawable.baseline_toys_black_36),
                contentDescription = null,
                modifier = Modifier
                    .size(36.dp)
                    .offset(x = _posX.value)
            )
        }
        // ----------------------------------------------------------
        Text(text = "[ StiffnessMedium ]")
        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.StiffnessMedium
                )
            )
            Image(
                painter = painterResource(R.drawable.baseline_toys_black_36),
                contentDescription = null,
                modifier = Modifier
                    .size(36.dp)
                    .offset(x = _posX.value)
            )
        }
        // ----------------------------------------------------------
        Text(text = "[ StiffnessMediumLow ]")
        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 = "[ StiffnessLow ]")
        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.StiffnessLow
                )
            )
            Image(
                painter = painterResource(R.drawable.baseline_toys_black_36),
                contentDescription = null,
                modifier = Modifier
                    .size(36.dp)
                    .offset(x = _posX.value)
            )
        }
        // ----------------------------------------------------------
        Text(text = "[ StiffnessVeryLow ]")
        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.StiffnessVeryLow
                )
            )
            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はアニメーションの終了を判断するための閾値です。

閾値の役割

値(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 = animateValueAsState(
                targetValue = if(_toggle.value) 1.0f else 0.0f,
                typeConverter = Float.VectorConverter,
                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 = animateValueAsState(
                targetValue = if(_toggle.value) 1.0f else 0.0f,
                typeConverter = Float.VectorConverter,
                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 = animateValueAsState(
                targetValue = if(_toggle.value) 1.0f else 0.0f,
                typeConverter = Float.VectorConverter,
                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 = animateValueAsState(
                targetValue = if(_toggle.value) 1.0f else 0.0f,
                typeConverter = Float.VectorConverter,
                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 = animateValueAsState(
                targetValue = if(_toggle.value) 1.0f else 0.0f,
                typeConverter = Float.VectorConverter,
                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 = animateValueAsState(
                targetValue = if(_toggle.value) 1.0f else 0.0f,
                typeConverter = Float.VectorConverter,
                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を設定して、アニメーション期間は必要最小にすべきです。

注意:animateFloatAsStateの場合

animateFloatAsStateは引数にvisibilityThresholdを持ちます。
※animateFloatAsStateについては「Compose Animation:animate*AsState」を参照

visibilityThresholdはanimateFloatAsState側が優先されて、spring側は無視されるので、注意してください。

この原因は、animateValueAsStateの呼び出しで、「visibilityThreshold = null」に出来ないためです。

@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 <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 toolingOverride = remember { mutableStateOf<State<T>?>(null) }
    val animatable = remember { Animatable(targetValue, typeConverter, visibilityThreshold, label) }
    val listener by rememberUpdatedState(finishedListener)
    val animSpec: AnimationSpec<T> by rememberUpdatedState(
        animationSpec.run {
            if (visibilityThreshold != null && this is SpringSpec &&
                this.visibilityThreshold != visibilityThreshold
            ) {
                spring(dampingRatio, stiffness, visibilityThreshold)
            } else {
                this
            }
        }
    )
    ...
	
    return toolingOverride.value ?: animatable.asState()
}
スポンサーリンク

関連記事:

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 ...
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 ...
アニメーションの動きに特殊な効果を加えることができます。 特殊な効果とは、動きが加速したり、減速したり、弾んだり、または向きを変えたり、などです。 上記のような特殊な効果は、動きの軌道がそれぞれ異なる形になるため、ここでは「アニメーションの形状」と表現しています。 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 ...
スポンサーリンク