Compose 动画进阶:AnimatedVisibility、AnimatedContent、Crossfade

说实话,Compose 的动画 API 是我从 View 体系迁移过来后,最让我「真香」的部分。以前在 XML 里写个淡入淡出,要定义两个动画文件,还要在代码里手动控制 startAnimation。现在?几行代码搞定。

今天咱们聊聊三个最常用的进阶动画组件:AnimatedVisibilityAnimatedContentCrossfade。它们覆盖了日常开发中 80% 的 UI 切换场景。

Compose 动画进阶三剑客 UI 切换动画 AnimatedVisibility 组件出现/消失动画 AnimatedContent 内容切换 + 过渡动画 Crossfade 交叉淡入淡出 适用场景对比 AnimatedVisibility → 显示/隐藏单个组件 AnimatedContent → 切换不同内容布局 Crossfade → 两个状态间淡入淡出

AnimatedVisibility:让组件优雅地出现和消失

这个 API 是我项目里用得最频繁的。说白了,它就是给 if/else 显示逻辑加了一层动画包装。

核心用法:传入一个 visible 布尔值,Compose 会自动处理 enter/exit 动画。

@Composable
fun AnimatedVisibilityDemo() {
    var visible by remember { mutableStateOf(true) }
    
    Column {
        Button(onClick = { visible = !visible }) {
            Text(if (visible) "隐藏" else "显示")
        }
        
        AnimatedVisibility(
            visible = visible,
            enter = slideInVertically() + fadeIn(),
            exit = slideOutVertically() + fadeOut()
        ) {
            Card(
                modifier = Modifier.padding(8.dp),
                colors = CardDefaults.cardColors(
                    containerColor = MaterialTheme.colorScheme.primaryContainer
                )
            ) {
                Text(
                    text = "这段内容会带着动画出现/消失",
                    modifier = Modifier.padding(16.dp)
                )
            }
        }
    }
}

我个人习惯把 enterexit 参数拆开写,这样可读性更好。你想想看,如果所有动画都挤在一行,后期维护时自己都看不懂。

小技巧slideInVertically 默认是从底部滑入。如果想从顶部滑入,可以设置 initialOffsetY = { -it }

AnimatedContent:内容切换的「变形金刚」

这个组件比 AnimatedVisibility 更灵活。它不只是控制显示/隐藏,而是在两个不同的内容之间做过渡动画

我在项目中遇到过这样一个需求:一个详情页需要根据用户选择的 Tab 切换不同的信息卡片。如果用传统的 if/else,切换会非常生硬。用 AnimatedContent 就丝滑多了。

@Composable
fun AnimatedContentDemo() {
    var state by remember { mutableIntStateOf(0) }
    
    Column {
        Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
            Button(onClick = { state = 0 }) { Text("卡片A") }
            Button(onClick = { state = 1 }) { Text("卡片B") }
            Button(onClick = { state = 2 }) { Text("卡片C") }
        }
        
        AnimatedContent(
            targetState = state,
            transitionSpec = {
                // 使用 infix 写法,更简洁
                fadeIn() togetherWith fadeOut()
            },
            label = "contentAnimation"
        ) { currentState ->
            // 根据 currentState 渲染不同内容
            when (currentState) {
                0 -> CardA()
                1 -> CardB()
                2 -> CardC()
            }
        }
    }
}

注意transitionSpec 里的 lambda 返回的是 ContentTransform。我见过有人把 fadeInfadeOut 写反了,结果新内容淡出、旧内容淡入——完全反过来了。

嗯,这里要注意一点:AnimatedContenttargetState 可以是任何类型,不一定是 Int。我习惯用枚举或者 sealed class,这样类型更安全。

Crossfade:最简单的淡入淡出方案

Crossfade 是三个里面最「专一」的——它只做一件事:在两个内容之间交叉淡入淡出

说白了,就是旧内容慢慢消失的同时,新内容慢慢出现。中间会有一段时间两者半透明叠加。

@Composable
fun CrossfadeDemo() {
    var isLogin by remember { mutableStateOf(true) }
    
    Crossfade(
        targetState = isLogin,
        animationSpec = tween(durationMillis = 500)
    ) { loggedIn ->
        if (loggedIn) {
            UserProfile()
        } else {
            LoginButton()
        }
    }
}

我曾经在一个登录/登出的切换场景里用过 Crossfade。效果比直接跳转好太多——用户不会感觉页面「闪」了一下,而是平滑过渡。

什么时候用 Crossfade?当你只有两个状态,而且希望过渡尽可能平滑时。比如:登录态/非登录态、白天模式/夜间模式、展开/收起。

三个 API 的对比总结

API 核心能力 适用场景 动画控制粒度
AnimatedVisibility 控制单个组件的出现/消失 列表项展开、提示条显示、加载状态 细(可分别控制 enter/exit)
AnimatedContent 在不同内容之间切换 Tab 切换、页面转场、状态机 中(可自定义过渡组合)
Crossfade 两个内容交叉淡入淡出 登录/登出、主题切换、简单状态切换 粗(只有淡入淡出)

避坑指南

这几个 API 用起来虽然爽,但坑也不少。我踩过的几个,分享给你:

  • 动画不执行?检查一下 key 是否正确。Compose 通过 key 来识别内容是否变化。如果 key 没变,动画不会触发。
  • 动画卡顿?看看是不是在动画内容里做了耗时操作。比如在 AnimatedContent 的 content lambda 里加载网络图片——这会导致动画掉帧。
  • 动画方向反了?检查 slideInslideOut 的偏移量设置。我曾经把 slideOutVerticallytargetOffsetY 设成了正数,结果组件往下滑出去了——其实应该用负数。

我的建议:刚开始用的时候,先只加 fadeIn/fadeOut。等效果跑通了,再叠加 slidescale。一口吃不成胖子,动画也是。

好了,这三个 API 的核心用法就这些。你想想看,以前在 View 体系里要实现类似效果,得写多少代码?现在 Compose 把这些复杂度都封装好了,我们只需要关注业务逻辑和动画效果本身。

下次遇到 UI 切换的场景,别再用 if/else 硬切了——试试这几个动画组件,用户体验会提升一个档次。