13、动画基础:animate*AsState、AnimatedVisibility、AnimatedContent、Crossfade
动画这东西,说白了就是让界面“活”起来。用户点个按钮,如果只是生硬地跳转,体验就很廉价。我在早期做项目时,总觉得动画是锦上添花,后来发现——好的动画能直接提升用户对App品质的感知。Jetpack Compose 的动画 API 设计得非常优雅,你不需要像传统 View 系统那样写一堆 Animator 和监听器,几行代码就能搞定流畅的过渡效果。
今天咱们就聊四个最常用的动画工具:animate*AsState、AnimatedVisibility、AnimatedContent 和 Crossfade。掌握了它们,你就能应对 90% 的日常动画需求。
13.1 animate*AsState:让单个值动起来
这是最基础的动画 API。你想想看,很多时候我们只需要让一个数值平滑变化——比如按钮的透明度、卡片的高度、图标的旋转角度。animate*AsState 就是干这个的。
它的用法极其简单:把目标值传进去,它返回一个动画中的值。你只需要在 Composable 里读取这个返回值,Compose 会自动帮你做插值。
// 基础用法:让一个浮点数从 0 过渡到 1
var enabled by remember { mutableStateOf(false) }
val alpha by animateFloatAsState(
targetValue = if (enabled) 1f else 0.3f,
animationSpec = tween(durationMillis = 500)
)
Box(
modifier = Modifier
.size(100.dp)
.graphicsLayer(alpha = alpha)
.background(Color.Blue)
.clickable { enabled = !enabled }
)
animationSpec 单独抽出来定义,方便复用。比如定义一个 fastSpec = tween(300) 和一个 smoothSpec = spring(dampingRatio = 0.5f),项目里到处都能用。
除了 animateFloatAsState,还有 animateDpAsState、animateColorAsState、animateIntAsState 等等。命名规则就是 animate + 类型 + AsState。
我在项目中遇到过一个问题:用 animateDpAsState 改变卡片高度时,里面的文字会跟着“跳”。后来发现是因为高度变化时,子组件重新布局导致的。解决办法是给子组件加 animateContentSize() 修饰符,或者用 clipToBounds 裁剪掉溢出部分。
13.2 AnimatedVisibility:优雅地显示/隐藏
这个 API 我几乎每个项目都会用。你想让一个组件出现时从底部滑入、消失时淡出——用 AnimatedVisibility 一行代码搞定。
var visible by remember { mutableStateOf(true) }
AnimatedVisibility(
visible = visible,
enter = slideInVertically() + fadeIn(),
exit = slideOutVertically() + fadeOut()
) {
Text("我是会动的内容", fontSize = 20.sp)
}
enter 和 exit 参数支持组合多个动画效果。常用的有:
| 动画类型 | 说明 | 常用参数 |
|---|---|---|
fadeIn / fadeOut |
透明度变化 | animationSpec |
slideInVertically / slideOutVertically |
垂直方向滑入/滑出 | initialOffsetY / targetOffsetY |
slideInHorizontally / slideOutHorizontally |
水平方向滑入/滑出 | initialOffsetX / targetOffsetX |
expandIn / shrinkOut |
从中心展开/收缩 | expandFrom / shrinkTowards |
scaleIn / scaleOut |
缩放效果 | initialScale / targetScale |
AnimatedVisibility 包裹列表项时,如果列表本身也在滚动,会出现动画卡顿。原因是动画和滚动都在占用主线程。解决办法是给列表项加 key,或者用 LazyColumn 的 animateItemPlacement 替代。
13.3 AnimatedContent:内容切换的“变形金刚”
当你想在两个不同的 UI 之间做过渡时——比如 Tab 切换、状态切换——AnimatedContent 是更好的选择。它比 AnimatedVisibility 更强大,因为它能同时处理“旧内容消失”和“新内容出现”两个过程。
var state by remember { mutableStateOf("A") }
AnimatedContent(targetState = state) { target ->
// 根据当前状态渲染不同的 UI
when (target) {
"A" -> Text("页面 A", fontSize = 24.sp)
"B" -> Text("页面 B", fontSize = 24.sp)
"C" -> Text("页面 C", fontSize = 24.sp)
}
}
默认的过渡效果是淡入淡出。你可以通过 transitionSpec 自定义:
AnimatedContent(
targetState = state,
transitionSpec = {
// 从右向左滑入,同时淡入淡出
slideInHorizontally { width -> width } + fadeIn() togetherWith
slideOutHorizontally { width -> -width } + fadeOut()
}
) { target ->
when (target) {
"A" -> Text("页面 A")
"B" -> Text("页面 B")
"C" -> Text("页面 C")
}
}
这里有个细节:togetherWith 是 Compose 动画的“组合子”,它把进入动画和退出动画绑定在一起。你还可以用 using 来指定对齐方式,比如 SizeTransform(clip = false) 让内容在切换时不裁剪。
AnimatedContent 配合 SizeTransform 效果非常丝滑。记得把 clip 设为 false,否则切换过程中内容会被裁剪。
13.4 Crossfade:最简单的淡入淡出
如果你只需要一个纯粹的淡入淡出效果——不想要滑动、缩放那些花哨的过渡——Crossfade 是最简洁的选择。它本质上就是 AnimatedContent 的一个特化版本,只做透明度交叉渐变。
var currentPage by remember { mutableStateOf(0) }
Crossfade(targetState = currentPage) { page ->
when (page) {
0 -> LoginScreen()
1 -> HomeScreen()
2 -> ProfileScreen()
}
}
就这么简单。你传一个状态进去,它自动处理旧内容淡出、新内容淡入。默认动画时长是 300 毫秒,你可以通过 animationSpec 调整:
Crossfade(
targetState = currentPage,
animationSpec = tween(durationMillis = 600)
) { page ->
// ...
}
为什么会有 Crossfade 和 AnimatedContent 两个 API?我个人理解是:Crossfade 是“开箱即用”的,你不需要配置任何过渡效果,拿来就用。AnimatedContent 是“可定制”的,适合需要复杂过渡的场景。说白了,Crossfade 就是 AnimatedContent 的“懒人版”。
AnimatedContent 还是 Crossfade,它们都会同时保留新旧两个内容在视图层级中。如果你的内容很重(比如大图、列表),建议在切换完成后手动释放资源,或者用 remember 缓存轻量级状态。
13.5 实战:组合使用这些动画
光讲理论没意思。我拿一个实际场景来说:做一个可折叠的详情卡片。点击卡片头部,详情区域展开/收起,同时头部图标旋转 180 度。
@Composable
fun ExpandableCard(title: String, content: String) {
var expanded by remember { mutableStateOf(false) }
val rotateAngle by animateFloatAsState(
targetValue = if (expanded) 180f else 0f,
animationSpec = tween(300)
)
Card(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp)
.clickable { expanded = !expanded }
) {
Column {
Row(
modifier = Modifier.fillMaxWidth().padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(title, fontWeight = FontWeight.Bold)
Icon(
painter = painterResource(R.drawable.ic_arrow),
contentDescription = null,
modifier = Modifier.rotate(rotateAngle)
)
}
AnimatedVisibility(visible = expanded) {
Text(
text = content,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
)
}
}
}
}
你看,这里同时用了 animateFloatAsState 控制旋转角度,AnimatedVisibility 控制内容展开收起。两个动画互不干扰,代码却非常简洁。
AnimatedVisibility 的内容高度是动态的(比如网络加载的文本),展开动画可能会“跳一下”。解决办法是给内容设置一个固定最大高度,或者用 animateContentSize() 修饰符让高度变化也带动画。
13.6 动画规范与性能
最后聊点经验之谈。动画不是越多越好,我见过有些 App 每个按钮都带缩放动画,结果用户觉得“太晃了”。我的原则是:
- 功能性动画(如展开/收起、页面切换)一定要做,能提升可用性
- 装饰性动画(如按钮缩放、背景渐变)适可而止,别喧宾夺主
- 时长控制:展开动画 200-300ms,页面切换 300-500ms,太短显得生硬,太长用户会等得不耐烦
- 慎用无限循环动画:比如旋转加载图标,记得在页面不可见时暂停,否则耗电
性能方面,Compose 动画默认运行在 UI 线程,但因为是声明式的,Compose 会智能地跳过不必要的重组。如果你发现动画掉帧,检查一下是不是在动画过程中触发了大量重组——比如在 animateFloatAsState 的回调里更新了其他状态。
好了,这四种动画工具你掌握了吗?从最简单的 animate*AsState 到最灵活的 AnimatedContent,它们覆盖了日常开发中绝大多数的动画需求。动手写几个例子,你会发现 Compose 的动画真的比传统 View 系统爽太多了。