10、动画基础:animate*AsState 系列,AnimatedVisibility,Crossfade 与 AnimatedContent
动画这东西,说白了就是让界面「活」起来。我刚开始用 Compose 写动画时,第一反应是「终于不用再跟 XML 动画文件死磕了」。在传统 Android 开发里,做个平移动画要写一堆监听器,还要考虑生命周期。但在 Compose 里,动画是声明式的——你只管描述「最终长什么样」,剩下的交给框架。
这一章我们聊四个核心动画工具:animate*AsState 系列、AnimatedVisibility、Crossfade 和 AnimatedContent。它们覆盖了 90% 的日常动画需求。我个人习惯把它们分成两类:值动画和布局动画。
核心思想:Compose 动画本质上是「状态驱动」。你改变一个状态值,动画系统自动帮你插值过渡。不需要手动控制帧率或监听器。
10.1 animate*AsState:最简单的值动画
先看最常用的 animateFloatAsState、animateColorAsState、animateDpAsState 等。它们的作用是:当目标值变化时,自动从旧值平滑过渡到新值。
举个例子。你有个按钮,点击后透明度从 1.0 变成 0.3。传统写法要写 ObjectAnimator,但在 Compose 里只需要这样:
@Composable
fun FadeButton() {
var isVisible by remember { mutableStateOf(true) }
val alpha by animateFloatAsState(
targetValue = if (isVisible) 1f else 0.3f,
animationSpec = tween(durationMillis = 500)
)
Button(
onClick = { isVisible = !isVisible },
modifier = Modifier.alpha(alpha)
) {
Text("点击切换透明度")
}
}
嗯,这里要注意:animateFloatAsState 返回的是一个 State<Float>,所以要用 by 委托。我个人习惯把 animationSpec 参数单独拎出来,方便复用。比如定义一个全局的 spring() 弹性动画,比 tween 手感更自然。
小技巧:如果你想让动画「弹」一下,用 spring(dampingRatio = 0.3f)。我在做点赞动画时就用这个,效果比线性过渡好很多。
除了 Float,还有 animateColorAsState 做颜色过渡。比如主题切换时,背景色从白色渐变到深色:
val bgColor by animateColorAsState(
targetValue = if (isDark) Color(0xFF121212) else Color.White,
animationSpec = tween(600)
)
你想想看,以前写颜色动画要算 ARGB 插值,现在一行代码搞定。这就是 Compose 的威力。
10.2 AnimatedVisibility:优雅的显示与隐藏
接下来是 AnimatedVisibility。它解决的是「组件出现和消失时怎么过渡」的问题。我见过太多人用 if 直接控制组件显示,结果界面一闪一闪的,用户体验很差。
AnimatedVisibility 的用法非常直观:
@Composable
fun ExpandableCard() {
var expanded by remember { mutableStateOf(false) }
Column {
Button(onClick = { expanded = !expanded }) {
Text(if (expanded) "收起" else "展开")
}
AnimatedVisibility(
visible = expanded,
enter = slideInVertically() + fadeIn(),
exit = slideOutVertically() + fadeOut()
) {
Card(modifier = Modifier.fillMaxWidth()) {
Text("这里是展开后的内容,会平滑出现和消失")
}
}
}
}
这里 enter 和 exit 参数可以组合多种动画。比如 slideInVertically() 让内容从上方滑入,fadeIn() 同时淡入。我曾经在项目中遇到一个问题:列表项删除时,如果只用 fadeOut,视觉上会「原地消失」,感觉很奇怪。后来加上 shrinkVertically() 让高度收缩,效果就自然多了。
避坑指南:我曾经在 AnimatedVisibility 里嵌套 LazyColumn,结果动画卡顿。原因是 LazyColumn 的测量和动画冲突。解决方案:把 AnimatedVisibility 放在 LazyColumn 的 item 内部,而不是包裹整个列表。
另外,AnimatedVisibility 还支持 MutableTransitionState,可以手动控制动画的初始状态。比如让组件第一次加载时从底部滑入:
val state = remember { MutableTransitionState(false) }
state.targetState = true
AnimatedVisibility(
visibleState = state,
enter = slideInVertically { it }
) {
Text("首次加载从底部滑入")
}
10.3 Crossfade:淡入淡出切换内容
Crossfade 适合做「内容切换」的场景。比如 Tab 切换、页面切换。它的原理很简单:旧内容淡出,新内容淡入,中间有交叉重叠。
看代码:
@Composable
fun ProfileScreen(tabIndex: Int) {
Crossfade(targetState = tabIndex) { index ->
when (index) {
0 -> Text("个人信息")
1 -> Text("设置页面")
2 -> Text("关于我们")
}
}
}
这里 targetState 是切换的「钥匙」。每次 tabIndex 变化,Crossfade 会自动执行淡入淡出。你还可以自定义 animationSpec 控制时长和缓动曲线。
我个人习惯把 Crossfade 和 TabRow 搭配使用。但要注意:如果切换的内容很大(比如整个页面),建议用 AnimatedContent 而不是 Crossfade,因为 Crossfade 会同时保留新旧两个内容在布局中,可能造成性能开销。
经验之谈:我在做一个图片浏览应用时,用 Crossfade 切换不同分辨率的图片。缩略图切换到高清图时,用 300ms 的淡入淡出,视觉上非常平滑,用户几乎感觉不到加载延迟。
10.4 AnimatedContent:更自由的过渡动画
最后是 AnimatedContent,它是 Crossfade 的「升级版」。除了淡入淡出,你还可以自定义滑动、缩放、旋转等任意过渡效果。
基本用法:
@Composable
fun AnimatedCounter(count: Int) {
AnimatedContent(
targetState = count,
transitionSpec = {
if (targetState > initialState) {
slideInVertically { it } + fadeIn() togetherWith
slideOutVertically { -it } + fadeOut()
} else {
slideInVertically { -it } + fadeIn() togetherWith
slideOutVertically { it } + fadeOut()
}
},
label = "counter"
) { targetCount ->
Text(text = "$targetCount", fontSize = 48.sp)
}
}
这段代码实现了一个「数字增减时上下滑动」的效果。注意 transitionSpec 里的 togetherWith 关键字——它把入场和退场动画「绑定」在一起。你还可以用 using 指定 SizeTransform 来控制尺寸变化。
为什么会需要 AnimatedContent 而不是直接用 Crossfade?因为有些场景下,淡入淡出不够直观。比如切换图片时,用户希望看到「左右滑动」的效果,而不是两张图重叠。这时候 AnimatedContent 就派上用场了。
小技巧:如果你想让动画更「跟手」,可以结合 animateContentSize 修饰符。比如聊天列表里,消息内容展开时,列表项高度平滑变化。这个组合我在即时通讯项目里用过多次,效果很赞。
10.5 动画性能与最佳实践
动画虽好,但别滥用。我总结了几条原则:
- 避免在动画中频繁重组:用
remember缓存不变的部分,比如animationSpec定义在外部。 - 优先用
animate*AsState:它是最轻量的,适合单个属性变化。 - 复杂动画用
Animatable:如果需要手动控制动画进度(比如拖拽手势),用Animatable配合LaunchedEffect。 - 测试动画:用
ComposeTestRule的mainClock.advanceTimeBy()模拟时间流逝,验证动画结束后的状态。
我曾经在项目里遇到一个坑:AnimatedVisibility 的 enter 动画如果用了 spring(),在低端机上会出现「弹跳过头」导致内容被裁剪。后来我把 spring 的 dampingRatio 调高到 0.7,问题解决。
| API | 适用场景 | 性能 | 自定义程度 |
|---|---|---|---|
| animate*AsState | 单个属性变化(颜色、大小、透明度) | 高 | 低 |
| AnimatedVisibility | 组件显示/隐藏 | 中 | 中 |
| Crossfade | 内容淡入淡出切换 | 中 | 低 |
| AnimatedContent | 复杂过渡(滑动、缩放、组合) | 中低 | 高 |
最后说一句:动画不是装饰品,它是用户体验的一部分。好的动画让用户觉得「流畅自然」,差的动画只会让人觉得「卡顿做作」。我个人建议,每个动画都问自己三个问题:这个动画有意义吗?它帮助用户理解界面变化了吗?它会不会让用户等太久?
嗯,这一章的内容就到这里。记住,动手写代码比看十遍文档都管用。打开你的 Android Studio,把上面的例子跑一遍,然后试着改改参数,看看效果有什么不同。