Android Compose UI Animations 動畫

在 Jetpack Compose 中,動畫不再是繁瑣的 XML 定義或命令式的 startAnimation()。動畫的核心思維是宣告式的:你只需要定義「目標狀態」,Compose 的動畫系統就會自動處理狀態改變時的過場效果。

本文涵蓋了從基礎的屬性轉換到進階的手勢驅動動畫,協助你建構流暢且具備物理真實感的現代化 UI。

屬性動畫 (animate*AsState)

這是最直觀且最常用的動畫 API。它能將一個基準 State 轉換成「動畫化的 State」。當 targetValue 改變時,回傳的 value 會平滑地過渡。

var isActivated by remember { mutableStateOf(false) }

// 動畫色彩
val backgroundColor by animateColorAsState(
    targetValue = if (isActivated) Color.Green else Color.Gray,
    label = "ColorAnimation"
)

// 動畫尺寸
val size by animateDpAsState(
    targetValue = if (isActivated) 200.dp else 100.dp,
    label = "SizeAnimation"
)

Box(
    modifier = Modifier
        .size(size)
        .background(backgroundColor)
        .clickable { isActivated = !isActivated }
)

動畫規格 (AnimationSpec)

你可以透過 animationSpec 參數來自訂過渡的質感:

  • tween:傳統的持續時間插值(可設定 Easing)。
  • spring:基於物理的彈簧動畫(官方推薦,因為它最自然)。
  • keyframes:精確控制各個時間點的數值。
val size by animateDpAsState(
    targetValue = if (expanded) 200.dp else 100.dp,
    animationSpec = spring(
        dampingRatio = Spring.DampingRatioMediumBounce, // 彈跳程度
        stiffness = Spring.StiffnessLow                // 剛性 (速度)
    )
)

佈局過渡動畫

AnimatedVisibility (元件存滅)

處理元件進入或退出畫面時的動畫。你可以自由組合多種效果:

AnimatedVisibility(
    visible = isVisible,
    enter = slideInHorizontally() + fadeIn(), // 從側面滑入 + 淡入
    exit = slideOutHorizontally() + fadeOut()  // 向側面滑出 + 淡出
) {
    Box(Modifier.fillMaxWidth().height(100.dp).background(Color.Red))
}

AnimatedContent (內容切換)

當一個 Composable 內的內容(如數字、圖片)改變時,使用這款 API 來定義舊內容如何離開、新內容如何進入。

var count by remember { mutableStateOf(0) }

AnimatedContent(
    targetState = count,
    transitionSpec = {
        // 使用 slideIntoContainer 手法簡化代碼
        slideIntoContainer(AnimatedContentTransitionScope.SlideDirection.Up) togetherWith
        slideOutOfContainer(AnimatedContentTransitionScope.SlideDirection.Up)
    }
) { targetCount ->
    Text("目前數字: $targetCount", style = MaterialTheme.typography.displayLarge)
}

animateContentSize (尺寸自動變化)

如果你有一個 ColumnRow,其內容大小會動態改變,只要在 Modifier 加上這行,佈局的調整就會變得非常平滑。

Box(
    modifier = Modifier
        .background(Color.LightGray)
        .animateContentSize() // 只要內部內容變大,外殼就會平滑變大
) {
    Text(
        text = if (isExpanded) longText else shortText,
        modifier = Modifier.padding(16.dp)
    )
}

進階動畫控制

當簡單的目標動畫無法滿足需求時,Compose 提供了更強大的底層 API。

updateTransition (多屬性同步動畫)

如果你有多個動畫屬性(如:顏色、尺寸、旋轉角度)都取決於同一個狀態,updateTransition 是最佳選擇。它能統一追蹤狀態改變,並讓所有屬性同步過渡。

enum class BoxState { Small, Large }
var currentState by remember { mutableStateOf(BoxState.Small) }

val transition = updateTransition(currentState, label = "BoxTransition")

val size by transition.animateDp(label = "Size") { state ->
    if (state == BoxState.Small) 50.dp else 150.dp
}
val color by transition.animateColor(label = "Color") { state ->
    if (state == BoxState.Small) Color.Blue else Color.Magenta
}

Box(
    modifier = Modifier
        .size(size)
        .background(color)
        .clickable { currentState = if (currentState == BoxState.Small) BoxState.Large else BoxState.Small }
)

Animatable (底層命令式 API)

Animatable 允許你在 Coroutine 中直接呼叫 animateTo(),提供最極致的控制權,適合處理連續或複雜的邏輯。

val color = remember { Animatable(Color.Gray) }

LaunchedEffect(isError) {
    if (isError) {
        color.animateTo(Color.Red, animationSpec = tween(500))
        color.animateTo(Color.Gray, animationSpec = tween(500))
    }
}

InfiniteTransition (循環動畫)

用於建立永不停止的動畫,例如呼吸燈效果或旋轉圖示。

val infiniteTransition = rememberInfiniteTransition(label = "Infinite")
val alpha by infiniteTransition.animateFloat(
    initialValue = 1f,
    targetValue = 0.2f,
    animationSpec = infiniteRepeatable(
        animation = tween(1000),
        repeatMode = RepeatMode.Reverse
    ),
    label = "Alpha"
)

手勢與動畫的整合

在 Compose 中,手勢驅動動畫 (Gesture-driven Animation) 讓 UI 具備與使用者互動的真實感。

var offsetX by remember { mutableStateOf(0f) }
var offsetY by remember { mutableStateOf(0f) }

Box(
    Modifier
        .offset { IntOffset(offsetX.roundToInt(), offsetY.roundToInt()) }
        .pointerInput(Unit) {
            detectDragGestures { change, dragAmount ->
                change.consume()
                offsetX += dragAmount.x
                offsetY += dragAmount.y
            }
        }
        .size(80.dp)
        .background(Color.Blue, CircleShape)
)

效能與除錯建議

  1. 使用 graphicsLayer:對於 Alpha、Scale、Rotation 等屬性,優先在 graphicsLayer 中設定,避免不必要的重組 (Recomposition)。
  2. Lambda 版 Modifier:頻繁變動的數值(如 Offset)建議使用 lambda 版本以優化效能。
  3. 善用 Label:填寫動畫的 label 參數,以便在 Android Studio 的 Animation Preview 工具中進行視覺化除錯。
好的動畫應該是「隱形」的。它們應該感覺自然且反應靈敏,不應分散使用者的注意力。優先選擇 Spring 物理動畫能讓你的 App 質感瞬間提升。