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のラッパー関数になります。
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関数は次のような引数を持ちます。
引数 | 概要 |
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編)」
形状の違い(サンプル)
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出力を停止させます。
プラスα?
キャンセル後にプラスαのvalue出力あります。個数は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)は絶対的な値なので、引数に与えた値のままです。
ですので、visibilityThresholdが同じである場合、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 ...