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)
        }
    }
}

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

我曾经踩过的坑:如果你在 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 而不是 offsetsize 因为 graphicsLayer 只触发绘制阶段,不触发布局
4. 测试低端设备 高端机动画流畅,不代表低端机也流畅 我习惯在模拟器上把 CPU 降频到 50% 测试

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

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

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

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

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

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


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