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

投稿日:  更新日:

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

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

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

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

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

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

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

この中のkeyframesは、折れ線を描いて変化するアニメーションを提供します。keyframesの実態はAnimationSpecを実装したKeyframesSpecクラスです。

@Stable
fun <T> keyframes(
    init: KeyframesSpec.KeyframesSpecConfig<T>.() -> Unit
): KeyframesSpec<T> {
    return KeyframesSpec(KeyframesSpec.KeyframesSpecConfig<T>().apply(init))
}

keyframesの変化

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 = "[ 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)
            )
        }
		...
        // ----------------------------------------------------------
        Spacer(modifier = Modifier.height(20.dp))
        Button(onClick = { _toggle.value = !_toggle.value }) {
            Text(text = "Toggle")
        }
    }
}
スポンサーリンク

関数の引数

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

引数概要
initKeyframesSpec.KeyframesSpecConfig.() -> Unit折れ線の仕様
スポンサーリンク

折れ線の仕様(init)

initは折れ線の仕様を指定します。

KeyframesSpecConfigは仕様を定義するクラスです。3つのプロパティに折れ線の情報を格納します。

プロパティ概要
durationMillisIntアニメーションの継続時間
デフォルト:300
delayMillisIntアニメーションの待機時間
デフォルト:0
keyframes MutableMap<Int, KeyFrameEntity<T>>変化ポイントのリスト
※T:制御対象の値の型
@Immutable
class KeyframesSpec<T>(val config: KeyframesSpecConfig<T>) : DurationBasedAnimationSpec<T> {

    class KeyframesSpecConfig<T> {

        var durationMillis: Int = DefaultDurationMillis
        var delayMillis: Int = 0
        internal val keyframes = mutableMapOf<Int, KeyframeEntity<T>>()

        infix fun T.at(timeStamp: Int): KeyframeEntity<T> {
            return KeyframeEntity(this).also {
                keyframes[timeStamp] = it	// timeStamp:変化ポイントの時刻
            }								// it:KeyframeEntity(変化ポイント)
        }

        infix fun T.atFraction(fraction: Float): KeyframeEntity<T> {
            return at((durationMillis * fraction).roundToInt())
        }

        infix fun KeyframeEntity<T>.with(easing: Easing) {
            this.easing = easing
        }

        ...
    }
    ...

    class KeyframeEntity<T> internal constructor(   // 変化ポイント
        internal val value: T,						// ・到達値
        internal var easing: Easing = LinearEasing	// ・線分の形状
    ) {
        internal fun <V : AnimationVector> toPair(convertToVector: (T) -> V) =
            convertToVector.invoke(value) to easing
        ...
    }
}

そして、keyframes(マップ)へ折れ線の変化ポイント(KeyframeEntity)の登録が必要になりますが、これには2つの方法(atとatFraction関数)が実装されています。

スポンサーリンク

変化ポイント(at)

折れ線の変化ポイント(KeyframeEntity)を絶対的な値(Value)と時刻(Time)で指定します。

指定された変化ポイントは、keyframes(マップ)へ登録されます。

        infix fun T.at(timeStamp: Int): KeyframeEntity<T> {
            return KeyframeEntity(this).also {
                keyframes[timeStamp] = it	// timeStamp:変化ポイントの時刻
            }								// it:KeyframeEntity(変化ポイント)
        }

atで変化ポイントの指定

@Preview_mdpi
@Composable
private fun TimeStampSample() {
    Column {
        val _toggle = remember { mutableStateOf(false) }
        Spacer(modifier = Modifier.height(20.dp))
        // ----------------------------------------------------------
        Text(text = "[ TimeStamp ]")
        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 = if(_toggle.value) keyframes {
                    durationMillis = 2000
                    20.dp.at(0)			// a0
                    150.dp.at(800)		// a1
                    130.dp.at(1200)		// a2
                    260.dp.at(2000)		// a3
                } else keyframes { }
            )
            System.out.println("%04d,%6.4f".format(Tim4(), _posX.value.value))
            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")
        }
    }
}
スポンサーリンク

変化ポイント(atFraction)

折れ線の変化ポイント(KeyframeEntity)を絶対的な値(Value)と割合(Fraction)で指定します。

指定された変化ポイントは、keyframes(マップ)へ登録されます。

        infix fun T.atFraction(fraction: Float): KeyframeEntity<T> {
            return at((durationMillis * fraction).roundToInt())
        }

atFractionで変化ポイントの指定

@Preview_mdpi
@Composable
private fun FractionSample() {
    Column {
        val _toggle = remember { mutableStateOf(false) }
        Spacer(modifier = Modifier.height(20.dp))
        // ----------------------------------------------------------
        Text(text = "[ Fraction ]")
        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 = if(_toggle.value) keyframes {
                    durationMillis = 2000
                    20.dp.atFraction(0.0f)      // a0:    0 / 2000 = 0.0f
                    150.dp.atFraction(0.4f)     // a1:  800 / 2000 = 0.4f
                    130.dp.atFraction(0.6f)     // a2: 1200 / 2000 = 0.6f
                    260.dp.atFraction(1.0f)     // a3: 2000 / 2000 = 1.0f
                } else keyframes { }
            )
            System.out.println("%04d,%6.4f".format(Tim4(), _posX.value.value))
            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")
        }
    }
}

※サンプルの実行結果は省略、atの場合と同じ

スポンサーリンク

曲線のアルゴリズム(with)

線分の形状を曲線のアルゴリズム(Easing)で指定します。
※曲線のアルゴリズムについては「Compose Animation:AnimationSpecでアニメの形状を指定(tween編)」を参照

        infix fun KeyframeEntity<T>.with(easing: Easing) {
            this.easing = easing
        }

withで線分の形状の指定

@Preview_mdpi
@Composable
private fun EasingSample() {
    Column {
        val _toggle = remember { mutableStateOf(false) }
        Spacer(modifier = Modifier.height(20.dp))
        // ----------------------------------------------------------
        Text(text = "[ with Easing ]")
        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 = if(_toggle.value) keyframes {
                    durationMillis = 2000
                    20.dp.at(0).with(FastOutSlowInEasing)		// a0
                    150.dp.at(800)								// a1
                    130.dp.at(1200).with(FastOutSlowInEasing)	// a2
                    260.dp.at(2000)								// a3
                } else keyframes { }
            )
            System.out.println("%04d,%6.4f".format(Tim4(), _posX.value.value))
            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 }
            )
        }
        // ----------------------------------------------------------
        Spacer(modifier = Modifier.height(20.dp))
        Button(onClick = { _toggle.value = !_toggle.value }) {
            Text(text = "Toggle")
        }
    }
}
スポンサーリンク

継続・待機時間(durationMillis, delayMillis)

durationMillisはアニメーションの継続時間を、delayMillisはアニメーション開始までの待機時間を指定します。

継続・待機時間

例:durationMillisの違い

「durationMillis≠最終変化ポイントの時刻(timeStamp)」であった場合は、小さい方の時刻でアニメーションは打ち切られます。

durationMillisの違いサンプル

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

@Preview_mdpi320x480
@Composable
private fun DurationSample() {
    Column {
        val _toggle = remember { mutableStateOf(false) }
        Spacer(modifier = Modifier.height(20.dp))
        // ----------------------------------------------------------
        Text(text = "[ Duration  1000ms ]")
        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 = if(_toggle.value) keyframes {
                    durationMillis = 1000
                    20.dp.at(0)
                    150.dp.at(800)
                    130.dp.at(1200)
                    260.dp.at(2000)
                } else keyframes { }
            )
            Image(
                painter = painterResource(R.drawable.baseline_toys_black_36),
                contentDescription = null,
                modifier = Modifier
                    .size(36.dp)
                    .offset(x = _posX.value)
            )
        }
        // ----------------------------------------------------------
        Text(text = "[ Duration  2000ms ]")
        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 = if(_toggle.value) keyframes {
                    durationMillis = 2000
                    20.dp.at(0)
                    150.dp.at(800)
                    130.dp.at(1200)
                    260.dp.at(2000)
                } else keyframes { }
            )
            Image(
                painter = painterResource(R.drawable.baseline_toys_black_36),
                contentDescription = null,
                modifier = Modifier
                    .size(36.dp)
                    .offset(x = _posX.value)
            )
        }
        // ----------------------------------------------------------
        Text(text = "[ Duration  3000ms ]")
        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 = if(_toggle.value) keyframes {
                    durationMillis = 3000
                    20.dp.at(0)
                    150.dp.at(800)
                    130.dp.at(1200)
                    260.dp.at(2000)
                } else keyframes { }
            )
            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")
        }
    }
}

例:delayMillisの違い

delayMillisの違い

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

@Preview_mdpi320x480
@Composable
private fun DelaySample() {
    Column {
        val _toggle = remember { mutableStateOf(false) }
        Spacer(modifier = Modifier.height(20.dp))
        // ----------------------------------------------------------
        Text(text = "[ Delay 200ms ]")
        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 = if(_toggle.value) keyframes {
                    delayMillis = 200
                    durationMillis = 2000
                    20.dp.at(0)
                    150.dp.at(800)
                    130.dp.at(1200)
                    260.dp.at(2000)
                } else keyframes { }
            )
            Image(
                painter = painterResource(R.drawable.baseline_toys_black_36),
                contentDescription = null,
                modifier = Modifier
                    .size(36.dp)
                    .offset(x = _posX.value)
            )
        }
        // ----------------------------------------------------------
        Text(text = "[ Delay 500ms ]")
        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 = if(_toggle.value) keyframes {
                    delayMillis = 500
                    durationMillis = 2000
                    20.dp.at(0)
                    150.dp.at(800)
                    130.dp.at(1200)
                    260.dp.at(2000)
                } else keyframes { }
            )
            Image(
                painter = painterResource(R.drawable.baseline_toys_black_36),
                contentDescription = null,
                modifier = Modifier
                    .size(36.dp)
                    .offset(x = _posX.value)
            )
        }
        // ----------------------------------------------------------
        Text(text = "[ Delay 1000ms ]")
        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 = if(_toggle.value) keyframes {
                    delayMillis = 1000
                    durationMillis = 3000
                    20.dp.at(0)
                    150.dp.at(800)
                    130.dp.at(1200)
                    260.dp.at(2000)
                } else keyframes { }
            )
            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")
        }
    }
}
スポンサーリンク

infixで記述

at、atFractionとwith関数はInfixなので、次のような記述が可能です。

@Preview_mdpi
@Composable
private fun InfixSample() {
    Column {
        val _toggle = remember { mutableStateOf(false) }
        Spacer(modifier = Modifier.height(20.dp))
        // ----------------------------------------------------------
        Text(text = "[ Infix ]")
        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 = if(_toggle.value) keyframes {
                    durationMillis = 2000
                    20.dp at 0 with FastOutSlowInEasing
                    150.dp at 800
                    130.dp at 1200 with FastOutSlowInEasing
                    260.dp at 2000
                } else keyframes { }
            )
            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 }
            )
        }
        // ----------------------------------------------------------
        Spacer(modifier = Modifier.height(20.dp))
        Button(onClick = { _toggle.value = !_toggle.value }) {
            Text(text = "Toggle")
        }
    }
}

※サンプルの実行結果は省略、withの場合と同じ

スポンサーリンク

関連記事:

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つ提供しています。 この中から、「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つ提供しています。 この中から、「snap」と取りあげて、まとめます。 ※環境:Android Studio Iguana | 2023.2.1 Patch 1     Kotlin 1.9.0     Compose Compiler 1.5.1     androidx.compose.animation:* 1.5.0 ...
スポンサーリンク