15、Compose动画之过渡动画:AnimatedVisibility、AnimatedContent、Crossfade
聊到Compose动画,前面几章我们一直在讲单个属性的变化——大小、颜色、位移这些。但实际项目中,我们更多时候需要处理的是整个组件的出现/消失,或者内容切换的场景。比如弹窗淡入、列表项滑出、Tab切换内容……这些就是过渡动画的用武之地。
我个人习惯把过渡动画分成三类:显隐过渡、内容切换、交叉淡入淡出。Compose提供了三个对应的API:AnimatedVisibility、AnimatedContent、Crossfade。今天我们就一个一个拆开来看。
15.1 AnimatedVisibility:让组件优雅地出现和消失
先看最常用的——AnimatedVisibility。它的作用很简单:当visible参数从false变成true时,子组件会以动画方式出现;反之则消失。
基本用法长这样:
var visible by remember { mutableStateOf(true) }
AnimatedVisibility(visible = visible) {
Text("我是会动的文字")
}
默认情况下,它会同时做淡入淡出 + 缩放。但说实话,默认效果比较平淡。我在项目中做过一个通知栏,默认动画看起来就像个半成品。后来我改成了从顶部滑入:
AnimatedVisibility(
visible = visible,
enter = slideInVertically() + fadeIn(),
exit = slideOutVertically() + fadeOut()
) {
Text("从顶部滑入的通知")
}
这里有个关键点:enter和exit参数可以组合多个动画。用+号连接就行。Compose会同时执行这些动画,而不是串行。
slideInVertically传一个spring()的动画规格。比如slideInVertically(animationSpec = spring(dampingRatio = 0.5f)),效果会非常Q弹。
15.2 自定义进入/退出动画
除了滑入滑出,Compose还提供了很多内置的过渡效果:
| 动画类型 | 说明 | 常用场景 |
|---|---|---|
fadeIn() / fadeOut() |
透明度变化 | 弹窗、提示 |
slideInHorizontally() / slideOutHorizontally() |
水平滑入/滑出 | 侧边栏、列表项 |
slideInVertically() / slideOutVertically() |
垂直滑入/滑出 | 通知栏、底部弹窗 |
expandIn() / shrinkOut() |
从中心展开/收缩 | 图片预览、卡片展开 |
scaleIn() / scaleOut() |
缩放 | 按钮点击反馈 |
我曾经踩过一个坑:用expandIn做列表项的展开动画,结果发现它默认从左上角开始展开,而不是从中心。后来查文档才知道,expandIn有个expandFrom参数,可以指定对齐方式:
AnimatedVisibility(
visible = visible,
enter = expandIn(expandFrom = Alignment.Center)
) {
// 从中心展开的内容
}
AnimatedVisibility内部使用了LazyColumn或LazyRow,可能会出现动画卡顿。这是因为列表的懒加载机制和动画系统有冲突。我的建议是:把AnimatedVisibility放在列表项外部,或者用animateItemPlacement()替代。
15.3 AnimatedContent:内容切换的优雅方案
接下来是AnimatedContent。它和AnimatedVisibility的区别在于:AnimatedVisibility控制的是同一个组件的显隐,而AnimatedContent控制的是不同内容之间的切换。
举个例子,Tab切换:
var tabIndex by remember { mutableStateOf(0) }
AnimatedContent(targetState = tabIndex) { index ->
when (index) {
0 -> Text("首页内容")
1 -> Text("发现内容")
2 -> Text("我的内容")
}
}
默认情况下,AnimatedContent会做淡入淡出。但说实话,这个效果太普通了。我更喜欢用slideInHorizontally配合slideOutHorizontally,做出左右滑动的效果:
AnimatedContent(
targetState = tabIndex,
transitionSpec = {
// 根据目标状态决定滑动方向
if (targetState > initialState) {
slideInHorizontally { width -> width } + fadeIn() togetherWith
slideOutHorizontally { width -> -width } + fadeOut()
} else {
slideInHorizontally { width -> -width } + fadeIn() togetherWith
slideOutHorizontally { width -> width } + fadeOut()
}
}
) { index ->
when (index) {
0 -> Text("首页内容")
1 -> Text("发现内容")
2 -> Text("我的内容")
}
}
这里有个关键点:togetherWith。它表示进入动画和退出动画同时执行。如果你想让进入动画先执行,退出动画后执行,可以用using关键字。不过我个人很少用using,因为同时执行的效果更自然。
AnimatedContent的targetState可以是任何类型——Int、String、甚至是自定义数据类。只要状态变了,它就会触发动画。这比View时代的ViewSwitcher灵活太多了。
15.4 Crossfade:最简单的交叉淡入淡出
最后是Crossfade。它其实是AnimatedContent的一个简化版——只做淡入淡出,没有滑动、缩放这些花哨效果。但正因为简单,它在某些场景下反而更好用。
比如图片切换:
var imageIndex by remember { mutableStateOf(0) }
Crossfade(targetState = imageIndex) { index ->
Image(
painter = painterResource(images[index]),
contentDescription = null,
modifier = Modifier.fillMaxSize()
)
}
默认的淡入淡出时长是300毫秒。你可以通过animationSpec调整:
Crossfade(
targetState = imageIndex,
animationSpec = tween(durationMillis = 500)
) { index ->
// ...
}
我记得有一次做相册应用,用户快速滑动图片时,Crossfade会出现闪烁。后来发现是因为targetState变化太快,前一个动画还没结束,新的动画又开始了。解决方案是加一个debounce:
val debouncedIndex by snapshotFlow { imageIndex }
.debounce(200)
.collectAsState(initial = imageIndex)
Crossfade(targetState = debouncedIndex) { ... }
Crossfade最合适。比如头像切换、封面图切换。如果内容大小会变,或者你想做更丰富的过渡效果,那就用AnimatedContent。
15.5 三者的对比与选择
说了这么多,我们来总结一下这三个API的适用场景:
| API | 适用场景 | 动画类型 | 性能 |
|---|---|---|---|
| AnimatedVisibility | 组件的出现/消失 | 淡入淡出、滑动、缩放、展开 | 高(只影响单个组件) |
| AnimatedContent | 不同内容之间的切换 | 淡入淡出、滑动、自定义 | 中(需要同时处理进出) |
| Crossfade | 简单的内容替换 | 仅淡入淡出 | 高(实现简单) |
嗯,这里要注意:AnimatedContent和Crossfade在内部都会同时保留新旧两个组件,直到动画结束。如果你的组件很重(比如加载了大图),建议在动画结束后手动释放资源。不过Compose的DisposableEffect可以帮你做这件事。
下面这张图展示了三者的核心逻辑关系:
最后说一句:这三个API都是声明式动画的典型代表。你不需要手动控制动画的启停,只需要告诉Compose「状态变了」,剩下的交给框架。这和View时代的AnimationSet、AnimatorSet比起来,开发效率提升了一大截。
我在项目中用AnimatedVisibility做过一个折叠面板,用AnimatedContent做过一个引导页切换,用Crossfade做过一个图片轮播。每个场景都只需要几十行代码。嗯,这就是Compose的魅力所在。
公众号:蓝海资料掘金营,微信deep3321