入門 Jetpack Compose Animation part2
はじめに
Animation modifiers and composablesを読んで学習した内容をまとめました。
今回は、Value-based animationsを読んで学習した内容をまとめます。
animate*AsState
単一の値をアニメーション化するためのAPIです。
種類は以下の通りです。
- androidx.compose.animation
- animateColorAsState
- androidx.compose.animation.core
- animateDpAsState
- animateFloatAsState
- animateIntAsState
- animateIntOffsetAsState
- animateIntSizeAsState
- animateOffsetAsState
- animateRectAsState
- animateSizeAsState
- animateValueAsState
animate*AsStateは、アニメーションの終点のみを指定し、その値が変化したときにアニメーションを開始します。
@Composable
fun AnimateSomethingAsStateSample(modifier: Modifier = Modifier) {
var enabled by remember { mutableStateOf(true) }
val animatedAlpha by animateFloatAsState(if (enabled) 1f else 0.5f, label = "alpha")
val width by animateDpAsState(if (enabled) 200.dp else 100.dp, label = "width")
val background by animateColorAsState(
if (enabled) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.primaryContainer
},
label = "color"
)
val textColor by animateColorAsState(
if (enabled) {
MaterialTheme.colorScheme.onPrimary
} else {
MaterialTheme.colorScheme.onPrimaryContainer
},
label = "color"
)
val offset by animateIntOffsetAsState(
if (enabled) {
IntOffset(100, 100)
} else {
IntOffset(0, 0)
},
label = "offset"
)
Box(
modifier = modifier
.height(100.dp)
.width(width)
.graphicsLayer {
alpha = animatedAlpha
}
.background(background)
.clickable {
enabled = !enabled
}
) {
Text(
text = "Hello World",
color = textColor,
modifier = Modifier.offset { offset }
)
}
}
Transition
1つのターゲットに対して複数の値をアニメーション化するためのAPIです。
animate*AsStateは、1つのターゲットに対して1つの値をアニメーション化するのに対し、Transitionは、1つのターゲットに対して複数の値をアニメーション化できます。
enum class BoxState {
Collapsed,
Expanded,
}
@Composable
fun TransitionSample(modifier: Modifier = Modifier) {
var currentState by remember { mutableStateOf(BoxState.Collapsed) }
val transition = updateTransition(currentState, label = "box state")
val size by transition.animateSize { state ->
when (state) {
BoxState.Collapsed -> Size(100f, 100f)
BoxState.Expanded -> Size(200f, 200f)
}
}
val borderWidth by transition.animateDp { state ->
when (state) {
BoxState.Collapsed -> 1.dp
BoxState.Expanded -> 2.dp
}
}
val color by transition.animateColor(
transitionSpec = {
when {
BoxState.Expanded isTransitioningTo BoxState.Collapsed ->
spring(stiffness = 50f)
else ->
tween(durationMillis = 500)
}
},
label = "color",
) { state ->
when (state) {
BoxState.Collapsed -> MaterialTheme.colorScheme.primary
BoxState.Expanded -> MaterialTheme.colorScheme.background
}
}
Box(
modifier = modifier
.size(size.width.dp, size.height.dp)
.border(borderWidth, Color.Black)
.background(color)
.clickable {
currentState = when (currentState) {
BoxState.Collapsed -> BoxState.Expanded
BoxState.Expanded -> BoxState.Collapsed
}
}
)
}
Transition.createChildTransition
Transitionが複雑に絡み合っている場合、Transition.createChildTransitionを使用して、親のTransitionから子のTransitionを作成することで整理できます。
(ドキュメントに書いてあることをまんま書いているだけで、下の例は有用な例ではないです。🙏)
enum class DialerState { DialerMinimized, NumberPad }
@Composable
fun DialerButton(
isVisibleTransition: Transition<Boolean>,
onClick: () -> Unit,
) {
isVisibleTransition.AnimatedVisibility(
visible = { it },
enter = fadeIn(),
exit = fadeOut(),
) {
Button(
onClick = onClick,
modifier = Modifier.width(200.dp)
) {
Text("Dialer")
}
}
}
@Composable
fun NumberPad(
isVisibleTransition: Transition<Boolean>,
onClick: () -> Unit,
) {
isVisibleTransition.AnimatedVisibility(
visible = { it },
enter = fadeIn(),
exit = fadeOut(),
) {
Button(
onClick = onClick,
colors = ButtonDefaults.buttonColors().copy(
containerColor = MaterialTheme.colorScheme.secondary,
contentColor = MaterialTheme.colorScheme.onSecondary,
),
modifier = Modifier.width(200.dp)
) {
Text("NumberPad")
}
}
}
@OptIn(ExperimentalTransitionApi::class)
@Composable
fun Dialer(
modifier: Modifier = Modifier,
) {
var dialerState by remember { mutableStateOf(DialerState.DialerMinimized) }
val transition = updateTransition(dialerState, label = "dialer state")
Box(modifier) {
NumberPad(
transition.createChildTransition {
it == DialerState.NumberPad
},
onClick = { dialerState = DialerState.DialerMinimized }
)
DialerButton(
transition.createChildTransition {
it == DialerState.DialerMinimized
},
onClick = { dialerState = DialerState.NumberPad }
)
}
}
Transition.AnimatedVisibility, Transition.AnimatedContent
Transitionをトリガーとして、AnimatedVisibility、AnimatedContentが実行できる。
通常のAnimatedVisibilityはBooleanをターゲットとしているが、Transition.AnimatedVisibilityは、ラムダ式を受け取ってTransitionのターゲットからBooleanを算出しそれを用いる。
@Composable
fun TransitionAnimatedVisibilitySample(modifier: Modifier = Modifier) {
var selected by remember { mutableStateOf(false) }
val transition = updateTransition(selected, label = "selected state")
val borderColor by transition.animateColor(label = "border color") { isSelected ->
if (isSelected) Color.Magenta else Color.White
}
val elevation by transition.animateDp(label = "elevation") { isSelected ->
if (isSelected) 10.dp else 2.dp
}
Surface(
onClick = { selected = !selected },
shape = RoundedCornerShape(8.dp),
border = BorderStroke(2.dp, borderColor),
shadowElevation = elevation,
modifier = modifier,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
Text(text = "Hello, world!")
transition.AnimatedVisibility(
visible = { targetSelected -> targetSelected },
enter = expandVertically(),
exit = shrinkVertically()
) {
Text(text = "It is fine today.")
}
transition.AnimatedContent { targetState ->
if (targetState) {
Text(text = "Selected")
} else {
Icon(imageVector = Icons.Default.Phone, contentDescription = "Phone")
}
}
}
}
}
カプセル化
UIやアニメーションが複雑になると、Transitionの状態をカプセル化することが重要です。
- アニメーションの状態をカプセル化するためのホルダー(POKO)を作る
- Transitionを作成し、1のホルダーを返すComposable関数を作成する
- 2のComposable関数を使って、UIを作成する
@Composable
fun AnimatingBox(modifier: Modifier = Modifier) {
var boxState by remember { mutableStateOf(BoxState.Collapsed) }
val transitionData = updateTransitionData(boxState)
Box(
modifier = modifier
.size(transitionData.size)
.background(transitionData.color)
.clickable {
boxState = when (boxState) {
BoxState.Collapsed -> BoxState.Expanded
BoxState.Expanded -> BoxState.Collapsed
}
}
)
}
/**
* アニメーションする値をカプセル化したクラス
*/
private class TransitionData(
color: State<Color>,
size: State<Dp>,
) {
val color by color
val size by size
}
/**
* updateTransitionに倣って、updateTransitionDataという名前にする
* boxStateの変更に基づいて、transitionDataの値を更新する
*/
@Composable
private fun updateTransitionData(boxState: BoxState): TransitionData {
val transition = updateTransition(boxState, label = "box state")
val color = transition.animateColor(label = "color") { state ->
when (state) {
BoxState.Collapsed -> Color.Red
BoxState.Expanded -> Color.Green
}
}
val size = transition.animateDp(label = "size") { state ->
when (state) {
BoxState.Collapsed -> 64.dp
BoxState.Expanded -> 128.dp
}
}
return remember(transition) { TransitionData(color, size) }
}
rememberInfiniteTransition
無限に繰り返すアニメーションを作成するためのAPIです。
InfiniteTransitionのインスタンスを rememberInfiniteTransition
で作成し、 animate*
メソッドを使用して値を作成します。
デフォルトで利用できるメソッドは以下の3つです。
animateColor
animateFloat
animateValue
DpやIntなどの型は、animateValueを使用してアニメーション化できます。その際、typeConverterを指定する必要があります。Dpの場合は、Dp.VectorConverterを指定します。同様にデフォルトでサポートされている型についてはVectorConverterが提供されています。
上記のメソッドのインターフェースはこれまでと違い、初期値とターゲット値を両方指定する必要があります。
@Composable
fun InfiniteTransition.animateColor(
initialValue: Color,
targetValue: Color,
animationSpec: InfiniteRepeatableSpec<Color>,
label: String = "ColorAnimation"
)
またアニメーションの挙動を指定するために、InfiniteRepeatableSpecを渡す必要があります。
InfiniteRepeatableSpecのインスタンスは infiniteRepeatable
関数を使用して作成します。
infiniteRepeatable
はanimetionパラメータにDurationBasedAnimationSpecを受け取ります。DurationBasedAnimationSpecは、tween
、keyframes
、snap
のいずれかを指定できます。(springは指定できません。)
@Stable
fun <T> infiniteRepeatable(
animation: DurationBasedAnimationSpec<T>,
repeatMode: RepeatMode = RepeatMode.Restart,
initialStartOffset: StartOffset = StartOffset(0)
)
@Composable
fun InfiniteTransitionSample(modifier: Modifier = Modifier) {
val transition = rememberInfiniteTransition(label = "infinite transition")
val color by transition.animateColor(
initialValue = Color.Red,
targetValue = Color(0xffffc56e),
animationSpec = infiniteRepeatable(
animation = tween(2000),
repeatMode = RepeatMode.Reverse,
),
label = "color",
)
val size by transition.animateValue(
initialValue = 100.dp,
targetValue = 120.dp,
typeConverter = Dp.VectorConverter,
animationSpec = infiniteRepeatable(
animation = tween(2000),
repeatMode = RepeatMode.Reverse,
),
label = "size",
)
Box(
contentAlignment = Alignment.Center,
modifier = modifier
.size(200.dp)
) {
Box(
modifier = Modifier
.clip(CircleShape)
.size(size)
.background(color)
)
}
}
参考資料
Discussion