10、动画基础:animate*AsState 系列,AnimatedVisibility,Crossfade 与 AnimatedContent

动画这东西,说白了就是让界面「活」起来。我刚开始用 Compose 写动画时,第一反应是「终于不用再跟 XML 动画文件死磕了」。在传统 Android 开发里,做个平移动画要写一堆监听器,还要考虑生命周期。但在 Compose 里,动画是声明式的——你只管描述「最终长什么样」,剩下的交给框架。

这一章我们聊四个核心动画工具:animate*AsState 系列、AnimatedVisibilityCrossfadeAnimatedContent。它们覆盖了 90% 的日常动画需求。我个人习惯把它们分成两类:值动画布局动画

核心思想:Compose 动画本质上是「状态驱动」。你改变一个状态值,动画系统自动帮你插值过渡。不需要手动控制帧率或监听器。

Compose 动画核心工具 动画 API animate*AsState 值动画:颜色、大小、偏移 AnimatedVisibility 显示/隐藏 + 入场/退场 Crossfade 淡入淡出切换内容 AnimatedContent 自定义过渡动画 声明式动画:状态驱动,自动插值

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

先看最常用的 animateFloatAsStateanimateColorAsStateanimateDpAsState 等。它们的作用是:当目标值变化时,自动从旧值平滑过渡到新值

举个例子。你有个按钮,点击后透明度从 1.0 变成 0.3。传统写法要写 ObjectAnimator,但在 Compose 里只需要这样:

@Composable
fun FadeButton() {
    var isVisible by remember { mutableStateOf(true) }
    val alpha by animateFloatAsState(
        targetValue = if (isVisible) 1f else 0.3f,
        animationSpec = tween(durationMillis = 500)
    )
    
    Button(
        onClick = { isVisible = !isVisible },
        modifier = Modifier.alpha(alpha)
    ) {
        Text("点击切换透明度")
    }
}

嗯,这里要注意:animateFloatAsState 返回的是一个 State<Float>,所以要用 by 委托。我个人习惯把 animationSpec 参数单独拎出来,方便复用。比如定义一个全局的 spring() 弹性动画,比 tween 手感更自然。

小技巧:如果你想让动画「弹」一下,用 spring(dampingRatio = 0.3f)。我在做点赞动画时就用这个,效果比线性过渡好很多。

除了 Float,还有 animateColorAsState 做颜色过渡。比如主题切换时,背景色从白色渐变到深色:

val bgColor by animateColorAsState(
    targetValue = if (isDark) Color(0xFF121212) else Color.White,
    animationSpec = tween(600)
)

你想想看,以前写颜色动画要算 ARGB 插值,现在一行代码搞定。这就是 Compose 的威力。

10.2 AnimatedVisibility:优雅的显示与隐藏

接下来是 AnimatedVisibility。它解决的是「组件出现和消失时怎么过渡」的问题。我见过太多人用 if 直接控制组件显示,结果界面一闪一闪的,用户体验很差。

AnimatedVisibility 的用法非常直观:

@Composable
fun ExpandableCard() {
    var expanded by remember { mutableStateOf(false) }
    
    Column {
        Button(onClick = { expanded = !expanded }) {
            Text(if (expanded) "收起" else "展开")
        }
        
        AnimatedVisibility(
            visible = expanded,
            enter = slideInVertically() + fadeIn(),
            exit = slideOutVertically() + fadeOut()
        ) {
            Card(modifier = Modifier.fillMaxWidth()) {
                Text("这里是展开后的内容,会平滑出现和消失")
            }
        }
    }
}

这里 enterexit 参数可以组合多种动画。比如 slideInVertically() 让内容从上方滑入,fadeIn() 同时淡入。我曾经在项目中遇到一个问题:列表项删除时,如果只用 fadeOut,视觉上会「原地消失」,感觉很奇怪。后来加上 shrinkVertically() 让高度收缩,效果就自然多了。

避坑指南:我曾经在 AnimatedVisibility 里嵌套 LazyColumn,结果动画卡顿。原因是 LazyColumn 的测量和动画冲突。解决方案:把 AnimatedVisibility 放在 LazyColumnitem 内部,而不是包裹整个列表。

另外,AnimatedVisibility 还支持 MutableTransitionState,可以手动控制动画的初始状态。比如让组件第一次加载时从底部滑入:

val state = remember { MutableTransitionState(false) }
state.targetState = true

AnimatedVisibility(
    visibleState = state,
    enter = slideInVertically { it }
) {
    Text("首次加载从底部滑入")
}

10.3 Crossfade:淡入淡出切换内容

Crossfade 适合做「内容切换」的场景。比如 Tab 切换、页面切换。它的原理很简单:旧内容淡出,新内容淡入,中间有交叉重叠

看代码:

@Composable
fun ProfileScreen(tabIndex: Int) {
    Crossfade(targetState = tabIndex) { index ->
        when (index) {
            0 -> Text("个人信息")
            1 -> Text("设置页面")
            2 -> Text("关于我们")
        }
    }
}

这里 targetState 是切换的「钥匙」。每次 tabIndex 变化,Crossfade 会自动执行淡入淡出。你还可以自定义 animationSpec 控制时长和缓动曲线。

我个人习惯把 CrossfadeTabRow 搭配使用。但要注意:如果切换的内容很大(比如整个页面),建议用 AnimatedContent 而不是 Crossfade,因为 Crossfade 会同时保留新旧两个内容在布局中,可能造成性能开销。

经验之谈:我在做一个图片浏览应用时,用 Crossfade 切换不同分辨率的图片。缩略图切换到高清图时,用 300ms 的淡入淡出,视觉上非常平滑,用户几乎感觉不到加载延迟。

10.4 AnimatedContent:更自由的过渡动画

最后是 AnimatedContent,它是 Crossfade 的「升级版」。除了淡入淡出,你还可以自定义滑动、缩放、旋转等任意过渡效果。

基本用法:

@Composable
fun AnimatedCounter(count: Int) {
    AnimatedContent(
        targetState = count,
        transitionSpec = {
            if (targetState > initialState) {
                slideInVertically { it } + fadeIn() togetherWith 
                slideOutVertically { -it } + fadeOut()
            } else {
                slideInVertically { -it } + fadeIn() togetherWith 
                slideOutVertically { it } + fadeOut()
            }
        },
        label = "counter"
    ) { targetCount ->
        Text(text = "$targetCount", fontSize = 48.sp)
    }
}

这段代码实现了一个「数字增减时上下滑动」的效果。注意 transitionSpec 里的 togetherWith 关键字——它把入场和退场动画「绑定」在一起。你还可以用 using 指定 SizeTransform 来控制尺寸变化。

为什么会需要 AnimatedContent 而不是直接用 Crossfade?因为有些场景下,淡入淡出不够直观。比如切换图片时,用户希望看到「左右滑动」的效果,而不是两张图重叠。这时候 AnimatedContent 就派上用场了。

小技巧:如果你想让动画更「跟手」,可以结合 animateContentSize 修饰符。比如聊天列表里,消息内容展开时,列表项高度平滑变化。这个组合我在即时通讯项目里用过多次,效果很赞。

10.5 动画性能与最佳实践

动画虽好,但别滥用。我总结了几条原则:

  • 避免在动画中频繁重组:用 remember 缓存不变的部分,比如 animationSpec 定义在外部。
  • 优先用 animate*AsState:它是最轻量的,适合单个属性变化。
  • 复杂动画用 Animatable:如果需要手动控制动画进度(比如拖拽手势),用 Animatable 配合 LaunchedEffect
  • 测试动画:用 ComposeTestRulemainClock.advanceTimeBy() 模拟时间流逝,验证动画结束后的状态。

我曾经在项目里遇到一个坑:AnimatedVisibilityenter 动画如果用了 spring(),在低端机上会出现「弹跳过头」导致内容被裁剪。后来我把 springdampingRatio 调高到 0.7,问题解决。

API 适用场景 性能 自定义程度
animate*AsState 单个属性变化(颜色、大小、透明度)
AnimatedVisibility 组件显示/隐藏
Crossfade 内容淡入淡出切换
AnimatedContent 复杂过渡(滑动、缩放、组合) 中低

最后说一句:动画不是装饰品,它是用户体验的一部分。好的动画让用户觉得「流畅自然」,差的动画只会让人觉得「卡顿做作」。我个人建议,每个动画都问自己三个问题:这个动画有意义吗?它帮助用户理解界面变化了吗?它会不会让用户等太久?

嗯,这一章的内容就到这里。记住,动手写代码比看十遍文档都管用。打开你的 Android Studio,把上面的例子跑一遍,然后试着改改参数,看看效果有什么不同。

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