入門 Jetpack Compose Animation part2

に公開

はじめに

https://zenn.dev/solenoid/articles/2f429139925226
前回は、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 }
        )
    }
}

AnimateSomethingAsStateSample.gif

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
                }
            }
    )
}

TransitionSample.gif

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 }
        )
    }
}

Dialer.gif

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")
                }
            }
        }
    }
}

TransitionAnimatedVisibilitySample.gif

カプセル化

UIやアニメーションが複雑になると、Transitionの状態をカプセル化することが重要です。

  1. アニメーションの状態をカプセル化するためのホルダー(POKO)を作る
  2. Transitionを作成し、1のホルダーを返すComposable関数を作成する
  3. 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) }
}

AnimatingBox.gif

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は、tweenkeyframessnapのいずれかを指定できます。(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)
        )
    }
}

InfiniteTransitionSample.gif

参考資料

https://developer.android.com/jetpack/compose/animation/value-based?hl=en
https://github.com/mikanIchinose/android-compose-animation-sample

GitHubで編集を提案

Discussion