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座標を変更することで、左から右へ移動するアニメーションです。

関数の動作
初回ComposeでtargetValueへ値(サンプルは20.dp)を新規設定しても、アニメーションは行われません。
イベント(サンプルはクリック)等によりtargetValueの値(サンプルは260.dp)が変更されると、前targetValueと現targetvalueの間でアニメーションが行われます。
そして、targetValueが変更される毎に、これを繰り返します。

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関数のサンプル」を参照
| アニメーション関数 | タイプ | コメント |
|---|---|---|
| animateFloatAsState | Float | |
| animateIntAsState | Int | |
| animateDpAsState | Dp | |
| animateOffsetAsState | Offset | x、yはFloat型 x、yは独立して制御可能 |
| animateIntOffsetAsState | IntOffset | x、yはInt型 x、yは独立して制御可能 |
| animateSizeAsState | Size | width、heightはFloat型 width、heightは独立して制御可能 |
| animateIntSizeAsState | IntSize | width、heightはInt型 width、heightは独立して制御可能 |
| animateColorAsState | Color | a、r、g、bは独立して制御可能 |
| animateRectAsState | Rect | left、top、right、bottomはFloat型 left、top、right、bottomは独立して制御可能 |
| animateValueAsState | Value | 上記9つの原型になる関数 |
この中で、animateValueAsStateは原型になる関数です。他の関数はanimateValueAsStateのラッパー関数になります。
@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関数は次のような引数を持ちます。
| 引数 | 概要 | |
|---|---|---|
| targetValue | T | 最終の到達値 |
| animationSpec | AnimationSpec | アニメーションの形状 |
| label | String | |
| 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 | 指定時間に即座に切り替え |




※詳細はリンクを参照
tween:「AnimationSpecでアニメの形状を指定(tween編)」
spring:「AnimationSpecでアニメの形状を指定(spring編)」
keyframes:「AnimationSpecでアニメの形状を指定(keyframes編)」
snap:「AnimationSpecでアニメの形状を指定(snap編)」
形状の違い(サンプル)
アニメーション終了の閾値(visibilityThreshold)
visibilityThresholdはアニメーションの終了を判断するための閾値です。
AnimationSpecがspringの時のみ有効です。
閾値の役割
値(value)が|value|≦visibilityThresholdになると確定した時点で、アニメエンジンの動作をキャンセルし、value出力を停止させます。

アニメエンジンは別スレッドで動作しています。ですので、キャンセルにより直ちに止められないためであると考えられます。
#コードが複雑で、解析を断念しました。申し訳ありません!#
閾値の違い(サンプル)

targetValueの大きさ
targetValueの大きさを2倍にすると、それに伴ってspringの振幅も2倍になります。つまり、両者は比例します。
また、振幅が2倍になると、1倍の場合よりも、振動の収束(targetValueに落ち着く)に時間がかかります。
これに対して、visibilityThreshold(th)は絶対的な値なので、引数に与えた値のままです。

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

打ち切りの遅れは、アニメーション期間の拡大を意味します。
アニメーションは端末のリソース(CPU能力、メモリー、消費電力など)を多く消費する処理です。
リソースを浪費しないように、適切なvisibilityThresholdを設定して、アニメーション期間は必要最小にすべきです。
関連記事:
