Jetpack Compose 动画全攻略:从基础到手势驱动的流畅交互实战

一、为什么选择 Jetpack Compose 动画?
在传统 Android 开发中,动画通常依赖 ObjectAnimator、ValueAnimator 或 TransitionManager,代码量大且与视图系统耦合严重。Jetpack Compose 的声明式 UI 范式带来了全新的动画体系——开发者只需描述"状态应该是什么",框架负责计算并执行过渡动画。
声明式:动画与状态绑定,状态变化自动驱动动画
可组合:动画 API 与 Composable 函数天然融合
高性能:基于协程与 Canvas 渲染,避免不必要的重组
可中断:动画可随时响应新的状态变化并平滑过渡
本文将覆盖从入门到进阶的 Compose 动画 API,帮助你在实际项目中快速落地。
二、基础动画 API:animateAsState 系列
animateFloatAsState、animateDpAsState、animateColorAsState 等是最简单的动画入口。当目标值改变时,它们会自动执行补间动画:
@Composable
fun AnimatedBox() {
var expanded by remember { mutableStateOf(false) }
val size by animateDpAsState(
targetValue = if (expanded) 200.dp else 80.dp,
animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessLow
),
label = "box_size"
)
Box(
modifier = Modifier
.size(size)
.background(Color(0xFF6200EE))
.clickable { expanded = !expanded }
)
}通过 animationSpec 参数,你可以灵活选择缓动曲线:
spring():弹性动画,自然真实,推荐用于交互反馈tween(durationMillis, easing):时间线动画,精确控制时长与曲线keyframes {}:关键帧动画,支持多段不同速率的过渡repeatable()/infiniteRepeatable():循环动画
三、可见性动画:AnimatedVisibility
AnimatedVisibility 是处理组件显示/隐藏的利器,内置多种进入/退出动效组合:
@Composable
fun NotificationBanner(show: Boolean, message: String) {
AnimatedVisibility(
visible = show,
enter = slideInVertically(
initialOffsetY = { -it },
animationSpec = tween(400, easing = EaseOutCubic)
) + fadeIn(animationSpec = tween(400)),
exit = slideOutVertically(
targetOffsetY = { -it },
animationSpec = tween(300)
) + fadeOut(animationSpec = tween(300))
) {
Surface(
color = Color(0xFF03DAC6),
modifier = Modifier.fillMaxWidth().padding(8.dp)
) {
Text(
text = message,
modifier = Modifier.padding(16.dp),
color = Color.Black
)
}
}
}进入/退出效果可以通过 + 运算符自由组合,常用的有:
fadeIn / fadeOut:淡入淡出slideInHorizontally / slideOutHorizontally:水平滑动expandVertically / shrinkVertically:垂直展开/收起scaleIn / scaleOut:缩放
四、多属性协同动画:updateTransition
当多个属性需要同步动画时,updateTransition 是首选方案。它创建一个与状态绑定的 Transition 对象,所有衍生动画共享同一时间轴:
enum class CardState { Collapsed, Expanded }
@Composable
fun AnimatedCard(cardState: CardState) {
val transition = updateTransition(targetState = cardState, label = "card_transition")
val cardHeight by transition.animateDp(
label = "card_height",
transitionSpec = { spring(stiffness = Spring.StiffnessMedium) }
) { state ->
if (state == CardState.Expanded) 280.dp else 120.dp
}
val cornerRadius by transition.animateDp(
label = "corner_radius",
transitionSpec = { tween(300) }
) { state ->
if (state == CardState.Expanded) 4.dp else 16.dp
}
val backgroundColor by transition.animateColor(
label = "background_color",
transitionSpec = { tween(400) }
) { state ->
if (state == CardState.Expanded) Color(0xFF1565C0) else Color(0xFF42A5F5)
}
Card(
modifier = Modifier.fillMaxWidth().height(cardHeight),
shape = RoundedCornerShape(cornerRadius),
colors = CardDefaults.cardColors(containerColor = backgroundColor)
) { /* 内容 */ }
}使用 updateTransition 的好处在于:所有动画天然同步,状态中途变化时所有属性会协同过渡,不会出现不一致的中间状态。
五、无限循环动画:rememberInfiniteTransition
对于加载指示器、呼吸灯、脉冲效果等需要持续运行的动画,使用 rememberInfiniteTransition:
@Composable
fun PulsingHeartIcon() {
val infiniteTransition = rememberInfiniteTransition(label = "heart_pulse")
val scale by infiniteTransition.animateFloat(
initialValue = 1f,
targetValue = 1.3f,
animationSpec = infiniteRepeatable(
animation = tween(600, easing = EaseInOutSine),
repeatMode = RepeatMode.Reverse
),
label = "heart_scale"
)
val alpha by infiniteTransition.animateFloat(
initialValue = 0.7f,
targetValue = 1f,
animationSpec = infiniteRepeatable(
animation = tween(600, easing = EaseInOutSine),
repeatMode = RepeatMode.Reverse
),
label = "heart_alpha"
)
Icon(
imageVector = Icons.Filled.Favorite,
contentDescription = "Heart",
tint = Color.Red.copy(alpha = alpha),
modifier = Modifier.scale(scale).size(48.dp)
)
}
@Composable
fun ShimmerEffect(modifier: Modifier = Modifier) {
val infiniteTransition = rememberInfiniteTransition(label = "shimmer")
val shimmerOffset by infiniteTransition.animateFloat(
initialValue = 0f,
targetValue = 1f,
animationSpec = infiniteRepeatable(
animation = tween(1200, easing = LinearEasing)
),
label = "shimmer_offset"
)
val brush = Brush.linearGradient(
colors = listOf(Color(0xFFE0E0E0), Color(0xFFF5F5F5), Color(0xFFE0E0E0)),
start = Offset(shimmerOffset * 1000f - 500f, 0f),
end = Offset(shimmerOffset * 1000f, 0f)
)
Box(modifier = modifier.background(brush))
}六、手势驱动动画:Animatable 与低级 API
当动画需要与手势直接交互(如拖拽弹回、滑动删除),Animatable 提供了最底层的控制能力:
@Composable
fun DraggableCard() {
val offsetX = remember { Animatable(0f) }
val scope = rememberCoroutineScope()
Box(
modifier = Modifier
.offset { IntOffset(offsetX.value.roundToInt(), 0) }
.pointerInput(Unit) {
detectHorizontalDragGestures(
onDragEnd = {
scope.launch {
if (abs(offsetX.value) > 200f) {
// 滑出屏幕
offsetX.animateTo(
targetValue = if (offsetX.value > 0) 1000f else -1000f,
animationSpec = tween(300)
)
} else {
// 弹回原位
offsetX.animateTo(
targetValue = 0f,
animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy
)
)
}
}
},
onHorizontalDrag = { _, dragAmount ->
scope.launch {
offsetX.snapTo(offsetX.value + dragAmount)
}
}
)
}
.size(300.dp, 200.dp)
.background(Color(0xFF6200EE), RoundedCornerShape(16.dp))
)
}Animatable 的 snapTo 用于直接跳转(跟手),animateTo 用于弹性过渡(松手后)。两者结合可以实现完美的物理感交互。
七、性能优化:避免不必要的重组
Compose 动画的一个常见陷阱是触发过多重组,影响帧率。以下是关键优化技巧:
使用
graphicsLayer代替offset/scale/alphaModifier:graphicsLayer修改在 Drawing 阶段执行,跳过 Layout 阶段,性能更好Modifier 键:稳定化引用:避免在每次重组时创建新的 lambda,使用
remember缓存状态读取下沉:将
animatedValue读取操作放在尽可能底层的 Composable 内,减少重组范围避免在动画中触发快照写入:不要在
animationSpec的过程中修改 State,容易造成动画循环
// ❌ 每次重组都会触发 Layout 计算
Box(modifier = Modifier.offset(x = animatedOffsetX.dp))
// ✅ 只在 Drawing 阶段修改,跳过 Layout
Box(modifier = Modifier.graphicsLayer {
translationX = animatedOffsetX
})八、实战案例:底部 Sheet 弹出动画
将上述知识融合,实现一个带有自定义动画的底部 Sheet 组件:
@Composable
fun AnimatedBottomSheet(
visible: Boolean,
onDismiss: () -> Unit,
content: @Composable () -> Unit
) {
val transition = updateTransition(targetState = visible, label = "sheet")
val offsetY by transition.animateFloat(
label = "sheet_offset",
transitionSpec = {
if (targetState) spring(dampingRatio = 0.8f, stiffness = 400f)
else tween(250, easing = EaseInCubic)
}
) { if (it) 0f else 1f }
val scrimAlpha by transition.animateFloat(
label = "scrim_alpha",
transitionSpec = { tween(300) }
) { if (it) 0.5f else 0f }
if (transition.currentState || transition.targetState) {
Box(modifier = Modifier.fillMaxSize()) {
// 遮罩层
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = scrimAlpha))
.clickable { onDismiss() }
)
// Sheet 主体
Surface(
modifier = Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth()
.graphicsLayer { translationY = offsetY * 600f },
shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp),
shadowElevation = 16.dp
) {
content()
}
}
}
}这个实现通过 updateTransition 同步驱动遮罩透明度和 Sheet 位移,进入和退出使用不同的 animationSpec,体验自然流畅。
九、总结与最佳实践
Jetpack Compose 的动画体系经过精心设计,从简单的属性动画到复杂的手势交互都有对应方案。选择建议:
单属性简单动画 →
animateXxxAsState组件显隐 →
AnimatedVisibility多属性同步 →
updateTransition无限循环 →
rememberInfiniteTransition手势交互 →
Animatable性能敏感 → 优先用
graphicsLayer,状态读取下沉
掌握这套体系后,你会发现在 Compose 中实现任何复杂动效都变得直观而愉快。建议结合 Android Studio 的 Animation Preview 工具进行调试,可视化地观察动画曲线和时序。祝你打造出令用户惊艳的动效体验!
发布评论
热门评论区: