15、Compose动画之过渡动画:AnimatedVisibility、AnimatedContent、Crossfade

聊到Compose动画,前面几章我们一直在讲单个属性的变化——大小、颜色、位移这些。但实际项目中,我们更多时候需要处理的是整个组件的出现/消失,或者内容切换的场景。比如弹窗淡入、列表项滑出、Tab切换内容……这些就是过渡动画的用武之地。

我个人习惯把过渡动画分成三类:显隐过渡内容切换交叉淡入淡出。Compose提供了三个对应的API:AnimatedVisibilityAnimatedContentCrossfade。今天我们就一个一个拆开来看。

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("从顶部滑入的通知")
}

这里有个关键点:enterexit参数可以组合多个动画。用+号连接就行。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内部使用了LazyColumnLazyRow,可能会出现动画卡顿。这是因为列表的懒加载机制和动画系统有冲突。我的建议是:把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,因为同时执行的效果更自然。

核心理解:AnimatedContenttargetState可以是任何类型——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?我个人觉得,当切换的内容大小和位置不变,只是内容本身变化时,用Crossfade最合适。比如头像切换、封面图切换。如果内容大小会变,或者你想做更丰富的过渡效果,那就用AnimatedContent

15.5 三者的对比与选择

说了这么多,我们来总结一下这三个API的适用场景:

API 适用场景 动画类型 性能
AnimatedVisibility 组件的出现/消失 淡入淡出、滑动、缩放、展开 高(只影响单个组件)
AnimatedContent 不同内容之间的切换 淡入淡出、滑动、自定义 中(需要同时处理进出)
Crossfade 简单的内容替换 仅淡入淡出 高(实现简单)

嗯,这里要注意:AnimatedContentCrossfade在内部都会同时保留新旧两个组件,直到动画结束。如果你的组件很重(比如加载了大图),建议在动画结束后手动释放资源。不过Compose的DisposableEffect可以帮你做这件事。

下面这张图展示了三者的核心逻辑关系:

Compose 过渡动画核心逻辑 AnimatedVisibility AnimatedContent Crossfade 核心参数 • visible: Boolean • enter: EnterTransition • exit: ExitTransition 核心参数 • targetState: Any • transitionSpec • content: (state) -> Unit 核心参数 • targetState: Any • animationSpec • content: (state) -> Unit 选择建议:显隐用 AnimatedVisibility,切换用 AnimatedContent,简单替换用 Crossfade

最后说一句:这三个API都是声明式动画的典型代表。你不需要手动控制动画的启停,只需要告诉Compose「状态变了」,剩下的交给框架。这和View时代的AnimationSetAnimatorSet比起来,开发效率提升了一大截。

我在项目中用AnimatedVisibility做过一个折叠面板,用AnimatedContent做过一个引导页切换,用Crossfade做过一个图片轮播。每个场景都只需要几十行代码。嗯,这就是Compose的魅力所在。


公众号:蓝海资料掘金营,微信deep3321