返回博客

Jetpack Compose 动画实战

动画是提升用户体验的关键元素。Jetpack Compose 提供了一套强大的声明式动画 API,让动画实现变得简单直观。本文将深入探讨 Compose 动画的核心概念和实战技巧。

核心优势:声明式 API、自动优化、与状态无缝集成、可测试性强

动画类型概览

Compose 动画分为三大类:

类型特点适用场景
简单动画单一值变化,API 简洁透明度、颜色、尺寸渐变
状态动画基于状态切换,自动过渡展开/收起、选中/未选中
高级动画完全控制,可定制性强复杂手势动画、物理动画

一、简单动画

1. animate*AsState

最基础的动画 API,用于单一值的变化:

@Composable
fun FadeInBox(visible: Boolean) {
    val alpha by animateFloatAsState(
        targetValue = if (visible) 1f else 0f,
        animationSpec = tween(durationMillis = 300),
        label = "alpha"
    )
    
    Box(
        modifier = Modifier
            .size(100.dp)
            .alpha(alpha)
            .background(Color.Blue)
    )
}

2. 常用 animate*AsState 函数

// 颜色动画
val color by animateColorAsState(
    targetValue = if (selected) Color.Red else Color.Gray,
    animationSpec = spring(stiffness = Spring.StiffnessLow)
)

// 尺寸动画
val size by animateDpAsState(
    targetValue = if (expanded) 200.dp else 100.dp,
    animationSpec = tween(300, easing = FastOutSlowInEasing)
)

// 偏移动画
val offset by animateIntOffsetAsState(
    targetValue = if (moved) IntOffset(100, 100) else IntOffset.Zero
)

// 圆角动画
val corner by animateDpAsState(
    targetValue = if (rounded) 24.dp else 0.dp
)

二、状态动画

1. AnimatedVisibility

控制组件显示/隐藏的动画:

@Composable
fun ExpandableCard(expanded: Boolean) {
    AnimatedVisibility(
        visible = expanded,
        enter = fadeIn() + expandVertically(),
        exit = fadeOut() + shrinkVertically()
    ) {
        Card(
            modifier = Modifier
                .fillMaxWidth()
                .padding(16.dp)
        ) {
            Text(
                text = "这是一段可以展开/收起的内容",
                modifier = Modifier.padding(16.dp)
            )
        }
    }
}
组合动画fadeIn() + expandVertically() 使用 + 组合多个动画效果,它们会同时执行。

2. AnimatedContent

内容切换时的过渡动画:

@Composable
fun TabContent(selectedTab: Int) {
    AnimatedContent(
        targetState = selectedTab,
        transitionSpec = {
            if (targetState > initialState) {
                slideInHorizontally { width -> width } + fadeIn() togetherWith
                    slideOutHorizontally { width -> -width } + fadeOut()
            } else {
                slideInHorizontally { width -> -width } + fadeIn() togetherWith
                    slideOutHorizontally { width -> width } + fadeOut()
            }
        },
        label = "tab-content"
    ) { targetTab ->
        when (targetTab) {
            0 -> HomeScreen()
            1 -> ProfileScreen()
            2 -> SettingsScreen()
        }
    }
}

3. Crossfade

淡入淡出切换两个状态:

@Composable
fun LoadingContent(isLoading: Boolean) {
    Crossfade(
        targetState = isLoading,
        animationSpec = tween(500),
        label = "loading-crossfade"
    ) { loading ->
        if (loading) {
            CircularProgressIndicator()
        } else {
            ContentList()
        }
    }
}

三、高级动画

1. Animatable

手动控制动画,支持中断和反向:

@Composable
fun DraggableBall() {
    val animatable = remember { Animatable(0f) }
    val coroutineScope = rememberCoroutineScope()
    
    Box(
        modifier = Modifier
            .fillMaxWidth()
            .height(200.dp)
            .pointerInput(Unit) {
                detectTapGestures(
                    onPress = { offset ->
                        // 动画到点击位置
                        coroutineScope.launch {
                            animatable.animateTo(
                                targetValue = offset.x,
                                animationSpec = spring(
                                    dampingRatio = Spring.DampingRatioMediumBouncy,
                                    stiffness = Spring.StiffnessLow
                                )
                            )
                        }
                    }
                )
            }
    ) {
        Box(
            modifier = Modifier
                .offset { IntOffset(animatable.value.roundToInt(), 0) }
                .size(50.dp)
                .background(Color.Red, CircleShape())
        )
    }
}

2. Transition

管理多状态动画:

enum class ButtonState {
    Idle, Pressed, Loading, Success
}

@Composable
fun AnimatedButton() {
    var buttonState by remember { mutableStateOf(ButtonState.Idle) }
    
    val transition = updateTransition(buttonState, label = "button-state")
    
    val bgColor by transition.animateColor(label = "bg-color") { state ->
        when (state) {
            ButtonState.Idle -> Color.Blue
            ButtonState.Pressed -> Color.DarkGray
            ButtonState.Loading -> Color.Gray
            ButtonState.Success -> Color.Green
        }
    }
    
    val elevation by transition.animateDp(label = "elevation") { state ->
        if (state == ButtonState.Pressed) 2.dp else 8.dp
    }
    
    Button(
        onClick = { /* 处理点击 */ },
        colors = ButtonDefaults.buttonColors(containerColor = bgColor),
        modifier = Modifier.shadow(elevation)
    ) {
        AnimatedContent(buttonState, label = "button-content") { state ->
            when (state) {
                ButtonState.Idle -> Text("提交")
                ButtonState.Pressed -> Text("按下")
                ButtonState.Loading -> CircularProgressIndicator()
                ButtonState.Success -> Text("完成")
            }
        }
    }
}

AnimationSpec 详解

AnimationSpec 定义动画的执行方式:

// 1. tween - 时间插值动画
tween(
    durationMillis = 300,
    delayMillis = 100,
    easing = FastOutSlowInEasing
)

// 2. spring - 弹簧动画(物理模拟)
spring(
    dampingRatio = Spring.DampingRatioNoBouncy,
    stiffness = Spring.StiffnessMedium
)

// 3. keyframes - 关键帧动画
keyframes {
    durationMillis = 500
    0f at 0 with LinearEasing
    0.5f at 200 with FastOutLinearInEasing
    1f at 500
}

// 4. repeatable - 重复动画
repeatable(
    iterations = 3,
    animation = tween(300),
    repeatMode = RepeatMode.Reverse
)

// 5. infiniteRepeatable - 无限循环
infiniteRepeatable(
    animation = tween(300),
    repeatMode = RepeatMode.Restart
)
性能提示spring 动画基于物理模拟,可能触发多次重组,在复杂 UI 中注意性能。

实战案例:点赞动画

@Composable
fun LikeButton(
    isLiked: Boolean,
    onLike: () -> Unit
) {
    val scale by animateFloatAsState(
        targetValue = if (isLiked) 1.2f else 1f,
        animationSpec = spring(
            stiffness = Spring.StiffnessMedium,
            dampingRatio = Spring.DampingRatioMediumBouncy
        ),
        label = "like-scale"
    )
    
    val color by animateColorAsState(
        targetValue = if (isLiked) Color.Red else Color.Gray,
        label = "like-color"
    )
    
    Icon(
        imageVector = if (isLiked) Icons.Filled.Favorite else Icons.Outlined.FavoriteBorder,
        contentDescription = "点赞",
        tint = color,
        modifier = Modifier
            .scale(scale)
            .clickable { onLike() }
            .size(32.dp)
    )
}

最佳实践

总结

Jetpack Compose 动画 API 设计精良,核心要点:

参考资源
Compose Animation 文档:developer.android.com/jetpack/compose/animation
Compose Animation Codelab:codelabs.developers.google.com