21、Compose动画之布局动画:AnimatedVisibility的布局模式、Modifier.animateContentSize

布局动画,说白了就是让组件在「出现、消失、大小变化」这些场景下动起来。在 View 时代,我们得靠 LayoutTransition 或者自己写 ValueAnimator 监听布局变化,代码量不小。到了 Compose,事情变得简单多了——两个核心 API 就能覆盖绝大多数场景:AnimatedVisibilityModifier.animateContentSize

我个人习惯把布局动画分成两类:「进出动画」「尺寸变化动画」。前者管的是组件从无到有、从有到无的过程,后者管的是组件内容变化导致自身大小改变时的平滑过渡。今天我们就来把这两块彻底讲透。

核心观点:布局动画不是「锦上添花」,而是「用户体验的刚需」。突然闪现的 UI 会让用户觉得「卡了一下」,而平滑的过渡会让交互变得自然、流畅。

21.1 AnimatedVisibility:控制组件的进出场

AnimatedVisibility 是 Compose 中最常用的布局动画 API。它的用法非常直观——你只需要告诉它「当前是否可见」,剩下的动画细节由它自动处理。

@Composable
fun DemoAnimatedVisibility() {
    var visible by remember { mutableStateOf(true) }

    Column {
        Button(onClick = { visible = !visible }) {
            Text(if (visible) "隐藏" else "显示")
        }

        AnimatedVisibility(visible = visible) {
            Text("我是会动的内容")
        }
    }
}

嗯,就这么简单。但如果你以为它只有这点本事,那就太小看它了。AnimatedVisibility 提供了三种布局模式,这才是真正需要理解的地方。

21.2 三种布局模式:选对模式,事半功倍

AnimatedVisibility 的布局模式通过 enterexit 参数来控制。我把它归纳为三种典型模式:

模式 适用场景 典型 enter/exit 配置
淡入淡出 提示信息、Toast、状态标签 fadeIn() / fadeOut()
滑动进入 底部弹出面板、侧边栏、下拉菜单 slideInVertically() / slideOutVertically()
缩放进入 弹窗、气泡提示、头像展开 scaleIn() / scaleOut()

我在项目中遇到过这样一个场景:一个列表项需要展开显示详情。如果用默认的 fadeIn,效果很平淡。后来改成 expandVertically() + fadeIn() 的组合,展开时内容从顶部向下「生长」出来,视觉上舒服多了。

AnimatedVisibility(
    visible = expanded,
    enter = expandVertically() + fadeIn(),
    exit = shrinkVertically() + fadeOut()
) {
    // 详情内容
    DetailContent()
}
小技巧:多个动画效果可以用「+」运算符组合。比如 slideInVertically() + fadeIn() 会让组件一边滑入一边淡入,效果更丰富。

21.3 Modifier.animateContentSize:让尺寸变化「丝般顺滑」

这个 Modifier 是我个人非常喜欢的一个 API。它解决了一个很常见的痛点:当组件的内容发生变化时,组件的尺寸会「跳变」。比如一个 Text 组件,文字从短变长,如果没有动画,布局会瞬间撑开,看起来很突兀。

@Composable
fun DemoAnimateContentSize() {
    var expanded by remember { mutableStateOf(false) }

    Column(
        modifier = Modifier
            .animateContentSize()
            .clickable { expanded = !expanded }
    ) {
        Text("点击我展开/收起")
        if (expanded) {
            Text("这里是展开后的详细内容,会撑大整个 Column")
            Text("而且这个过程是平滑动画的")
        }
    }
}

你想想看,如果没有 animateContentSize(),这个 Column 会在 expanded 变化时瞬间跳变。加上之后,它会自动在旧尺寸和新尺寸之间做插值动画。底层原理其实是对 IntSize 做了动画,然后应用到布局约束上。

注意:animateContentSize 默认的动画时长是 300ms,动画曲线是 FastOutSlowInEasing。如果你觉得太快或太慢,可以自定义:animateContentSize(animationSpec = tween(500))

21.4 实战对比:两种 API 的典型场景

为了帮你更清晰地理解两者的区别,我画了一张对比图:

布局动画核心 API 对比 AnimatedVisibility ▸ 控制组件的出现/消失 ▸ 支持多种进出场模式 ▸ 典型场景: - 展开/收起面板 - 弹出提示/菜单 - 列表项增删 核心参数:visible、enter、exit Modifier.animateContentSize ▸ 控制组件尺寸的平滑变化 ▸ 自动监听内容变化 ▸ 典型场景: - 文字内容动态变化 - 图片加载完成撑开 - 列表项高度变化 核心参数:animationSpec、finishedListener

21.5 避坑指南:我曾经踩过的坑

讲几个我实际开发中遇到的坑,希望能帮你少走弯路。

  • AnimatedVisibility 嵌套问题:如果你在 AnimatedVisibility 内部再嵌套一个 AnimatedVisibility,动画可能会互相干扰。我曾经在列表项的展开详情里又套了一个子面板的动画,结果动画时序全乱了。解决方案是:尽量保持单层动画,或者用 animateContentSize 替代内层进出动画。
  • animateContentSize 与 LazyColumn 的冲突:在 LazyColumn 中使用 animateContentSize 要小心。因为 LazyColumn 会复用 item,动画状态可能会错乱。我建议只在确定 item 不会被频繁回收的场景下使用,或者配合 key 参数使用。
  • 动画时长与用户感知:默认的 300ms 在大多数场景下是合适的。但如果你做的是底部弹出面板,我个人习惯把时长调到 400-500ms,配合 OvershootInterpolator,效果会更「弹」一些,用户会觉得更自然。
调试小技巧:在开发阶段,可以给动画加上 AnimationSpecrepeatable() 包装,让动画循环播放,方便观察效果。上线前再改回一次性动画。

21.6 小结

布局动画是 Compose 动画体系中最「接地气」的部分。AnimatedVisibility 管的是「有没有」,animateContentSize 管的是「变多大」。两者配合使用,基本能覆盖 90% 的布局变化场景。

我个人建议:能用 animateContentSize 解决的,就不要用 AnimatedVisibility。因为尺寸变化动画更轻量,性能开销更小。只有当你需要控制「出现/消失」的完整过程(包括透明度、位移、缩放等组合效果)时,才用 AnimatedVisibility。

嗯,这一章的内容就到这里。记住一句话:好的布局动画,用户不会注意到它;不好的布局动画,用户会一直注意到它


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