Jetpack Compose 动画全解析:从入门到实战
前言
动画是现代移动应用中不可或缺的体验要素。一个平滑的过渡、一个微妙的反馈,都能让用户感受到应用的精致与专业。Jetpack Compose 提供了一套声明式、分阶段感知、高度可定制的动画系统,让开发者用极少的代码就能实现复杂的动效。本文将从基础到高级,系统梳理 Compose 动画的完整知识体系。
第一部分:动画入门
1.1 Compose 动画的设计哲学
Compose 的动画系统是声明式的 — 你只需要描述"动画到哪个状态",框架会自动处理中间的过渡过程。这与传统的命令式动画(手动计算每一帧)有本质区别:
// 声明式:只指定目标值,Compose 自动补间
val alpha by animateFloatAsState(targetValue = if (visible) 1f else 0f, label = "alpha")
同时,Compose 动画是分阶段感知的。动画可以发生在不同的 Compose 阶段:
组合阶段(Composition):值驱动重组
布局阶段(Layout):位置、尺寸变化
绘制阶段(Draw):透明度、颜色、缩放等
在绘制阶段执行的动画性能最优,因为它跳过了昂贵的重组和重新布局。
1.2 如何选择动画 API
Compose 提供了丰富的动画 API,选择合适的 API 是高效开发的第一步:
第二部分:值动画(Value-based Animations)
2.1 animate*AsState — 最简单的单值动画
这是 Compose 中最简单的动画 API。你只需要指定目标值,Compose 会自动从当前值平滑过渡到目标值。它天然支持中断 — 如果在动画过程中目标值再次变化,动画会无缝转向新目标。
var enabled by remember { mutableStateOf(true) }
val animatedAlpha by animateFloatAsState(
targetValue = if (enabled) 1f else 0.5f,
label = "alpha"
)
Box(
modifier = Modifier
.size(100.dp)
.graphicsLayer { alpha = animatedAlpha }
.background(Color.Red)
)
animate*AsState 支持多种类型:animateFloatAsState、animateColorAsState、animateDpAsState、animateIntAsState、animateSizeAsState 等。
2.2 updateTransition — 多属性状态机
当你需要同时动画多个属性,并且这些属性由一个状态(通常是 enum)驱动时,updateTransition 是最佳选择:
enum class BoxState { Collapsed, Expanded }
var boxState by remember { mutableStateOf(BoxState.Collapsed) }
val transition = updateTransition(targetState = boxState, label = "box state")
val rect by transition.animateRect(label = "rectangle") { state ->
when (state) {
BoxState.Collapsed -> Rect(0f, 0f, 100f, 100f)
BoxState.Expanded -> Rect(100f, 100f, 300f, 300f)
}
}
val color by transition.animateColor(label = "color") { state ->
when (state) {
BoxState.Collapsed -> Color.Gray
BoxState.Expanded -> Color.Blue
}
}
你还可以通过 transitionSpec 为不同的状态转换指定不同的动画参数:
val transition = updateTransition(targetState = boxState, label = "box state")
val size by transition.animateDp(
transitionSpec = {
if (BoxState.Expanded isTransitioningTo BoxState.Collapsed) {
spring(dampingRatio = Spring.DampingRatioHighBounce)
} else {
tween(durationMillis = 500)
}
},
label = "size"
) { state ->
if (state == BoxState.Expanded) 200.dp else 100.dp
}
2.3 rememberInfiniteTransition — 无限循环动画
用于加载指示器、背景呼吸效果等需要持续运行的动画:
val infiniteTransition = rememberInfiniteTransition(label = "pulse")
val color by infiniteTransition.animateColor(
initialValue = Color.Red,
targetValue = Color.Green,
animationSpec = infiniteRepeatable(
animation = tween(1000),
repeatMode = RepeatMode.Reverse
),
label = "color"
)
Box(modifier = Modifier.size(100.dp).background(color))
2.4 Animatable — 协程级别的精细控制
Animatable 是 animate*AsState 的底层引擎。当你需要手势驱动的动画、或者需要在协程中精确控制动画时机时使用它:
val offset = remember { Animatable(Offset(0f, 0f), Offset.VectorConverter) }
Box(
modifier = Modifier
.fillMaxSize()
.pointerInput(Unit) {
coroutineScope {
while (true) {
awaitPointerEventScope {
val position = awaitFirstDown().position
// 点击移动 — 自动中断进行中的动画并保持速度
launch { offset.animateTo(position) }
}
}
}
}
) {
Circle(modifier = Modifier.offset { offset.value.toIntOffset() })
}
Animatable 的关键方法:
animateTo(target):平滑动画到目标值,自动中断并继承速度snapTo(value):瞬间跳变(用于拖拽时实时同步手指位置)animateDecay(velocity, decay):惯性衰减动画(用于 fling 手势)stop():停止正在进行的动画(在手势开始时调用)
2.5 TargetBasedAnimation — 最低级帧控制
这是 Compose 动画的最底层,通常只在需要手动控制播放头(playhead)时使用,比如与自定义渲染循环同步:
val animation = remember {
TargetBasedAnimation(
animationSpec = tween(200),
typeConverter = Float.VectorConverter,
initialValue = 200f,
targetValue = 1000f
)
}
var playTime = 0L
LaunchedEffect(animation) {
val startTime = withFrameNanos { it }
do {
playTime = withFrameNanos { it } - startTime
val value = animation.getValueFromNanos(playTime)
} while (someCondition())
}
第三部分:动画修饰符与可组合函数
3.1 AnimatedVisibility — 出现与消失动画
AnimatedVisibility 是控制内容出现/消失的首选 API。它在内容隐藏时会将其从组合中完全移除(而非仅仅设为透明),因此性能更好。
var visible by remember { mutableStateOf(true) }
AnimatedVisibility(
visible = visible,
enter = fadeIn() + slideInVertically(),
exit = fadeOut() + slideOutVertically()
) {
Text("Hello, Compose!")
}
默认的进入动画是 fadeIn + expandIn,退出动画是 fadeOut + shrinkOut。你可以用 + 运算符组合多种过渡效果:
如果需要在首次组成时立即触发动画,可以使用 MutableTransitionState:
val state = remember { MutableTransitionState(false).apply { targetState = true } }
AnimatedVisibility(visibleState = state) {
Text("I appear immediately with animation!")
}
3.2 AnimatedContent — 内容切换动画
当你需要在两个完全不同的 UI 之间切换时使用 AnimatedContent:
var count by remember { mutableStateOf(0) }
AnimatedContent(
targetState = count,
transitionSpec = {
if (targetState > initialState) {
slideInVertically { it } togetherWith slideOutVertically { -it }
} else {
slideInVertically { -it } togetherWith slideOutVertically { it }
}
},
label = "count"
) { targetCount ->
// 始终使用 lambda 参数,而非外部状态变量
Text("Count: $targetCount")
}
对于简单的淡入淡出,可以使用更简洁的 Crossfade:
Crossfade(targetState = screen, label = "screen") { currentScreen ->
when (currentScreen) {
Screen.Home -> HomeScreen()
Screen.Settings -> SettingsScreen()
}
}
3.3 Modifier.animateContentSize() — 尺寸自适应动画
当容器内容变化导致尺寸变化时,自动添加平滑的尺寸过渡:
var expanded by remember { mutableStateOf(false) }
Column(
modifier = Modifier.animateContentSize()
) {
Text("Always visible text")
if (expanded) {
Text("Extra content that appears and expands smoothly")
}
}
注意:
animateContentSize()必须放在影响尺寸的修饰符(如.height()、.size())之前,才能正确向布局系统报告尺寸变化。
3.4 子元素动画 — animateEnterExit
在 AnimatedVisibility 或 AnimatedContent 内部,可以给子元素指定独立的入场/出场动画:
AnimatedVisibility(
visible = visible,
enter = EnterTransition.None, // 父容器不做动画
exit = ExitTransition.None
) {
Row {
Icon(
modifier = Modifier.animateEnterExit(
enter = scaleIn(),
exit = scaleOut()
),
imageVector = Icons.Default.Star,
contentDescription = null
)
Text(
modifier = Modifier.animateEnterExit(
enter = slideInHorizontally(),
exit = slideOutHorizontally()
),
text = "Animated child"
)
}
}
3.5 内部自定义动画
在 AnimatedVisibility 和 AnimatedContent 的作用域内,可以访问 transition 属性来运行与进入/退出动画同时进行的自定义动画:
AnimatedVisibility(visible = visible) {
// 背景色在可见时为蓝色,不可见时为灰色
val background by transition.animateColor { state ->
if (state == EnterExitState.Visible) Color.Blue else Color.Gray
}
Box(
modifier = Modifier
.size(100.dp)
.background(background)
)
}
AnimatedVisibility 会等待所有基于 transition 的动画完成后才将内容从组合中移除。
第四部分:实战场景速查
4.1 常见属性动画
4.2 导航过渡动画
使用 Navigation Compose(v2.7+),在 composable() 中直接定义进出动画:
NavHost(navController, startDestination = "home") {
composable(
route = "home",
enterTransition = { slideIntoContainer(AnimatedContentTransitionScope.SlideDirection.Right) },
exitTransition = { slideOutOfContainer(AnimatedContentTransitionScope.SlideDirection.Left) },
popEnterTransition = { slideIntoContainer(AnimatedContentTransitionScope.SlideDirection.Left) },
popExitTransition = { slideOutOfContainer(AnimatedContentTransitionScope.SlideDirection.Right) }
) {
HomeScreen()
}
composable("detail") {
DetailScreen()
}
}
动画可以用 + 组合:fadeIn() + slideIntoContainer(...)。
4.3 滑动消除(Swipe to Dismiss)
这是一个自定义 Modifier,结合了拖拽跟踪、速度计算和惯性动画:
fun Modifier.swipeToDismiss(onDismissed: () -> Unit) = composed {
val offsetX = remember { Animatable(0f) }
pointerInput(Unit) {
val decay = splineBasedDecay<Float>(this)
coroutineScope {
while (true) {
val velocityTracker = VelocityTracker()
// 手指按下时停止正在进行的动画
offsetX.stop()
awaitPointerEventScope {
val pointerId = awaitFirstDown().id
horizontalDrag(pointerId) { change ->
// 实时同步手指位置
launch { offsetX.snapTo(offsetX.value + change.positionChange().x) }
velocityTracker.addPosition(change.uptimeMillis, change.position)
}
}
// 计算释放时的速度
val velocity = velocityTracker.calculateVelocity().x
val targetOffsetX = decay.calculateTargetValue(offsetX.value, velocity)
// 设置边界
offsetX.updateBounds(-size.width.toFloat(), size.width.toFloat())
launch {
if (targetOffsetX.absoluteValue <= size.width) {
// 速度不够,滑回原位
offsetX.animateTo(0f, initialVelocity = velocity)
} else {
// 速度足够,惯性滑出
offsetX.animateDecay(velocity, decay)
onDismissed()
}
}
}
}
}.offset { IntOffset(offsetX.value.roundToInt(), 0) }
}
这个模式的关键原则是:用户交互永远优先。进行中的动画在手势开始时必须被中断。
4.4 列表项动画
当 LazyList 中的项目增加、删除或重排时,使用 animateItem() 为单个项目添加平滑过渡:
LazyColumn {
items(items, key = { it.id }) { item ->
Row(modifier = Modifier.animateItem()) {
Text(item.name)
}
}
}
第五部分:自定义动画规格(AnimationSpec)
AnimationSpec 是 Compose 动画的核心自定义参数,它决定了动画在两个值之间的进展方式。
5.1 spring — 物理弹簧动画
基于物理模型的动画,使用 dampingRatio(阻尼比)和 stiffness(刚度)来定义行为。天然支持中断,非常适合用户驱动的手势:
val size by animateDpAsState(
targetValue = if (expanded) 200.dp else 100.dp,
animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessMedium
),
label = "size"
)
预定义的阻尼比:DampingRatioNoBouncy、DampingRatioLowBouncy、DampingRatioMediumBouncy、DampingRatioHighBouncy。
预定义的刚度:StiffnessVeryLow、StiffnessLow、StiffnessMedium、StiffnessHigh、StiffnessVeryHigh。
5.2 tween — 时间驱动动画
指定持续时间和缓动曲线,适合简单的定时过渡:
val alpha by animateFloatAsState(
targetValue = 1f,
animationSpec = tween(
durationMillis = 300,
delayMillis = 100,
easing = FastOutSlowInEasing
),
label = "alpha"
)
内置缓动函数:FastOutSlowInEasing、LinearOutSlowInEasing、FastOutLinearEasing、LinearEasing。也可以使用 CubicBezierEasing 自定义曲线。
5.3 keyframes — 关键帧动画
在精确的时间点定义精确的值:
val x by animateFloatAsState(
targetValue = 500f,
animationSpec = keyframes {
durationMillis = 370
0.0f at 0
200.0f at 100
400.0f at 200
500.0f at 350
},
label = "x"
)
5.4 repeatable / infiniteRepeatable — 重复动画
// 有限重复
val alpha by animateFloatAsState(
targetValue = 0f,
animationSpec = repeatable(
iterations = 3,
animation = tween(500),
repeatMode = RepeatMode.Reverse
),
label = "alpha"
)
// 无限重复
val infiniteTransition = rememberInfiniteTransition(label = "infinite")
val color by infiniteTransition.animateColor(
initialValue = Color.Red,
targetValue = Color.Blue,
animationSpec = infiniteRepeatable(
animation = tween(1000),
repeatMode = RepeatMode.Reverse
),
label = "color"
)
5.5 snap — 即时跳变
不做过渡,直接跳到目标值,可选延迟:
val alpha by animateFloatAsState(
targetValue = 1f,
animationSpec = snap(delayMillis = 200),
label = "alpha"
)
5.6 自定义数据类型动画
要动画自定义对象,需要通过 TwoWayConverter 将其转换为 AnimationVector:
data class MySize(val width: Dp, val height: Dp)
val MySizeVectorConverter = TwoWayConverter(
convertToVector = { size: MySize ->
AnimationVector2D(size.width.value, size.height.value)
},
convertFromVector = { vector: AnimationVector2D ->
MySize(vector.v1.dp, vector.v2.dp)
}
)
val animSize: MySize by animateValueAsState(
targetValue = MySize(200.dp, 100.dp),
typeConverter = MySizeVectorConverter,
label = "size"
)
Compose 内置了常用类型的转换器:Color.VectorConverter、Dp.VectorConverter、Offset.VectorConverter、IntOffset.VectorConverter 等。
第六部分:共享元素过渡(Shared Element Transitions)
共享元素过渡在两个共享相同内容的可组合函数之间创建无缝的视觉连续性,常用于从列表到详情页的过渡。
6.1 核心 API
需要两个作用域:
SharedTransitionScope(来自SharedTransitionLayout)AnimatedVisibilityScope(来自AnimatedContent、AnimatedVisibility或NavHost)
6.2 sharedElement() vs sharedBounds()
6.3 基础实现
var showDetails by remember { mutableStateOf(false) }
SharedTransitionLayout {
AnimatedContent(showDetails, label = "shared transition") { targetState ->
val isDetail = targetState
Box(
modifier = Modifier
.sharedElement(
state = rememberSharedContentState(key = "hero_image"),
animatedVisibilityScope = this@AnimatedContent
)
) {
if (!isDetail) ListItem() else DetailView()
}
}
}
6.4 自定义共享元素
边界动画曲线(boundsTransform):
val boundsTransform = BoundsTransform { initialBounds, targetBounds ->
keyframes {
durationMillis = 300
initialBounds at 0 using ArcMode.ArcBelow using FastOutSlowInEasing
targetBounds at 300
}
}
Text(
"Cupcake",
modifier = Modifier.sharedBounds(
state,
scope,
boundsTransform = boundsTransform
)
)
缩放模式(resizeMode):
ResizeMode.RemeasureToBounds:每帧重新测量和布局,适合不同宽高比的内容ResizeMode.ScaleToBounds(推荐用于文字):缩放测量后的布局以适应边界,防止文字重排
条件启用(SharedContentConfig):
val config = object : SharedTransitionScope.SharedContentConfig {
override val SharedTransitionScope.SharedContentState.isEnabled: Boolean
get() = transition.currentState == "A" && transition.targetState == "B"
}
Modifier.sharedElement(
rememberSharedContentState(key = "key", config = config),
scope
)
6.5 与导航集成
Navigation 2:
SharedTransitionLayout {
NavHost(navController, startDestination = "home") {
composable("home") {
HomeScreen(
sharedTransitionScope = this@SharedTransitionLayout,
animatedVisibilityScope = this@composable,
onItemClick = { navController.navigate("details/$it") }
)
}
composable("details/{id}") {
DetailsScreen(
sharedTransitionScope = this@SharedTransitionLayout,
animatedVisibilityScope = this@composable
)
}
}
}
Navigation 3:
SharedTransitionLayout {
val backStack = rememberNavBackStack(HomeRoute)
NavDisplay(
backStack = backStack,
entryProvider = entryProvider {
entry<HomeRoute> {
HomeScreen(
sharedTransitionScope = this@SharedTransitionLayout,
animatedVisibilityScope = LocalNavAnimatedContentScope.current,
onItemClick = { backStack.add(DetailsRoute(it)) }
)
}
}
)
}
6.6 常见使用场景
异步图片(配合 Coil):
AsyncImage(
model = ImageRequest.Builder(LocalContext.current)
.data("image-url")
.crossfade(true)
.placeholderMemoryCacheKey("image-key")
.memoryCacheKey("image-key")
.build(),
contentDescription = null,
modifier = Modifier
.size(120.dp)
.sharedBounds(
rememberSharedContentState(key = "image-key"),
animatedVisibilityScope = this
)
)
文字过渡:
Text(
text = "Shared Text",
modifier = Modifier
.wrapContentWidth()
.sharedBounds(
rememberSharedContentState(key = "shared text"),
animatedVisibilityScope = this,
enter = fadeIn(),
exit = fadeOut(),
resizeMode = SharedTransitionScope.ResizeMode.scaleToBounds()
)
)
6.7 注意事项与限制
Modifier 顺序很关键:
sharedElement()之前的修饰符定义初始/目标边界,之后的修饰符应用于动画结果。匹配的组件之间顺序必须一致Key 必须唯一:在
LazyList中推荐使用data class或附加 item ID 避免冲突当前限制:不支持 Android View 与 Compose 之间的互操作(包括
Dialog、ModalBottomSheet、AndroidView)
第七部分:动画测试
使用 ComposeTestRule 可以确定性地运行动画测试,完全控制测试时钟:
@get:Rule
val rule = createComposeRule()
@Test
fun testAnimationWithClock() {
// 1. 暂停时钟自动推进
rule.mainClock.autoAdvance = false
var enabled by mutableStateOf(false)
rule.setContent {
val color by animateColorAsState(
targetValue = if (enabled) Color.Red else Color.Green,
animationSpec = tween(durationMillis = 250)
)
Box(Modifier.size(64.dp).background(color))
}
// 2. 启动动画
enabled = true
// 3. 手动推进时钟
rule.mainClock.advanceTimeBy(50L)
// 4. 验证中间状态
rule.onRoot().captureToImage().assertAgainstGolden()
}
关键点:
autoAdvance = false暂停动画自动运行advanceTimeBy(duration)手动推进时间(会向上取整到最接近的帧持续时间)可以在特定时间点截取 UI 状态或检查属性值
第八部分:动画工具(Android Studio Tooling)
Android Studio 的 Animation Preview 面板是调试和预览 Compose 动画的利器。
支持检查的 API
animate*AsStateCrossFaderememberInfiniteTransitionAnimatedContentupdateTransitionAnimatedVisibility
功能
逐帧预览:手动逐步查看过渡的精确渲染状态
值检查:查看过渡期间所有活跃动画的实时插值
状态模拟:在任意初始和目标状态之间预览,无需触发代码
多动画协调:在同一视图中检查和协调多个并发动画
最佳实践
给 updateTransition 和 AnimatedVisibility 添加明确的 label 参数,在 Animation Preview 中可以获得更清晰的调试信息:
val transition = updateTransition(targetState = isVisible, label = "Visibility Transition")
AnimatedVisibility(visible = isVisible, label = "Card Fade In") { ... }
第九部分:性能优化
9.1 阶段优化
Compose 动画发生在三个阶段之一:组合、布局、绘制。绘制阶段的动画性能最优,因为它避免了重组和重新布局的开销。
优先使用:
Modifier.graphicsLayer { }— 在绘制阶段执行Lambda 版本的修饰符(如
Modifier.offset { })— 在布局阶段执行但不触发重组
避免:
在动画中读取状态值触发重组(如
Modifier.offset(x.dp)中的x如果是状态读取会触发重组)
9.2 背景色动画优化
用 drawBehind 替代 background:
// 推荐:在绘制阶段
val color by animateColorAsState(targetValue = Color.Red, label = "color")
Box(modifier = Modifier.drawBehind { drawRect(color) })
// 不推荐:触发重组
Box(modifier = Modifier.background(color))
9.3 阴影动画优化
用 graphicsLayer 替代 shadow:
// 推荐:在绘制阶段
val elevation by animateDpAsState(targetValue = 8.dp, label = "elevation")
Box(modifier = Modifier.graphicsLayer { shadowElevation = elevation.toPx() })
// 不推荐:可能触发布局
Box(modifier = Modifier.shadow(elevation))
9.4 文字动画优化
对文字进行缩放/旋转时,设置 TextMotion.Animated:
val textStyle = LocalTextStyle.current.copy(textMotion = TextMotion.Animated)
val scale by animateFloatAsState(targetValue = 1.5f, label = "scale")
Text(
text = "Animated text",
style = textStyle,
modifier = Modifier.graphicsLayer { scaleX = scale; scaleY = scale }
)
9.5 性能检查清单
[ ] 优先在绘制阶段执行动画(
graphicsLayer、lambda 修饰符)[ ] 背景色动画使用
drawBehind[ ] 阴影动画使用
graphicsLayer.shadowElevation[ ] 文字动画设置
TextMotion.Animated[ ]
animateContentSize()放在尺寸修饰符之前[ ] 列表项使用
animateItem()而非手动动画[ ] 使用 Baseline Profiles 优化首帧性能
第十部分:动画矢量(Animated Vector Drawables)
Compose 支持三种矢量动画方式:
10.1 AnimatedVectorDrawable(实验性)
加载传统的 Android XML 动画矢量,通过布尔值切换开始和结束状态:
@Composable
fun AnimatedVectorDrawable() {
val image = AnimatedImageVector.animatedVectorResource(R.drawable.ic_hourglass_animated)
var atEnd by remember { mutableStateOf(false) }
Image(
painter = rememberAnimatedVectorPainter(image, atEnd),
contentDescription = "Timer",
modifier = Modifier.clickable { atEnd = !atEnd },
contentScale = ContentScale.Crop
)
}
需要标准的 Android XML drawable 文件。目前 API 仍处于实验阶段。
10.2 ImageVector + Compose 动画 API
对于纯 Compose 原生的矢量动画(如路径变形、动态渲染),将 ImageVector 与 Compose 的值动画 API 配合使用。
10.3 Lottie
对于复杂的、由设计师制作的动画,使用 Lottie 加载 JSON 格式的动画文件:
val composition by rememberLottieComposition(
LottieCompositionSpec.RawRes(R.raw.my_animation)
)
val progress by animateLottieCompositionAsState(composition)
LottieAnimation(composition, progress)
结语
Jetpack Compose 的动画系统提供了从简单到复杂的完整解决方案:
学习路径建议:
入门:从
animate*AsState和AnimatedVisibility开始,理解声明式动画的思想进阶:掌握
Transition多属性协调、AnimationSpec自定义、AnimatedContent内容切换高级:学习
Animatable手势驱动动画、共享元素过渡、自定义矢量动画精通:理解分阶段性能优化、动画测试、Android Studio Animation Preview
建议创建一个练习项目,逐一实现本文中的示例代码,亲自感受 Compose 动画系统带来的开发体验提升。