Jetpack Compose: 高度なアニメーションを段階的に

Compose の高度なアニメーションを完全解説:遷移、AnimatedVisibility、Animatable、ジェスチャー、滑らかな Android インターフェイスのためのパフォーマンス。

Android 開発者向けの Jetpack Compose 高度なアニメーション

アニメーションは、機能的なアプリを記憶に残るユーザー体験へと変えます。Jetpack Compose は、滑らかなインターフェイスの作成を大幅に簡素化する強力な宣言的アニメーション API を提供しています。本ガイドでは、パフォーマンスが高く保守しやすいアニメーションを構築するための高度なテクニックを解説します。

前提知識

このチュートリアルでは、Compose の基礎(リコンポジション、状態、Modifier)を理解していることを前提とします。基礎を確認するには、まず Jetpack Compose の面接質問ガイドを参照することをおすすめします。

Compose におけるアニメーション API の基礎

Compose は、アニメーション向けに複数のレベルの API を提供しています。選択は、求める制御の度合いとアニメーションの複雑さによって決まります。

API は大きく 3 つのカテゴリに分かれます。高レベルアニメーション(AnimatedVisibilityAnimatedContent)、状態ベースのアニメーション(animate*AsState)、低レベルアニメーション(AnimatableTransition)です。

AnimationLevels.ktkotlin
// Overview of the three animation API levels
@Composable
fun AnimationApiOverview() {
    // High level: simple predefined animations
    AnimatedVisibility(visible = isVisible) {
        Text("Animated content")
    }

    // Intermediate level: state-driven animation
    val alpha by animateFloatAsState(
        targetValue = if (isSelected) 1f else 0.5f,
        label = "alpha"
    )

    // Low level: full control over animation
    val animatable = remember { Animatable(0f) }
    LaunchedEffect(targetValue) {
        animatable.animateTo(targetValue)
    }
}

適切な API レベルを選ぶことは、必要な柔軟性を保ちながら可読性の高いコードを維持するうえで重要です。

AnimatedVisibility: 洗練された出現と消滅のアニメーション

AnimatedVisibility は、要素の出現と消滅をアニメーション化するための理想的な入り口です。この API は、コンテンツのコンポジションとデコンポジションを自動的に管理します。

enter パラメータと exit パラメータは、アニメーションの挙動を定義する遷移の組み合わせを受け取ります。これらの遷移は + 演算子で組み合わせることができます。

AnimatedVisibilityExample.ktkotlin
@Composable
fun ExpandableCard(
    title: String,
    content: String,
    modifier: Modifier = Modifier
) {
    var isExpanded by remember { mutableStateOf(false) }

    Card(
        modifier = modifier.clickable { isExpanded = !isExpanded }
    ) {
        Column(modifier = Modifier.padding(16.dp)) {
            Row(
                modifier = Modifier.fillMaxWidth(),
                horizontalArrangement = Arrangement.SpaceBetween
            ) {
                Text(text = title, style = MaterialTheme.typography.titleMedium)
                Icon(
                    imageVector = if (isExpanded) Icons.Default.ExpandLess
                                  else Icons.Default.ExpandMore,
                    contentDescription = null
                )
            }

            // Expansion animation with fade + slide
            AnimatedVisibility(
                visible = isExpanded,
                enter = fadeIn(animationSpec = tween(300)) +
                        expandVertically(animationSpec = tween(300)),
                exit = fadeOut(animationSpec = tween(200)) +
                       shrinkVertically(animationSpec = tween(200))
            ) {
                Text(
                    text = content,
                    modifier = Modifier.padding(top = 12.dp),
                    style = MaterialTheme.typography.bodyMedium
                )
            }
        }
    }
}

AnimatedVisibility 内部のコンテンツは visible = true のときだけコンポーズされるため、展開可能な項目を多く持つリストでもパフォーマンスが最適化されます。

遷移の組み合わせ

利用可能な遷移には、fadeIn/fadeOutslideIn/slideOutexpandIn/shrinkOutscaleIn/scaleOut などがあります。これらは自由に組み合わせて独自のエフェクトを作れます。

animate*AsState: 状態駆動のアニメーション

animate*AsState 系の関数は、プリミティブ値の変化を自動的にアニメーション化します。これは Compose で単純なアニメーションを書く際に最も慣用的なアプローチです。

各データ型に専用の関数があります:animateColorAsStateanimateFloatAsStateanimateDpAsStateanimateIntAsState などです。

AnimateAsStateExample.ktkotlin
@Composable
fun InteractiveButton(
    isSelected: Boolean,
    onClick: () -> Unit,
    modifier: Modifier = Modifier
) {
    // Animated background color
    val backgroundColor by animateColorAsState(
        targetValue = if (isSelected) MaterialTheme.colorScheme.primary
                      else MaterialTheme.colorScheme.surfaceVariant,
        animationSpec = tween(durationMillis = 250),
        label = "backgroundColor"
    )

    // Animated elevation
    val elevation by animateDpAsState(
        targetValue = if (isSelected) 8.dp else 2.dp,
        animationSpec = spring(
            dampingRatio = Spring.DampingRatioMediumBouncy,
            stiffness = Spring.StiffnessLow
        ),
        label = "elevation"
    )

    // Animated text size
    val textSize by animateFloatAsState(
        targetValue = if (isSelected) 18f else 14f,
        label = "textSize"
    )

    Surface(
        modifier = modifier.clickable(onClick = onClick),
        color = backgroundColor,
        shadowElevation = elevation,
        shape = RoundedCornerShape(12.dp)
    ) {
        Text(
            text = if (isSelected) "Selected" else "Select",
            modifier = Modifier.padding(horizontal = 24.dp, vertical = 12.dp),
            fontSize = textSize.sp
        )
    }
}

animationSpec パラメータは、アニメーションの時間的な挙動を制御します。よく使われる 2 つの spec は、tween(イージング付きの固定時間)と spring(バウンドを伴う現実的な物理)です。

Transition: 複数のアニメーションをオーケストレーションする

複数のプロパティを協調的にアニメーション化する必要がある場合、updateTransition が中央集約的な制御を提供します。この API はすべてのアニメーションが同期し続けることを保証します。

パターンは、enum の状態を定義し、その状態に依存する各プロパティに対するアニメーションを作成することです。

TransitionExample.ktkotlin
// Card state: defines visual behavior
enum class CardState { Collapsed, Expanded, Selected }

@Composable
fun AnimatedStateCard(
    cardState: CardState,
    modifier: Modifier = Modifier
) {
    // Central transition coordinating all animations
    val transition = updateTransition(
        targetState = cardState,
        label = "cardTransition"
    )

    // Card height based on state
    val cardHeight by transition.animateDp(
        transitionSpec = { spring(stiffness = Spring.StiffnessLow) },
        label = "height"
    ) { state ->
        when (state) {
            CardState.Collapsed -> 80.dp
            CardState.Expanded -> 200.dp
            CardState.Selected -> 160.dp
        }
    }

    // Border color based on state
    val borderColor by transition.animateColor(
        transitionSpec = { tween(300) },
        label = "borderColor"
    ) { state ->
        when (state) {
            CardState.Collapsed -> Color.Transparent
            CardState.Expanded -> MaterialTheme.colorScheme.outline
            CardState.Selected -> MaterialTheme.colorScheme.primary
        }
    }

    // Corner radius based on state
    val cornerRadius by transition.animateDp(
        label = "cornerRadius"
    ) { state ->
        when (state) {
            CardState.Collapsed -> 8.dp
            CardState.Expanded -> 16.dp
            CardState.Selected -> 24.dp
        }
    }

    Card(
        modifier = modifier
            .height(cardHeight)
            .border(2.dp, borderColor, RoundedCornerShape(cornerRadius)),
        shape = RoundedCornerShape(cornerRadius)
    ) {
        // Card content
    }
}

Androidの面接対策はできていますか?

インタラクティブなシミュレーター、flashcards、技術テストで練習しましょう。

Animatable: アニメーションの完全な制御

Animatable は、完全なプログラム的制御を提供する低レベル API です。中断可能なアニメーション、ジェスチャー、複雑なシナリオでは、このアプローチが必要となります。

animate*AsState と異なり、Animatable では、進行中のアニメーションの完了を待たずに停止、反転、変更が可能です。

AnimatableExample.ktkotlin
@Composable
fun SwipeableCard(
    onDismiss: () -> Unit,
    modifier: Modifier = Modifier,
    content: @Composable () -> Unit
) {
    // Horizontal offset controlled by Animatable
    val offsetX = remember { Animatable(0f) }
    val scope = rememberCoroutineScope()

    // Swipe threshold to trigger dismissal
    val dismissThreshold = 300f

    Box(
        modifier = modifier
            .offset { IntOffset(offsetX.value.roundToInt(), 0) }
            .pointerInput(Unit) {
                detectHorizontalDragGestures(
                    onDragEnd = {
                        scope.launch {
                            if (abs(offsetX.value) > dismissThreshold) {
                                // Animate out then callback
                                val target = if (offsetX.value > 0) 1000f else -1000f
                                offsetX.animateTo(
                                    targetValue = target,
                                    animationSpec = tween(200)
                                )
                                onDismiss()
                            } else {
                                // Return to initial position with spring
                                offsetX.animateTo(
                                    targetValue = 0f,
                                    animationSpec = spring(
                                        dampingRatio = Spring.DampingRatioMediumBouncy
                                    )
                                )
                            }
                        }
                    },
                    onHorizontalDrag = { _, dragAmount ->
                        scope.launch {
                            // snapTo for instant finger tracking
                            offsetX.snapTo(offsetX.value + dragAmount)
                        }
                    }
                )
            }
    ) {
        content()
    }
}

Animatable の主要なメソッドは、animateTo()(目標値へのアニメーション)、snapTo()(即時変更)、stop()(中断)です。

AnimatedContent: コンテンツの遷移

AnimatedContent は、異なるコンテンツ間の遷移をアニメーション化します。この API は、表示される UI を完全に変える状態変化に最適です。

キー targetState は、遷移をいつ発生させるかを決定します。transitionSpec は、退出するコンテンツと入場するコンテンツがどのように相互作用するかを定義します。

AnimatedContentExample.ktkotlin
@Composable
fun CounterWithAnimation(
    count: Int,
    modifier: Modifier = Modifier
) {
    AnimatedContent(
        targetState = count,
        modifier = modifier,
        transitionSpec = {
            // Determine animation direction
            val direction = if (targetState > initialState) {
                // New number enters from top
                slideInVertically { height -> -height } + fadeIn() togetherWith
                slideOutVertically { height -> height } + fadeOut()
            } else {
                // New number enters from bottom
                slideInVertically { height -> height } + fadeIn() togetherWith
                slideOutVertically { height -> -height } + fadeOut()
            }
            direction.using(SizeTransform(clip = false))
        },
        label = "counter"
    ) { targetCount ->
        Text(
            text = "$targetCount",
            style = MaterialTheme.typography.displayLarge,
            fontWeight = FontWeight.Bold
        )
    }
}
AnimatedContent のパフォーマンス

AnimatedContent 内部のコンテンツは、targetState が変わるたびにリコンポーズされます。複雑なコンテンツの場合は、コストの高い要素をメモ化するか、適切なキー戦略を採用すると良いです。

rememberInfiniteTransition による無限アニメーション

ループするアニメーション(ローディングインジケータ、パルス効果など)向けに、rememberInfiniteTransition は手動でのサイクル管理を必要としない専用 API を提供します。

InfiniteTransitionExample.ktkotlin
@Composable
fun PulsingDot(
    color: Color = MaterialTheme.colorScheme.primary,
    modifier: Modifier = Modifier
) {
    val infiniteTransition = rememberInfiniteTransition(label = "pulse")

    // Looping scale animation
    val scale by infiniteTransition.animateFloat(
        initialValue = 0.8f,
        targetValue = 1.2f,
        animationSpec = infiniteRepeatable(
            animation = tween(600, easing = FastOutSlowInEasing),
            repeatMode = RepeatMode.Reverse
        ),
        label = "scale"
    )

    // Synchronized opacity animation
    val alpha by infiniteTransition.animateFloat(
        initialValue = 0.5f,
        targetValue = 1f,
        animationSpec = infiniteRepeatable(
            animation = tween(600, easing = FastOutSlowInEasing),
            repeatMode = RepeatMode.Reverse
        ),
        label = "alpha"
    )

    Box(
        modifier = modifier
            .size(24.dp)
            .scale(scale)
            .alpha(alpha)
            .background(color = color, shape = CircleShape)
    )
}

LazyColumn のリストアニメーション

リスト項目のアニメーションには特別な配慮が必要です。animateItem() Modifier(以前は animateItemPlacement)は、並べ替えを自動的にアニメーション化します。

ListAnimationExample.ktkotlin
@Composable
fun AnimatedTaskList(
    tasks: List<Task>,
    onToggle: (Task) -> Unit,
    onDelete: (Task) -> Unit,
    modifier: Modifier = Modifier
) {
    LazyColumn(
        modifier = modifier,
        verticalArrangement = Arrangement.spacedBy(8.dp)
    ) {
        items(
            items = tasks,
            key = { it.id }  // Stable key required for animateItem
        ) { task ->
            var isVisible by remember { mutableStateOf(true) }

            // Exit animation before deletion
            AnimatedVisibility(
                visible = isVisible,
                exit = shrinkVertically() + fadeOut()
            ) {
                TaskItem(
                    task = task,
                    onToggle = { onToggle(task) },
                    onDelete = {
                        isVisible = false
                        // Delay to let animation complete
                    },
                    modifier = Modifier.animateItem(
                        fadeInSpec = tween(300),
                        fadeOutSpec = tween(300),
                        placementSpec = spring(
                            dampingRatio = Spring.DampingRatioMediumBouncy,
                            stiffness = Spring.StiffnessLow
                        )
                    )
                )
            }

            // Trigger deletion after animation
            LaunchedEffect(isVisible) {
                if (!isVisible) {
                    delay(300)
                    onDelete(task)
                }
            }
        }
    }
}
リストアニメーションには安定した key を

安定した key パラメータがないと、animateItem はリコンポジションをまたいで項目を追跡できません。リストのインデックスではなく一意な ID を使うべきです。

Canvas アニメーション

カスタムなビジュアルエフェクトには、Canvas とアニメーション値を組み合わせることで完全な柔軟性が得られます。

CanvasAnimationExample.ktkotlin
@Composable
fun AnimatedProgressRing(
    progress: Float,  // 0f to 1f
    modifier: Modifier = Modifier
) {
    // Progress animation with spring for natural feel
    val animatedProgress by animateFloatAsState(
        targetValue = progress,
        animationSpec = spring(
            dampingRatio = Spring.DampingRatioLowBouncy,
            stiffness = Spring.StiffnessVeryLow
        ),
        label = "progress"
    )

    // Continuous rotation animation
    val infiniteTransition = rememberInfiniteTransition(label = "rotation")
    val rotation by infiniteTransition.animateFloat(
        initialValue = 0f,
        targetValue = 360f,
        animationSpec = infiniteRepeatable(
            animation = tween(2000, easing = LinearEasing)
        ),
        label = "rotation"
    )

    val primaryColor = MaterialTheme.colorScheme.primary
    val trackColor = MaterialTheme.colorScheme.surfaceVariant

    Canvas(
        modifier = modifier
            .size(120.dp)
            .rotate(rotation)
    ) {
        val strokeWidth = 12.dp.toPx()
        val radius = (size.minDimension - strokeWidth) / 2

        // Background circle (track)
        drawCircle(
            color = trackColor,
            radius = radius,
            style = Stroke(width = strokeWidth, cap = StrokeCap.Round)
        )

        // Animated progress arc
        drawArc(
            color = primaryColor,
            startAngle = -90f,
            sweepAngle = animatedProgress * 360f,
            useCenter = false,
            style = Stroke(width = strokeWidth, cap = StrokeCap.Round),
            topLeft = Offset(strokeWidth / 2, strokeWidth / 2),
            size = Size(radius * 2, radius * 2)
        )
    }
}

Androidの面接対策はできていますか?

インタラクティブなシミュレーター、flashcards、技術テストで練習しましょう。

アニメーションのパフォーマンス最適化

最適化されていないアニメーションは、ジャンクの原因となりバッテリーを消耗させます。60 FPS を維持するためのベストプラクティスを示します。

第一の原則は、アニメーション中にアロケーションを避けることです。リコンポジションを引き起こす Modifier ではなく、graphicsLayer を使うべきです。

PerformanceOptimization.ktkotlin
@Composable
fun OptimizedAnimatedCard(
    isExpanded: Boolean,
    modifier: Modifier = Modifier
) {
    val scale by animateFloatAsState(
        targetValue = if (isExpanded) 1.1f else 1f,
        label = "scale"
    )

    val alpha by animateFloatAsState(
        targetValue = if (isExpanded) 1f else 0.8f,
        label = "alpha"
    )

    Card(
        modifier = modifier
            // ✅ graphicsLayer: GPU modifications without recomposition
            .graphicsLayer {
                scaleX = scale
                scaleY = scale
                this.alpha = alpha
            }
        // ❌ Avoid: .scale(scale).alpha(alpha)
        // These modifiers trigger recompositions
    ) {
        Text("Card content")
    }
}

// Example with remembered lambda to avoid allocations
@Composable
fun OptimizedClickableItem(
    onClick: () -> Unit,
    content: @Composable () -> Unit
) {
    // ✅ Stable remembered lambda
    val interactionSource = remember { MutableInteractionSource() }

    Box(
        modifier = Modifier
            .clickable(
                interactionSource = interactionSource,
                indication = ripple(),
                onClick = onClick
            )
    ) {
        content()
    }
}

第二の重要点は、リスト内のアニメーションです。同時に走るアニメーションの数を制限し、派生計算には derivedStateOf を使うべきです。

ListPerformance.ktkotlin
@Composable
fun PerformantAnimatedList(
    items: List<Item>,
    modifier: Modifier = Modifier
) {
    // Calculate once whether the list is empty
    val isEmpty by remember {
        derivedStateOf { items.isEmpty() }
    }

    LazyColumn(modifier = modifier) {
        items(
            items = items,
            key = { it.id }
        ) { item ->
            // Lightweight animation only on initial appearance
            var hasAppeared by remember { mutableStateOf(false) }

            LaunchedEffect(Unit) {
                hasAppeared = true
            }

            val alpha by animateFloatAsState(
                targetValue = if (hasAppeared) 1f else 0f,
                animationSpec = tween(200),
                label = "itemAlpha"
            )

            ItemCard(
                item = item,
                modifier = Modifier.graphicsLayer { this.alpha = alpha }
            )
        }
    }
}

まとめ

Jetpack Compose のアニメーションは、使いやすさと高度な制御のバランスを提供します。押さえておくべきポイントは次のとおりです:

  • ✅ 複雑さに応じて適切な API レベルを選ぶ(AnimatedVisibility → animate*AsState → Animatable)
  • ✅ 関連する複数のアニメーションをまとめるには updateTransition を使う
  • ✅ 自然なアニメーションには spring、正確な持続時間には tween を選ぶ
  • ✅ リストアニメーションでは常に安定した key パラメータを指定する
  • ✅ 不要なリコンポジションを避けるため graphicsLayer で最適化する
  • ✅ パフォーマンスを検証するために実機でアニメーションをテストする

Compose のアニメーションを習得することは、プロフェッショナルな Android アプリを際立たせます。これらのテクニックとパフォーマンスへの配慮を組み合わせれば、滑らかで魅力的なユーザー体験を生み出せます。

今すぐ練習を始めましょう!

面接シミュレーターと技術テストで知識をテストしましょう。

タグ

#jetpack compose
#android
#animations
#kotlin
#ui

共有

関連記事