19、Compose动画:animate*AsState、AnimatedVisibility、AnimatedContent、Transition、实战:流畅动画

动画,在移动端开发里,从来都不是锦上添花的东西。它直接影响用户对App「流畅感」的认知。我见过太多应用,功能没问题,但就是觉得「硬邦邦的」,点个按钮、切个页面,生硬得像在翻纸质书。嗯,Compose 的动画系统,就是为了解决这个问题而生的。

今天这一章,我们集中火力,把 Compose 里最核心的动画 API 过一遍。我会结合我自己的踩坑经历,告诉你哪些地方容易翻车,哪些技巧能让动画真正「丝滑」。

核心观点:Compose 动画的核心思想是「状态驱动」。你不需要手动控制动画的开始和结束,只需要告诉 Compose:「这个值要变了」,剩下的插值、刷新、重绘,框架全包了。

Compose 动画核心体系 animate*AsState 单个值动画 AnimatedVisibility 显示/隐藏动画 AnimatedContent 内容切换动画 Transition 多值组合动画 实战:流畅动画 状态驱动 声明式动画 自动插值 从简单到复杂,从单值到组合

19.1 animate*AsState:最简单的单值动画

如果你只需要让一个值「动起来」,比如透明度从0到1,或者一个按钮的尺寸从小变大,那 animate*AsState 就是你的首选。说白了,它就是一个带缓动效果的 State。

举个例子,我想让一个浮点数在 0f 和 1f 之间平滑过渡:

@Composable
fun FadeInDemo() {
    var visible by remember { mutableStateOf(false) }
    val alpha by animateFloatAsState(
        targetValue = if (visible) 1f else 0f,
        animationSpec = tween(durationMillis = 500)
    )

    Box(
        modifier = Modifier
            .size(100.dp)
            .graphicsLayer(alpha = alpha)
            .background(Color.Blue)
            .clickable { visible = !visible }
    )
}

你看,代码量非常少。我只需要定义目标值,剩下的交给 animateFloatAsState。它内部会帮你做差值计算,并且只在值变化时才触发重组。这一点很重要——不会因为动画的每一帧都触发整个 Composable 重组,性能有保障。

我的习惯:对于简单的颜色、尺寸、偏移量动画,我几乎只用 animate*AsState。它足够轻量,而且不容易出错。但要注意,如果你需要同时控制多个属性(比如位置+大小+颜色),那它就不太够用了,得用后面的 Transition。

19.2 AnimatedVisibility:显示与隐藏的优雅方案

这个 API 我太喜欢了。以前在 View 系统里,要做个淡入淡出或者滑动出现,得写一堆 Animation 和 AnimationListener。在 Compose 里,一行代码搞定。

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

    Column {
        Button(onClick = { visible = !visible }) {
            Text("切换显示")
        }

        AnimatedVisibility(
            visible = visible,
            enter = slideInVertically() + fadeIn(),
            exit = slideOutVertically() + fadeOut()
        ) {
            Text("我是会动的内容", fontSize = 24.sp)
        }
    }
}

这里有个细节:enter 和 exit 参数支持「组合动画」。你可以用 + 运算符把多个动画效果叠加在一起。比如上面这个例子,就是「从上方滑入 + 淡入」同时发生。

我曾经踩过的坑:如果你在 AnimatedVisibility 内部使用了 LazyColumn 或者 LazyRow,记得给它们设置 animateItemPlacement() 修饰符。否则,当列表项增删时,其他项会「瞬移」而不是平滑移动。这个坑我花了一下午才定位到。

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

当你的 UI 需要在两种完全不同的布局之间切换时,AnimatedContent 就派上用场了。比如一个详情页,从「加载中」状态切换到「展示数据」状态,或者一个卡片从「折叠」变成「展开」。

@Composable
fun AnimatedContentDemo() {
    var state by remember { mutableStateOf("loading") }

    AnimatedContent(targetState = state) { targetState ->
        when (targetState) {
            "loading" -> LoadingView()
            "content" -> ContentView()
            "error" -> ErrorView()
        }
    }
}

它的核心思想是:根据当前状态,决定显示哪个 Composable。切换时,旧的 Composable 执行退出动画,新的执行进入动画。而且,它默认会尝试「对齐」两个布局中相同 key 的元素,实现更自然的过渡。

我个人觉得,AnimatedContent 最适合的场景是「状态机驱动的 UI」。比如网络请求的三个状态:加载中、成功、失败。用这个 API 做切换,用户体验会好很多。

19.4 Transition:多值动画的「指挥官」

当你需要同时控制多个属性,并且这些属性之间有联动关系时,Transition 就是最佳选择。它允许你定义一个「动画状态机」,然后为每个状态指定一组目标值。

@Composable
fun TransitionDemo() {
    var selected by remember { mutableStateOf(false) }

    val transition = updateTransition(
        targetState = selected,
        label = "cardTransition"
    )

    val color by transition.animateColor(
        transitionSpec = { tween(300) }
    ) { isSelected ->
        if (isSelected) Color.Blue else Color.Gray
    }

    val size by transition.animateDp(
        transitionSpec = { spring(dampingRatio = 0.6f) }
    ) { isSelected ->
        if (isSelected) 120.dp else 80.dp
    }

    Box(
        modifier = Modifier
            .size(size)
            .background(color)
            .clickable { selected = !selected }
    )
}

你看,Transition 把多个动画「绑定」到了同一个状态上。当 selected 变化时,颜色和尺寸会同时开始动画。而且,你可以为每个属性指定不同的 animationSpec。比如颜色用线性插值,尺寸用弹簧效果。

关键区别:animate*AsState 是「单值单状态」,Transition 是「多值单状态」。如果你需要管理多个状态之间的切换(比如 A→B→C),那就要考虑用 AnimatedContent 或者自定义 Transition 状态机了。

19.5 实战:打造流畅动画的四个原则

理论说完了,我们来点实际的。我在项目中总结了几条让动画「流畅」的经验,分享给你。

原则 说明 我的做法
1. 避免过度重组 动画每一帧都触发重组,如果 Composable 太大,会卡顿 把动画部分抽成独立 Composable,用 derivedStateOf 减少计算
2. 选择合适的 Spec tween 适合线性运动,spring 适合弹性效果 按钮点击用 spring,页面切换用 tween
3. 使用 graphicsLayer 修改位置、大小、透明度时,优先用 graphicsLayer 而不是 offset 或 size 因为 graphicsLayer 只触发绘制阶段,不触发布局
4. 测试低端设备 高端机动画流畅,不代表低端机也流畅 我习惯在模拟器上把 CPU 降频到 50% 测试

嗯,这里重点说一下第二条。很多人觉得 spring 效果很酷,就到处用。但你要知道,spring 动画的时长是不固定的,它取决于阻尼比和刚度。如果你在列表滚动时用 spring,可能会导致动画「追不上」用户的操作。我一般只在「一次性交互」中使用 spring,比如点赞按钮的弹跳效果。

一个小技巧:如果你想让动画看起来更「跟手」,可以试试 animateDecay 配合 fling 效果。比如列表滑动到边缘时的回弹动画。这个 API 比较冷门,但效果非常好。

19.6 避坑指南:动画性能的隐形杀手

最后,说几个我实际项目中踩过的坑。希望能帮你少走弯路。

  • 不要在动画中做大量计算:比如在 animateFloatAsState 的 onFinish 回调里做网络请求或者数据库操作。这会导致动画帧率骤降。
  • 注意 remember 的作用域:如果你把动画状态定义在父 Composable 中,而父 Composable 频繁重组,动画会被打断。我习惯把动画状态定义在「最靠近使用它的地方」。
  • 避免嵌套 AnimatedVisibility:两个 AnimatedVisibility 嵌套时,内部的动画可能会被外部的「吞掉」。如果必须嵌套,考虑用 Transition 替代。
  • 动画时长不要太长:我个人认为,移动端动画超过 500ms 就会让用户觉得「慢」。除非是引导页或者特殊效果,否则尽量控制在 300ms 以内。

好了,这一章的内容就到这里。Compose 动画其实不难,难的是「什么时候用哪个 API」。记住:简单场景用 animate*AsState,显示隐藏用 AnimatedVisibility,内容切换用 AnimatedContent,多值联动用 Transition。按照这个思路选,基本不会错。


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