KINTO Tech Blog
Android

小規模なコード修正で効果的にアプリの印象を改善する、Jetpack Composeのアニメーション追加のテクニック

Cover Image for 小規模なコード修正で効果的にアプリの印象を改善する、Jetpack Composeのアニメーション追加のテクニック

この記事は KINTOテクノロジーズ Advent Calendar 2025 の 8 日目の記事です🎅🎄

KINTOテクノロジーズのAndroidエンジニア 山田 剛 です。
本記事では、少しのコード追加・変更でJetpack Composeを利用したUIにアニメーションを追加し、アニメーションの印象を向上させるための事例集を紹介します。

1. はじめに

スマートフォンアプリの印象を大きく左右する要素の一つがアニメーションです。適所に用意された気の利いたアニメーションは、ユーザーの操作に対する視覚的なフィードバックを提供し、アプリの動作の意味を理解しやすくするとともに、アプリの印象を向上させ、品質に対するユーザーの信頼感を増します。Jetpack Composeでは、「宣言的UI」の特性を活かした、従来のViewシステムよりも短く簡潔なコードで、生産性の高いアニメーションの実装が可能になっています。本記事では、その技術の中から、小規模なコードの追加・修正で既存のソースに容易にアニメーションを追加できる実用的なテクニックを紹介します。

本記事では、執筆時点での Compose Animation の最新の安定版 1.10.0を含んだ Jetpack Compose Libraries BOM 2025.12.00 でソースコードを検証しています。

2. 座標を指定するタイプのUI部品のアニメーション

以下は簡単なパズルゲーム(15パズル)を短いコードで書いています:

Puzzle15.kt
@Composable
fun Puzzle15(modifier: Modifier = Modifier) {
    var puzzleState by remember {
        mutableStateOf(PuzzleState.generate())
    }
    var moves by remember { mutableIntStateOf(puzzleState.moves) }
    val solved = puzzleState.isSolved()

    val titleStyle = MaterialTheme.typography.headlineLarge.merge(fontWeight = FontWeight.W600)
    val movesStyle = MaterialTheme.typography.titleMedium
    val solvedStyle =
        MaterialTheme.typography.titleLarge.merge(color = Color.Green, fontWeight = FontWeight.W600)
    val buttonStyle = MaterialTheme.typography.titleMedium.merge(fontWeight = FontWeight.W600)

    Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
        Column(
            modifier = modifier
                .fillMaxSize()
                .padding(16.dp),
            horizontalAlignment = Alignment.CenterHorizontally,
            verticalArrangement = Arrangement.Center
        ) {
            Text(
                text = "15 Puzzle",
                style = titleStyle,
                modifier = Modifier.padding(bottom = 16.dp)
            )

            Text(text = "Moves: $moves", style = movesStyle)

            PuzzleGrid(
                puzzleState = puzzleState,
                modifier = Modifier.padding(vertical = 24.dp)
            ) { index ->
                if (solved) return@PuzzleGrid
                puzzleState.moveTile(index)
                moves = puzzleState.moves
            }

            Button(onClick = {
                puzzleState = PuzzleState.generate()
                moves = 0
            }) {
                Text("New Game", style = buttonStyle)
            }
        }

        if (solved) {
            Text(text = "🎉 Solved! 🎉", style = solvedStyle)
        }
    }
}

4x4 の升目に描く15枚のタイルは、以下のコードで表示しています:

Puzzle15.kt
@Composable
private fun PuzzleGrid(
    puzzleState: PuzzleState,
    modifier: Modifier = Modifier,
    onTileClick: (Int) -> Unit
) {
    BoxWithConstraints(
        modifier = modifier
            .fillMaxWidth()
            .aspectRatio(1F)
    ) {
        val gridSize = maxWidth
        val tileSize = (gridSize - 12.dp) / 4 // 3 gaps of 4dp

        for (position in 0.until(PuzzleState.GRID_COUNT)) {
            val value = puzzleState.tileAt(position)
            if (value == 0) continue
            val targetOffset = DpOffset(
                x = (tileSize + 4.dp) * (position % 4),
                y = (tileSize + 4.dp) * (position / 4)
            )

            PuzzleTile(
                value = value,
                onClick = { onTileClick(position) },
                modifier = Modifier
                    .offset(x = targetOffset.x, y = targetOffset.y)
                    .size(tileSize)
            )
        }
    }
}

ユーザーは動かせるタイルをタップすることで空いた升目にタイルを移動させる操作を繰り返し、パズルの完成を目指します。タイルは1手で最大3枚同時に動かせます。これだけでもシンプルゲームとして充分楽しめますが、リアル世界のパズルゲームのように物理的にタイルを動かしている感触がほしいところです。それをアニメーションで表現することを目指します。

なお、論理的なパズルの状態を表すクラス PuzzleState は以下のようになっています:

class PuzzleState private constructor(private val tiles: IntArray) {
    var moves = 0
        private set

    fun isSolved(): Boolean = tiles.all { tiles[it] == it + 1 || it == 15 }

    private fun getMoveOffsetOrZero(index: Int): Int {
        val emptyIndex = tiles.indexOf(0)
        val row = index / 4
        val col = index % 4
        val emptyRow = emptyIndex / 4
        val emptyCol = emptyIndex % 4

        return when {
            row == emptyRow -> {
                if (col < emptyCol) 1 else -1
            }

            col == emptyCol -> {
                if (row < emptyRow) 4 else -4
            }

            else -> 0
        }
    }

    fun moveTile(index: Int) {
        val offset = getMoveOffsetOrZero(index)
        if (offset == 0) return

        var position = index
        do {
            position += offset
        } while (position >= 0 && position < tiles.size && tiles[position] != 0)
        do {
            val next = position - offset
            tiles[position] = tiles[next]
            position = next
        } while (position != index)
        tiles[index] = 0
        moves += 1
    }

    fun tileAt(index: Int) = tiles[index]

    companion object {
        const val GRID_COUNT = 16

        fun generate(): PuzzleState {
            val tiles = IntArray(GRID_COUNT) { it }
            // シャッフル(解ける配置のみ生成)
            do {
                tiles.shuffle(Random.Default)
            } while (!isSolvable(tiles))
            return PuzzleState(tiles)
        }

        private fun isSolvable(tiles: IntArray): Boolean {
            val inversions = (0..15).sumOf { idx ->
                (idx + 1 until tiles.size).count {
                    tiles[idx] != 0 && tiles[it] != 0 && tiles[idx] > tiles[it]
                }
            }
            // 空白が奇数行(下から)にあり、転置数が偶数の場合、または空白が偶数行にあり、転置数が奇数の場合は解ける
            return (3 - tiles.indexOf(0) / 4) % 2 == inversions % 2
        }
    }
}

2.1. 座標(オフセット)の状態を保持する State を定義する

以下のコードでは、修正前の PuzzleGrid(...)targetOffsetanimateValueAsState(...) で変換して新たに animatedOffset を定義し、 PuzzleTile(...)Modifier.offset(...) の引数を置き換えています。 animatedOffsettargetOffset と同じオフセットを表現しつつ、タイルを移動させるときの移動前から移動後のオフセットの変化をなめらかに表現する機能を持っています。

ただし、この例の場合はこの12行のコード追加だけではアニメーションせず、 // 各タイル(値)の現在位置を追跡 とコメントされた部分の表示順のソートを行った変数 tilePositions を使う必要がありました。これは、 PuzzleGrid(...) における for ループ内でのタイルの順番を常にタイルに描かれた数字の順に保つための並べ替えを行っています。このようにすることで、Jetpack Composeの「宣言的UI」の考え方のもと、移動前と移動後の1〜15のタイルをそれぞれ常に同一視できるようにして表現の連続性を確保し、アニメーションの表示を可能にしています(animateValueAsStatelabel 引数によって composable の同一性を同定してほしいところでしたが、 androidx.compose.animation ライブラリ 1.10.0 ではそのような効果は確認できませんでした。また、 key(...) を用いて composable を同定させる方法も考えられますが、これもうまくいかないようです):

Puzzle15.kt
@Composable
private fun PuzzleGrid(
    puzzleState: PuzzleState,
    modifier: Modifier = Modifier,
    onTileClick: (Int) -> Unit
) {
    // 各タイル(値)の現在位置を追跡
    val tilePositions = IntArray(16) { -1 }
    repeat(PuzzleState.GRID_COUNT) { index ->
        tilePositions[puzzleState.tileAt(index)] = index
    }

    BoxWithConstraints(
        modifier = modifier
            .fillMaxWidth()
            .aspectRatio(1F)
    ) {
        val gridSize = maxWidth
        val tileSize = (gridSize - 12.dp) / 4 // 3 gaps of 4dp

        // 空白以外のタイルを描画(1-15の値ごとに)
        for (value in 1.until(PuzzleState.GRID_COUNT)) {
            val position = tilePositions[value]
            if (position == -1) continue
            val targetOffset = DpOffset(
                x = (tileSize + 4.dp) * (position % 4),
                y = (tileSize + 4.dp) * (position / 4)
            )

            val animatedOffset by animateValueAsState(
                targetValue = targetOffset,
                typeConverter = TwoWayConverter(
                    convertToVector = { AnimationVector2D(it.x.value, it.y.value) },
                    convertFromVector = { DpOffset(Dp(it.v1), Dp(it.v2)) }
                ),
                animationSpec = spring(
                    dampingRatio = Spring.DampingRatioNoBouncy,
                    stiffness = Spring.StiffnessMedium
                ),
                label = "tile_$value"
            )

            PuzzleTile(
                value = value,
                onClick = { onTileClick(position) },
                modifier = Modifier
                    .offset(x = animatedOffset.x, y = animatedOffset.y)
                    .size(tileSize)
            )
        }
    }
}

ともあれ、十数行のコードの追加と数行の変更で、コードの構成をほぼそのままにしたままアニメーションを表現できました。このアニメーションをカスタマイズしたい場合には、 animateValueAsState(...) の引数 typeConverter, animationSpec などを変更してみてください。このように、要領よくアニメーションを追加していくテクニックを以下の項でも紹介していきます。

パズルのアニメーション中
Fig. 1: パズルのアニメーション中

3. 表示・非表示の切り替え時のアニメーションを追加する

先ほどのパズルゲームで、パズルを解いたときの表示があっさりしているので、少し凝ってみたいところです。凝る、といってもミニゲームですので、ささやかなものでよいでしょう。ひとまず、パズルが解けたときに飛び出してくるような演出を考えてみましょう。

これには簡単な方法が用意されています。

3.1. AnimatedVisibility(...) で囲み、内側で Modifier.animateEnterExit(...) を追加する

if (solved) { ... }AnimatedVisibility(solved) { ... } に替えるだけで、非表示から表示へ、および、表示から非表示へ変わるときにデフォルトのアニメーションが起こるようになります。デフォルトのアニメーションを変更するには、 AnimatedVisibility(solved) { ... } に囲まれた各composableの Modifier に対して animateEnterExit(...) を追加します:

Puzzle15.kt
@Composable
fun Puzzle15(modifier: Modifier = Modifier) {

    // ...

    Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {

        // ...

        AnimatedVisibility(solved) {
            Text(
                text = "🎉 Solved! 🎉",
                modifier = Modifier.animateEnterExit(
                    enter = scaleIn(
                        animationSpec = spring(
                            dampingRatio = Spring.DampingRatioHighBouncy,
                            stiffness = Spring.StiffnessMedium
                        )
                    ),
                    exit = None
                ),
                style = solvedStyle
            )
        }
    }
}

Modifier.animateEnterExit(...)enterexit にはそれぞれ非表示から表示へ、表示から非表示へ変わるときのアニメーションを設定します。デフォルト値にはそれぞれ fadeIn(), fadeOut() が設定されています。この場合は表示時のみアニメーションを設定したいので、 exit のときはアニメーションせずに消えるように None を設定しています。

composableを上位のcomposableで囲ってスコープ (AnimatedVisibilityScope) を作り、囲われた個別のcomposableの Modifier に個別の設定を追加する、というコーディングは、Jetpack Compose の頻出テクニックですね。このあとにも同様のテクニックが登場します。

4. 画面更新時に古い画面が消えて新しい画面が現れるアニメーションを表現する

以下のコードは、上下左右の矢印ボタンをタップして2次元の整数座標平面の上を移動する様子を表現したものです:

FlatField.kt
@Composable
fun FlatField(modifier: Modifier = Modifier) {
    var xy by remember { mutableStateOf(Coordinates2D(0, 0)) }

    Box(
        modifier = Modifier
            .fillMaxSize()
            .background(xy.background)
            .safeContentPadding()
    ) {
        IconButton(
            modifier = modifier.align(Alignment.CenterStart),
            onClick = { xy = xy.goLeft() }
        ) {
            Icon(
                modifier = Modifier.size(64.dp),
                imageVector = Icons.AutoMirrored.Filled.ArrowBack,
                contentDescription = "goLeft",
                tint = xy.foreground
            )
        }

        IconButton(
            modifier = Modifier.align(Alignment.TopCenter),
            onClick = { xy = xy.goUp() }
        ) {
            Icon(
                modifier = Modifier
                    .size(64.dp)
                    .rotate(90F),
                imageVector = Icons.AutoMirrored.Filled.ArrowBack,
                contentDescription = "goUp",
                tint = xy.foreground
            )
        }

        IconButton(
            modifier = Modifier.align(Alignment.CenterEnd),
            onClick = { xy = xy.goRight() }
        ) {
            Icon(
                modifier = Modifier.size(64.dp),
                imageVector = Icons.AutoMirrored.Filled.ArrowForward,
                contentDescription = "goRight",
                tint = xy.foreground
            )
        }

        IconButton(
            modifier = Modifier.align(Alignment.BottomCenter),
            onClick = { xy = xy.goDown() }
        ) {
            Icon(
                modifier = Modifier
                    .size(64.dp)
                    .rotate(90F),
                imageVector = Icons.AutoMirrored.Filled.ArrowForward,
                contentDescription = "goDown",
                tint = xy.foreground
            )
        }

        Text(
            text = xy.coordinateString,
            modifier = Modifier.align(Alignment.Center),
            color = xy.foreground,
            fontSize = 64.sp
        )
    }
}

初期画面の白い場所から↓ボタンの赤い場所に移動し、続いて→ボタンで青い場所に移動し…という操作を繰り返せますが、動きがないと移動している感覚をつかみにくいように感じます。こういう時こそ、アニメーションの追加が効果を発揮します。

移動中の状態を表すクラスは以下のようになります:

data class Coordinates2D(val x: Int, val y: Int) {
    val background: Color
    val foreground: Color
    val coordinateString: String

    init {
        val index = nonNegativeRemainder()
        background = backgroundColors[index]
        foreground = foregroundColors[index]
        coordinateString = coordinateString(x, y)
    }

    private fun nonNegativeRemainder(): Int = ((x + y) % 3).let { if (it < 0) it + 3 else it }

    fun goLeft() = Coordinates2D(x - 1, y)

    fun goUp() = Coordinates2D(x, y - 1)

    fun goRight() = Coordinates2D(x + 1, y)

    fun goDown() = Coordinates2D(x, y + 1)

    companion object Companion {
        private val backgroundColors =
            arrayOf(Color.White, Color(0xED, 0x29, 0x39), Color(0x00, 0x23, 0x95))
        private val foregroundColors = arrayOf(Color.Black, Color.White, Color.White)

        private fun coordinateString(x: Int, y: Int) = if (x == 0 && y == 0) "O" else "(${x}, ${y})"
    }
}

4.1. AnimatedContent(...) で囲み、古い画面から新しい画面へと切り替わるアニメーションを追加する

以下は FlatField(Modifier) の中の Box(...)AnimatedContent(...) で囲ったものですが、アニメーションの定義に Box(...) のサイズの情報が必要なため Box(...)BoxWithConstraints(...) に変え、 constraints を参照し maxWidthmaxHeight の値を使って AnimatedContent(...) の引数に与えています:

FlatField.kt
@Composable
fun FlatField(modifier: Modifier = Modifier) {
    var xy by remember { mutableStateOf(Coordinates2D(0, 0)) }

    var width by remember { mutableIntStateOf(0) }
    var height by remember { mutableIntStateOf(0) }

    AnimatedContent(
        modifier = modifier.fillMaxSize(),
        targetState = xy,
        transitionSpec = {
            val deltaX = targetState.x - initialState.x
            val deltaY = targetState.y - initialState.y

            slideIn { IntOffset(x = deltaX * width, y = deltaY * height) } togetherWith
                    slideOut { IntOffset(x = -deltaX * width, y = -deltaY * height) }
        },
        label = "coordinates2D"
    ) { targetXy ->
        BoxWithConstraints(
            modifier = Modifier
                .fillMaxSize()
                .background(targetXy.background)
                .safeContentPadding()
        ) {
            width = constraints.maxWidth
            height = constraints.maxHeight

            IconButton(
                modifier = Modifier.align(Alignment.CenterStart),
                onClick = { xy = targetXy.goLeft() }
            ) {
                Icon(
                    modifier = Modifier.size(64.dp),
                    imageVector = Icons.AutoMirrored.Filled.ArrowBack,
                    contentDescription = "goLeft",
                    tint = targetXy.foreground
                )
            }

            IconButton(
                modifier = Modifier.align(Alignment.TopCenter),
                onClick = { xy = targetXy.goUp() }
            ) {
                Icon(
                    modifier = Modifier
                        .size(64.dp)
                        .rotate(90F),
                    imageVector = Icons.AutoMirrored.Filled.ArrowBack,
                    contentDescription = "goUp",
                    tint = targetXy.foreground
                )
            }

            IconButton(
                modifier = Modifier.align(Alignment.CenterEnd),
                onClick = { xy = targetXy.goRight() }
            ) {
                Icon(
                    modifier = Modifier.size(64.dp),
                    imageVector = Icons.AutoMirrored.Filled.ArrowForward,
                    contentDescription = "goRight",
                    tint = targetXy.foreground
                )
            }

            IconButton(
                modifier = Modifier.align(Alignment.BottomCenter),
                onClick = { xy = targetXy.goDown() }
            ) {
                Icon(
                    modifier = Modifier
                        .size(64.dp)
                        .rotate(90F),
                    imageVector = Icons.AutoMirrored.Filled.ArrowForward,
                    contentDescription = "goDown",
                    tint = targetXy.foreground
                )
            }

            Text(
                text = targetXy.coordinateString,
                modifier = Modifier.align(Alignment.Center),
                color = targetXy.foreground,
                fontSize = 64.sp
            )
        }
    }
}

AnimatedContent(...) の引数 transitionSpec で、古い画面を追い出す slideOut { ... } と 新しい画面を引き入れる slideIn { ... }infix EnterTransition.togetherWith(ExitTransition) で合併してアニメーションを定義しています。 AnimatedContent(...) の引数 content に与えるラムダ式の引数 (targetXy) が追い出される画面の状態を保持しており、各 IconButton(...)onClicktargetXy から得た新しい状態を xy に代入することで状態を更新します。

アニメーションによる疑似スクロール
アニメーションによる疑似スクロール
Fig. 2-1, 2-2: アニメーションによる疑似スクロール

このコードで、仮に(xy = xy.goLeft() などと記述して) targetXy を使わないで(コンパイラの警告を無視して無理やり)ビルドすると、アニメーション中で追い出されていく古い画面が正しく表示されません。この書き方で、 AnimatedContent(...) がアニメーション中の状態を正しく管理してくれていることがわかります。このアニメーションをカスタマイズしたい場合には、 AnimatedContent(...) の引数 transitionSpec を変更するなどしてみてください。

アニメーションの追加で、位置を移動している感覚が一気につかみやすくなりました。この動きを確認していると、これは縦横2次元のページャーのようにも使えそうです。Jetpack Composeには、水平方向にスクロールする HorizontalPager、垂直方向にスクロールする VerticalPager が用意されていますが、2次元のページャーは標準にはありません。それがアニメーションの追加だけでページャー風のUIを作れます。もちろんアニメーションを追加しただけですので、スワイプして隣のページの一部だけを見るような操作はできませんが、十数行の追加と若干の変更だけで、アプリの印象を大きく変えられます。

5. NavHost(...) での画面遷移の前後をつなぐアニメーション

Compose Animation バージョン 1.10.0 から、 SharedTransitionLayout が安定版になりました。これはcomposableで作成した画面の共有要素を定義するものです。共有要素とは何か? それは、Compose での共有要素の遷移の中の短い動画で確認してください。
NavHost(...) を利用している場合のコード例を以下に示します:

GridTransform.kt
private val colorMap = mapOf(
    "赤" to Color.Red,
    "緑" to Color.Green,
    "青" to Color.Blue,
    "シアン" to Color.Cyan,
    "マゼンタ" to Color.Magenta,
    "黄" to Color.Yellow,
    "茶" to Color(132, 74, 43),
    "群青" to Color(76, 108, 179),
    "カーキー" to Color(197, 160, 90)
)

@Composable
fun GridTransform(modifier: Modifier = Modifier) {
    val navController = rememberNavController()
    NavHost(
        modifier = modifier
            .safeContentPadding()
            .fillMaxSize(),
        navController = navController,
        startDestination = ROUTE_SMALL_SQUARE
    ) {
        composable(route = ROUTE_SMALL_SQUARE) {
            val onClick: (String) -> Unit = {
                navController.navigate("$ROUTE_LARGE_SQUARE?$ARG_SHARED=$it")
            }
            Box(
                modifier = Modifier.fillMaxSize(),
                contentAlignment = Alignment.Center
            ) {
                Column(
                    modifier = Modifier
                        .aspectRatio(1f)
                        .padding(8.dp)
                        .fillMaxSize()
                        .background(
                            color = MaterialTheme.colorScheme.primaryContainer,
                            shape = RoundedCornerShape(16.dp)
                        )
                ) {
                    Row(
                        modifier = Modifier.fillMaxWidth(),
                        horizontalArrangement = Arrangement.spacedBy(8.dp)
                    ) {
                        ColorButton(
                            "赤",
                            modifier = Modifier.weight(1f),
                            onClick = onClick
                        )

                        ColorButton(
                            "緑",
                            modifier = Modifier.weight(1f),
                            onClick = onClick
                        )

                        ColorButton(
                            "青",
                            modifier = Modifier.weight(1f),
                            onClick = onClick
                        )
                    }

                    Row(
                        modifier = Modifier.fillMaxWidth(),
                        horizontalArrangement = Arrangement.spacedBy(8.dp)
                    ) {
                        ColorButton(
                            "シアン",
                            modifier = Modifier.weight(1f),
                            onClick = onClick
                        )

                        ColorButton(
                            "マゼンタ",
                            modifier = Modifier.weight(1f),
                            onClick = onClick
                        )

                        ColorButton(
                            "黄",
                            modifier = Modifier.weight(1f),
                            onClick = onClick
                        )
                    }

                    Row(
                        modifier = Modifier.fillMaxWidth(),
                        horizontalArrangement = Arrangement.spacedBy(8.dp)
                    ) {
                        ColorButton(
                            "茶",
                            modifier = Modifier.weight(1f),
                            onClick = onClick
                        )

                        ColorButton(
                            "群青",
                            modifier = Modifier.weight(1f),
                            onClick = onClick
                        )

                        ColorButton(
                            "カーキー",
                            modifier = Modifier.weight(1f),
                            onClick = onClick
                        )
                    }
                }
            }
        }

        composable(
            route = "$ROUTE_LARGE_SQUARE?$ARG_SHARED={sharedKey}",
            arguments = listOf(navArgument("sharedKey") { type = NavType.StringType })
        ) { entry ->
            val colorName = entry.arguments?.getString("sharedKey") ?: ""
            LargeSquare(colorName) {
                navController.popBackStack()
            }
        }
    }
}

@Composable
fun ColorButton(
    colorName: String,
    modifier: Modifier = Modifier,
    onClick: (String) -> Unit
) {
    TextButton(
        modifier = modifier
            .padding(16.dp)
            .aspectRatio(1f)
            .background(color = colorMap[colorName]!!, shape = RoundedCornerShape(16.dp)),
        onClick = { onClick(colorName) }
    ) {
        Text(colorName, color = Color.White)
    }
}

@Composable
private fun LargeSquare(
    colorName: String,
    modifier: Modifier = Modifier,
    onBack: () -> Unit
) {
    Box(
        modifier = modifier
            .padding(16.dp)
            .fillMaxSize()
            .aspectRatio(1f)
            .background(color = colorMap[colorName]!!, shape = RoundedCornerShape(16.dp))
            .clickable { onBack() }
    ) {
        Text(text = colorName, modifier = Modifier.padding(8.dp), color = Color.White)
    }
}

注意: このコードは、単純化のためバックボタンと9色のボタンの連打などの対策を省略しています。短時間での連打を避けてテストしてみてください。

初期画面で9色のボタンが表示され、ボタンをタップするとタップしたボタンの色の大きな正方形が現れます。大きな正方形が表示された状態でバックボタンを押す(またはバックジェスチャを行う)と9色のボタンの画面に戻ります。ここで、ボタンタップで大きな正方形の画面に戻るときと、バックボタンで9色のボタンの画面に戻るときにフェイドインとフェイドアウトのアニメーションが見られます。これは NavHost(...) の引数 enterTransition, exitTransition, popEnterTransition, popExitTransition, sizeTransform のデフォルト値で規定されています。この設定を、特定の画面遷移において以下のようなコードの追加と修正を加えることにより、特定の画面遷移前後の共有要素を関連づけるアニメーションで上書きすることができます。

NavHost(...) によるバックスタックの変化を伴わない画面遷移における共有要素アニメーションの実装は、Compose での共有要素の遷移を参照してください。下表の左から右、および右から左への画面遷移時に、左の画面でタップしたボタン(右下)によって現れた右の画面の正方形がまさに目的のコンテンツであったことがアニメーションによって視覚的に表現されます。

9色のボタンの画面 大きい正方形の画面
9色のボタンの画面Fig. 3-1 大きい正方形の画面Fig. 3-2

5.1. SharedTransitionLayout { ... } で囲み、画面要素を共有する

ここでは、 NavHost(...) による画面遷移の前後での共有要素アニメーションの実装を紹介します:

GridTransform.kt
@Composable
fun GridTransform(modifier: Modifier = Modifier) {
    val navController = rememberNavController()
    SharedTransitionLayout {
        NavHost(
            modifier = modifier
                .safeContentPadding()
                .fillMaxSize(),
            navController = navController,
            startDestination = ROUTE_SMALL_SQUARE
        ) {
            composable(route = ROUTE_SMALL_SQUARE) {
                val onClick: (String) -> Unit = {
                    navController.navigate("$ROUTE_LARGE_SQUARE?$ARG_SHARED=$it")
                }
                Box(
                    modifier = Modifier.fillMaxSize(),
                    contentAlignment = Alignment.Center
                ) {
                    Column(
                        modifier = Modifier
                            .aspectRatio(1f)
                            .padding(8.dp)
                            .fillMaxSize()
                            .background(
                                color = MaterialTheme.colorScheme.primaryContainer,
                                shape = RoundedCornerShape(16.dp)
                            )
                    ) {
                        Row(
                            modifier = Modifier.fillMaxWidth(),
                            horizontalArrangement = Arrangement.spacedBy(8.dp)
                        ) {
                            ColorButton(
                                this@composable,
                                "赤",
                                modifier = Modifier.weight(1f),
                                onClick = onClick
                            )

                            ColorButton(
                                this@composable,
                                "緑",
                                modifier = Modifier.weight(1f),
                                onClick = onClick
                            )

                            ColorButton(
                                this@composable,
                                "青",
                                modifier = Modifier.weight(1f),
                                onClick = onClick
                            )
                        }

                        Row(
                            modifier = Modifier.fillMaxWidth(),
                            horizontalArrangement = Arrangement.spacedBy(8.dp)
                        ) {
                            ColorButton(
                                this@composable,
                                "シアン",
                                modifier = Modifier.weight(1f),
                                onClick = onClick
                            )

                            ColorButton(
                                this@composable,
                                "マゼンタ",
                                modifier = Modifier.weight(1f),
                                onClick = onClick
                            )

                            ColorButton(
                                this@composable,
                                "黄",
                                modifier = Modifier.weight(1f),
                                onClick = onClick
                            )
                        }

                        Row(
                            modifier = Modifier.fillMaxWidth(),
                            horizontalArrangement = Arrangement.spacedBy(8.dp)
                        ) {
                            ColorButton(
                                this@composable,
                                "茶",
                                modifier = Modifier.weight(1f),
                                onClick = onClick
                            )

                            ColorButton(
                                this@composable,
                                "群青",
                                modifier = Modifier.weight(1f),
                                onClick = onClick
                            )

                            ColorButton(
                                this@composable,
                                "カーキー",
                                modifier = Modifier.weight(1f),
                                onClick = onClick
                            )
                        }
                    }
                }
            }

            composable(
                route = "$ROUTE_LARGE_SQUARE?$ARG_SHARED={sharedKey}",
                arguments = listOf(navArgument("sharedKey") { type = NavType.StringType })
            ) { entry ->
                val colorName = entry.arguments?.getString("sharedKey") ?: ""
                LargeSquare(this, colorName) {
                    navController.popBackStack()
                }
            }
        }
    }
}

@Composable
fun SharedTransitionScope.ColorButton(
    animatedContentScope: AnimatedVisibilityScope,
    colorName: String,
    modifier: Modifier = Modifier,
    onClick: (String) -> Unit
) {
    TextButton(
        modifier = modifier
            .padding(16.dp)
            .aspectRatio(1f)
            .background(color = colorMap[colorName]!!, shape = RoundedCornerShape(16.dp))
            .sharedBounds(
                sharedContentState = rememberSharedContentState(colorName),
                animatedVisibilityScope = animatedContentScope,
                boundsTransform = { _, _ ->
                    tween(durationMillis = 500)
                },
                enter = fadeIn(),
                exit = fadeOut(),
                resizeMode = SharedTransitionScope.ResizeMode.scaleToBounds()
            ),
        onClick = { onClick(colorName) }
    ) {
        Text(colorName, color = Color.White)
    }
}

@Composable
private fun SharedTransitionScope.LargeSquare(
    animatedContentScope: AnimatedVisibilityScope,
    colorName: String,
    modifier: Modifier = Modifier,
    onBack: () -> Unit
) {
    Box(
        modifier = modifier
            .padding(16.dp)
            .fillMaxSize()
            .aspectRatio(1f)
            .background(color = colorMap[colorName]!!, shape = RoundedCornerShape(16.dp))
            .sharedBounds(
                sharedContentState = rememberSharedContentState(colorName),
                animatedVisibilityScope = animatedContentScope,
                boundsTransform = { _, _ ->
                    tween(durationMillis = 500)
                },
                enter = fadeIn(),
                exit = fadeOut(),
                resizeMode = SharedTransitionScope.ResizeMode.scaleToBounds()
            )
            .clickable { onBack() }
    ) {
        Text(text = colorName, modifier = Modifier.padding(8.dp), color = Color.White)
    }
}

SharedTransitionLayout { ... } で大外を囲み、 SharedTransitionScope の中で Modifier.sharedBounds(...) を使って共有要素の対応づけを行うのは NavHost(...) を使わない画面遷移の場合と同じです。 Modifier.sharedBounds(...) の引数 animatedVisibilityScope には NavGraphBuilder.composable(...) に由来する AnimatedContentScope (this@composable) をあてます。これで NavHost(...) による画面遷移時に共有要素間のアニメーションを表示できるようになります。 SharedTransitionScope.ColorButton(...)SharedTransitionScope.LargeSquare(...) で、 rememberSharedContentState(Any) の引数 key にボタンの色の名前をあてることで、画面遷移前と遷移後の画面要素を共有していることを確かめてみてください。

共有要素アニメーションの設定のために新たに追加が必要なコーディングは、

  1. SharedTransitionLayout { ... } で囲む
  2. 共有要素を定義するための sharedBounds(...) (or sharedElement(...)) を設定し共有要素間で key を一致させる
    1. のために必要な SharedTransitionScope, AnimatedVisibilityScope の2つのスコープを composable に渡す

です。これらはアニメーション設定前のコードの構成を大きく変えることなく実装可能でしょう。もし、既存のコードに対して少ない変更での適用が難しいようでしたら、変更が容易な構成になるようリファクタリングを試みてみてください。

共有要素アニメーションはうまくはまれば美しいですが、画面設計のイメージとぴったり合うアニメーションは難しいかもしれません。その場合は Modifier.sharedBounds(...) の引数 enter, exit, boundsTransform, resizeMode をいろいろと調整するなどしてみてください。

5.2. 予測型「戻る」との関係

共有要素アニメーションは、 NavGraphBuilder.composable(...) のデフォルトの画面遷移アニメーションを上書きします。加えて、予測型「戻る」アニメーションの有効化、すなわち、API level 33〜35 の AndroidManifest.xml において android:enableOnBackInvokedCallback="true" を指定している場合、または API level 36 以上の AndroidManifest.xml において android:enableOnBackInvokedCallback="false" を指定していない場合において、 NavHost(...) による戻るアニメーションも上書きします。予測型「戻る」アニメーションを有効にした状態でアプリをビルドし、端末をジェスチャーナビゲーションモードに設定して上記の大きな正方形の画面で「戻る」ジェスチャをゆっくりと実行すると、共有要素アニメーションがゆっくりと逆戻りしていくことが容易に確かめられます。また、API level 36 以上、かつ Android OS 16 以上でボタンナビゲーションモードに設定してバックボタンを長押しすると、共有要素の逆戻りアニメーションが見られるはずです。

このように NavHost(...) の「戻る」アニメーションは予測型「戻る」アニメーションの設定に影響を与えます。そのことに留意して、予測型「戻る」アニメーションの有効化設定を行うか否か決定する必要があります。API level 36 の時点では、 android:enableOnBackInvokedCallback="false" を指定することによって予測型「戻る」アニメーションを無効にできます。

6. まとめ

本記事では、小規模の変更で Jetpack Compose における実用的なアニメーションを実装できるテクニックを4例ほど紹介しました。アプリ内のアニメーションは、ほとんどの場合必須の機能ではないため、特にスケジュールに余裕のない開発プロジェクトでは実装が省略されがちですが、使いどころによっては小さくない使い勝手の向上をもたらし、アプリの印象を大きく向上させる力を秘めています。それらを少ない工数で可能にする手段が豊富にあれば、気軽に実装を試すことができます。Compose Animation のAPIの多くは、宣言的、すなわち、UI要素をアニメーションさせるよ、と宣言するような感覚でアニメーションを追加できるように工夫されており、細かい手続き的記述の中で動作を定義しなければならないような複雑さを回避しやすい設計になっています。それらを活用し、多くのアプリの品質向上に役立てられれば幸いです。

7. 参考文献

Facebook

関連記事 | Related Posts

We are hiring!

【UI/UXデザイナー】クリエイティブ室/東京・大阪・福岡

クリエイティブGについてKINTOやトヨタが抱えている課題やサービスの状況に応じて、色々なプロジェクトが発生しそれにクリエイティブ力で応えるグループです。所属しているメンバーはそれぞれ異なる技術や経験を持っているので、クリエイティブの側面からサービスの改善案を出し、周りを巻き込みながらプロジェクトを進めています。

【プロジェクトマネージャー(iOS/Android/Flutter)】モバイルアプリ開発G/東京

モバイルアプリ開発GについてKINTOテクノロジーズにおける、モバイルアプリ開発のスペシャリストが集まっているグループです。KINTOやmy routeなどのサービスを開発・運用しているグループと協調しながら、品質の高いモバイルアプリを開発し、サービスの発展に貢献することを目標としています。

イベント情報