Zenn
🐕

入門 Jetpack Compose Animation part1

に公開
1

はじめに

Animationについて詳しくなるために、公式ドキュメントを読んで写経した内容をまとめていきます

AnimatedVisibility

要素の表示・非表示にアニメーションをつけるためのComposable関数

シンプルな使い方

@Composable
fun SimpleAnimatedVisibility(modifier: Modifier = Modifier) {
    var visible by remember { mutableStateOf(true) }
    Column(modifier) {
        Button({ visible = !visible }) {
            Text(if (visible) "Hide" else "Show")
        }
        AnimatedVisibility(
            visible = visible,
        ) {
            Box(
                Modifier
                    .fillMaxWidth()
                    .height(100.dp)
                    .background(MaterialTheme.colorScheme.primaryContainer)
            )
        }
    }
}

Modifier.animateEnterExit

AnimatedVisibilityの中では Modifier.animateEnterExit() が利用でき、これによって子要素のEnter/Exitアニメーションをカスタマイズすることができます

/**
 * Enter animation
 *   親が先に表示されて、遅れて子がfadeInする
 * Exit animation
 *   子が先にfadeOutして、その後、親も非表示になる
 */
@Composable
fun AnimatedVisibilityWithAnimateEnterExit(modifier: Modifier = Modifier) {
    var visible by remember { mutableStateOf(true) }
    Column(modifier) {
        Button({ visible = !visible }) {
            Text(if (visible) "Hide" else "Show")
        }
        AnimatedVisibility(
            visible = visible,
            exit = fadeOut(
                animationSpec = tween(delayMillis = 300)
            ) + shrinkVertically(
                animationSpec = tween(delayMillis = 300)
            ),
        ) {
            Box(
                Modifier
                    .fillMaxWidth()
                    .height(200.dp)
                    .background(Color(0x88000000))
                    .padding(16.dp)
            ) {
                Box(
                    Modifier
                        .animateEnterExit(
                            enter = fadeIn(
                                animationSpec = tween(delayMillis = 300)
                            )
                        )
                        .clip(RoundedCornerShape(10.dp))
                        .requiredHeight(100.dp)
                        .fillMaxWidth()
                        .background(Color.White)
                ) {
                    Text("Hello World")
                }
            }
        }
    }
}

MutableTransitionState

AnimatedVisibilityはMutableTransitionStateを受け取ることもでき、これを使うことでアニメーション状態の制御ができるほか、AnimatedVisibilityがCompositionに追加されたのち直ちにアニメーションを開始するのにも利用できます

@Composable
fun AnimatedVisibilityWithMutableTransitionState(modifier: Modifier = Modifier) {
    val state = remember {
        MutableTransitionState(false).apply {
            // アニメーションを直ちに開始する
            targetState = true
        }
    }

    Column(modifier) {
        Button({ state.targetState = !state.targetState }) {
            Text(if (state.currentState) "Hide" else "Show")
        }
        Text(
            text = when {
                state.isIdle && state.currentState -> "Visible"
                !state.isIdle && state.currentState -> "Disappearing"
                state.isIdle && !state.currentState -> "Invisible"
                else -> "Appearing"
            }
        )
        AnimatedVisibility(
            visibleState = state,
            enter = fadeIn(
                animationSpec = tween(delayMillis = 300)
            ) + expandHorizontally(
                animationSpec = tween(delayMillis = 300)
            ),
            exit = shrinkHorizontally(
                animationSpec = tween(delayMillis = 300)
            ) + fadeOut(
                animationSpec = tween(delayMillis = 300)
            )
        ) {
            Text(text = "Hello, world!")
        }
    }
}

AnimatedContnet

状態の変更を起点にアニメーションを発生させるComposable

シンプルな使い方

@Composable
fun SimpleAnimatedContent(modifier: Modifier = Modifier) {
    Row(modifier) {
        // targetState
        var count by remember { mutableIntStateOf(0) }
        Button(onClick = { count++ }) {
            Text("Add")
        }
        AnimatedContent(
            targetState = count,
            label = "animated content"
        ) { targetCount ->
            // lambdaパラメータを使う必要がある
            Text(text = "Count: $targetCount")
        }
    }
}

アニメーションのカスタマイズ

AnimatedVisibilityではenter, exitパラメータを用いて、アニメーションをカスタマイズできましたが、AnimatedContentの場合は、transitionSpecパラメータを用いでカスタマイズします

transitionSpec: AnimatedContentTransitionScope<S>.() -> ContentTransform = {
    (fadeIn(animationSpec = tween(220, delayMillis = 90)) +
        scaleIn(initialScale = 0.92f, animationSpec = tween(220, delayMillis = 90)))
        .togetherWith(fadeOut(animationSpec = tween(90)))
},

AnimatedVisibilityと同じ要領でEnterTransitionとExitTransitionを作成し、両者を togetherWith で合成して、ContentTransitionにします

さらにSizeTransformを使うことで、コンテンツのサイズについてのアニメーションも制御できます

Column {
    var count by remember { mutableIntStateOf(0) }
                                                                                           
    Row {
        Button(onClick = { count++ }) {
            Text("Add")
        }
        Button(onClick = { count-- }) {
            Text("Subtract")
        }
    }
    Row(
        verticalAlignment = Alignment.CenterVertically
    ) {
        Text("clip = true")
        AnimatedContent(
            targetState = count,
            transitionSpec = {
                val contentTransform = if (targetState > initialState) {
                    val enter = slideInVertically(tween(500)) { it } + fadeIn(tween(500))
                    val exit = slideOutVertically(tween(500)) { -it } + fadeOut(tween(500))
                    enter togetherWith exit
                } else {
                    // デクリメントの際は、上から下にアニメーション
                    val enter = slideInVertically(tween(500)) { -it } + fadeIn(tween(500))
                    val exit = slideOutVertically(tween(500)) { it } + fadeOut(tween(500))
                    enter togetherWith exit
                }
                // clip = false にすることで、コンテンツの領域をはみ出してアニメーションする
                // そのままの状態だと、クリッピングされるのでコンテンツ領域外になると見えなくなる
                contentTransform
            },
            label = "animated content",
            modifier = Modifier
                .padding(8.dp)
                .border(1.dp, Color.Black),
        ) { targetCount ->
            Text(
                text = "$targetCount",
            )
        }
                                                                                           
        Text("clip = false")
        AnimatedContent(
            targetState = count,
            transitionSpec = {
                val contentTransform = if (targetState > initialState) {
                    val enter = slideInVertically(tween(500)) { it } + fadeIn(tween(500))
                    val exit = slideOutVertically(tween(500)) { -it } + fadeOut(tween(500))
                    enter togetherWith exit
                } else {
                    val enter = slideInVertically(tween(500)) { -it } + fadeIn(tween(500))
                    val exit = slideOutVertically(tween(500)) { it } + fadeOut(tween(500))
                    enter togetherWith exit
                }
                // clip = false にすることで、コンテンツの領域をはみ出してアニメーションする
                // そのままの状態だと、クリッピングされるのでコンテンツ領域外になると見えなくなる
                val sizeTransform = SizeTransform(false)
                contentTransform using sizeTransform
            },
            label = "animated content",
            modifier = Modifier
                .padding(8.dp)
                .border(1.dp, Color.Black),
        ) { targetCount ->
            Text(
                text = "$targetCount",
            )
        }
    }
}

余談
EnterTransitionとExitTransitionを合成する関数名はかつてはwithでしたが、現在ではtogetherWithになっているようでした。変更になった理由は定かではないですが、Kotlinにwith関数が元々あるのでそれと混同しないようにするために変更になったんですかね
https://android-review.googlesource.com/c/platform/frameworks/support/+/2514375

SizeTransformを使ったアニメーション

SizeTransformを用いることでサイズのアニメーション制御を細かく行えます
下の例では、コンテンツの伸縮アニメーションをカスタマイズしています
広げる際はまず横方向に広げたのち、縦方向に広げ、畳む際は逆にしています

var expanded by remember { mutableStateOf(false) }
Surface(
    color = MaterialTheme.colorScheme.primary,
    onClick = { expanded = !expanded },
) {
    AnimatedContent(
        contentAlignment = Alignment.TopStart,
        targetState = expanded,
        transitionSpec = {
            fadeIn() togetherWith
                    fadeOut() using
                    SizeTransform(false) { initialSize, targetSize ->
                        if (targetState) {
                            // expand
                            keyframes {
                                // まず横に伸ばす
                                IntSize(targetSize.width, initialSize.height) at 150
                                durationMillis = 300
                                // その後縦に伸ばす
                            }
                        } else {
                            // shrink
                            keyframes {
                                // まず縦に縮める
                                IntSize(initialSize.width, targetSize.height) at 150
                                durationMillis = 300
                                // その後横に縮める
                            }
                        }
                    }
        },
        label = "size transform",
        modifier = Modifier.padding(16.dp)
    ) { targetExpanded ->
        if (targetExpanded) {
            Text(
                """
                EnterTransition defines how the target content should appear, and ExitTransition defines how the initial content should disappear. In addition to all of the EnterTransition and ExitTransition functions available for AnimatedVisibility, AnimatedContent offers slideIntoContainer and slideOutOfContainer. These are convenient alternatives to slideInHorizontally/Vertically and slideOutHorizontally/Vertically that calculate the slide distance based on the sizes of the initial content and the target content of the AnimatedContent content.
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                         
                SizeTransform defines how the size should animate between the initial and the target contents. You have access to both the initial size and the target size when you are creating the animation. SizeTransform also controls whether the content should be clipped to the component size during animations.
            """.trimIndent()
            )
        } else {
            Icon(Icons.Default.Edit, null)
        }
    }
}

Crossfade

異なる2つのレイアウト変更にクロスフェードアニメーションを追加するComposable

@Composable
fun CrossfadeSample(modifier: Modifier = Modifier) {
    var currentPage by remember { mutableStateOf("A") }
    Column(modifier) {
        Button({
            currentPage = when (currentPage) {
                "A" -> "B"
                else -> "A"
            }
        }) {
            Text("Change")
        }
        Crossfade(targetState = currentPage, label = "cross fade") { screen ->
            when (screen) {
                "A" -> Text("Page A")
                "B" -> Text("Page B")
            }
        }
    }
}

Modifier.animateContentSize

コンテンツのサイズ変更にアニメーションをつけてくれるModifier

注意: サイズ変更を正確に伝搬させるために、サイズ系のModifier(sizeやdefaultMinSizeなど)よりも先に呼び出す

@Composable
fun AnimateContentSizeSample(modifier: Modifier = Modifier) {
    var expanded by remember { mutableStateOf(false) }
    Box(
        modifier = modifier
            .background(MaterialTheme.colorScheme.primary)
            .animateContentSize()
            .height(if (expanded) 400.dp else 200.dp)
            .fillMaxWidth()
            .clickable {
                expanded = !expanded
            }
    )
}

参考文献

https://developer.android.com/develop/ui/compose/animation/composables-modifiers?hl=en
https://github.com/mikanIchinose/android-compose-animation-sample

GitHubで編集を提案
1

Discussion

ログインするとコメントできます