13、动画基础:animate*AsState、AnimatedVisibility、AnimatedContent、Crossfade

动画这东西,说白了就是让界面“活”起来。用户点个按钮,如果只是生硬地跳转,体验就很廉价。我在早期做项目时,总觉得动画是锦上添花,后来发现——好的动画能直接提升用户对App品质的感知。Jetpack Compose 的动画 API 设计得非常优雅,你不需要像传统 View 系统那样写一堆 Animator 和监听器,几行代码就能搞定流畅的过渡效果。

今天咱们就聊四个最常用的动画工具:animate*AsStateAnimatedVisibilityAnimatedContentCrossfade。掌握了它们,你就能应对 90% 的日常动画需求。

核心思想: Compose 动画本质上是“状态驱动”的。你改变一个值,动画系统自动帮你插值过渡,不需要手动控制帧率。
Compose 动画核心工具 动画 API 家族 animate*AsState 单个值过渡 AnimatedVisibility 显示/隐藏动画 AnimatedContent 内容切换动画 Crossfade 淡入淡出切换 💡 选择建议 • 单个属性变化(大小、颜色、偏移)→ animate*AsState • 组件出现/消失 → AnimatedVisibility • 内容切换(如Tab切换)→ AnimatedContent 或 Crossfade

13.1 animate*AsState:让单个值动起来

这是最基础的动画 API。你想想看,很多时候我们只需要让一个数值平滑变化——比如按钮的透明度、卡片的高度、图标的旋转角度。animate*AsState 就是干这个的。

它的用法极其简单:把目标值传进去,它返回一个动画中的值。你只需要在 Composable 里读取这个返回值,Compose 会自动帮你做插值。

// 基础用法:让一个浮点数从 0 过渡到 1
var enabled by remember { mutableStateOf(false) }
val alpha by animateFloatAsState(
    targetValue = if (enabled) 1f else 0.3f,
    animationSpec = tween(durationMillis = 500)
)

Box(
    modifier = Modifier
        .size(100.dp)
        .graphicsLayer(alpha = alpha)
        .background(Color.Blue)
        .clickable { enabled = !enabled }
)
我的习惯: 我一般把 animationSpec 单独抽出来定义,方便复用。比如定义一个 fastSpec = tween(300) 和一个 smoothSpec = spring(dampingRatio = 0.5f),项目里到处都能用。

除了 animateFloatAsState,还有 animateDpAsStateanimateColorAsStateanimateIntAsState 等等。命名规则就是 animate + 类型 + AsState

我在项目中遇到过一个问题:用 animateDpAsState 改变卡片高度时,里面的文字会跟着“跳”。后来发现是因为高度变化时,子组件重新布局导致的。解决办法是给子组件加 animateContentSize() 修饰符,或者用 clipToBounds 裁剪掉溢出部分。

13.2 AnimatedVisibility:优雅地显示/隐藏

这个 API 我几乎每个项目都会用。你想让一个组件出现时从底部滑入、消失时淡出——用 AnimatedVisibility 一行代码搞定。

var visible by remember { mutableStateOf(true) }

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

enterexit 参数支持组合多个动画效果。常用的有:

动画类型 说明 常用参数
fadeIn / fadeOut 透明度变化 animationSpec
slideInVertically / slideOutVertically 垂直方向滑入/滑出 initialOffsetY / targetOffsetY
slideInHorizontally / slideOutHorizontally 水平方向滑入/滑出 initialOffsetX / targetOffsetX
expandIn / shrinkOut 从中心展开/收缩 expandFrom / shrinkTowards
scaleIn / scaleOut 缩放效果 initialScale / targetScale
我曾经踩过的坑:AnimatedVisibility 包裹列表项时,如果列表本身也在滚动,会出现动画卡顿。原因是动画和滚动都在占用主线程。解决办法是给列表项加 key,或者用 LazyColumnanimateItemPlacement 替代。

13.3 AnimatedContent:内容切换的“变形金刚”

当你想在两个不同的 UI 之间做过渡时——比如 Tab 切换、状态切换——AnimatedContent 是更好的选择。它比 AnimatedVisibility 更强大,因为它能同时处理“旧内容消失”和“新内容出现”两个过程。

var state by remember { mutableStateOf("A") }

AnimatedContent(targetState = state) { target ->
    // 根据当前状态渲染不同的 UI
    when (target) {
        "A" -> Text("页面 A", fontSize = 24.sp)
        "B" -> Text("页面 B", fontSize = 24.sp)
        "C" -> Text("页面 C", fontSize = 24.sp)
    }
}

默认的过渡效果是淡入淡出。你可以通过 transitionSpec 自定义:

AnimatedContent(
    targetState = state,
    transitionSpec = {
        // 从右向左滑入,同时淡入淡出
        slideInHorizontally { width -> width } + fadeIn() togetherWith
        slideOutHorizontally { width -> -width } + fadeOut()
    }
) { target ->
    when (target) {
        "A" -> Text("页面 A")
        "B" -> Text("页面 B")
        "C" -> Text("页面 C")
    }
}

这里有个细节:togetherWith 是 Compose 动画的“组合子”,它把进入动画和退出动画绑定在一起。你还可以用 using 来指定对齐方式,比如 SizeTransform(clip = false) 让内容在切换时不裁剪。

我建议: 如果你的内容切换涉及尺寸变化(比如小图切换到大图),用 AnimatedContent 配合 SizeTransform 效果非常丝滑。记得把 clip 设为 false,否则切换过程中内容会被裁剪。

13.4 Crossfade:最简单的淡入淡出

如果你只需要一个纯粹的淡入淡出效果——不想要滑动、缩放那些花哨的过渡——Crossfade 是最简洁的选择。它本质上就是 AnimatedContent 的一个特化版本,只做透明度交叉渐变。

var currentPage by remember { mutableStateOf(0) }

Crossfade(targetState = currentPage) { page ->
    when (page) {
        0 -> LoginScreen()
        1 -> HomeScreen()
        2 -> ProfileScreen()
    }
}

就这么简单。你传一个状态进去,它自动处理旧内容淡出、新内容淡入。默认动画时长是 300 毫秒,你可以通过 animationSpec 调整:

Crossfade(
    targetState = currentPage,
    animationSpec = tween(durationMillis = 600)
) { page ->
    // ...
}

为什么会有 CrossfadeAnimatedContent 两个 API?我个人理解是:Crossfade 是“开箱即用”的,你不需要配置任何过渡效果,拿来就用。AnimatedContent 是“可定制”的,适合需要复杂过渡的场景。说白了,Crossfade 就是 AnimatedContent 的“懒人版”。

性能提示: 无论是 AnimatedContent 还是 Crossfade,它们都会同时保留新旧两个内容在视图层级中。如果你的内容很重(比如大图、列表),建议在切换完成后手动释放资源,或者用 remember 缓存轻量级状态。

13.5 实战:组合使用这些动画

光讲理论没意思。我拿一个实际场景来说:做一个可折叠的详情卡片。点击卡片头部,详情区域展开/收起,同时头部图标旋转 180 度。

@Composable
fun ExpandableCard(title: String, content: String) {
    var expanded by remember { mutableStateOf(false) }
    val rotateAngle by animateFloatAsState(
        targetValue = if (expanded) 180f else 0f,
        animationSpec = tween(300)
    )

    Card(
        modifier = Modifier
            .fillMaxWidth()
            .padding(8.dp)
            .clickable { expanded = !expanded }
    ) {
        Column {
            Row(
                modifier = Modifier.fillMaxWidth().padding(16.dp),
                horizontalArrangement = Arrangement.SpaceBetween,
                verticalAlignment = Alignment.CenterVertically
            ) {
                Text(title, fontWeight = FontWeight.Bold)
                Icon(
                    painter = painterResource(R.drawable.ic_arrow),
                    contentDescription = null,
                    modifier = Modifier.rotate(rotateAngle)
                )
            }

            AnimatedVisibility(visible = expanded) {
                Text(
                    text = content,
                    modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
                )
            }
        }
    }
}

你看,这里同时用了 animateFloatAsState 控制旋转角度,AnimatedVisibility 控制内容展开收起。两个动画互不干扰,代码却非常简洁。

注意: 如果 AnimatedVisibility 的内容高度是动态的(比如网络加载的文本),展开动画可能会“跳一下”。解决办法是给内容设置一个固定最大高度,或者用 animateContentSize() 修饰符让高度变化也带动画。

13.6 动画规范与性能

最后聊点经验之谈。动画不是越多越好,我见过有些 App 每个按钮都带缩放动画,结果用户觉得“太晃了”。我的原则是:

  • 功能性动画(如展开/收起、页面切换)一定要做,能提升可用性
  • 装饰性动画(如按钮缩放、背景渐变)适可而止,别喧宾夺主
  • 时长控制:展开动画 200-300ms,页面切换 300-500ms,太短显得生硬,太长用户会等得不耐烦
  • 慎用无限循环动画:比如旋转加载图标,记得在页面不可见时暂停,否则耗电

性能方面,Compose 动画默认运行在 UI 线程,但因为是声明式的,Compose 会智能地跳过不必要的重组。如果你发现动画掉帧,检查一下是不是在动画过程中触发了大量重组——比如在 animateFloatAsState 的回调里更新了其他状态。

好了,这四种动画工具你掌握了吗?从最简单的 animate*AsState 到最灵活的 AnimatedContent,它们覆盖了日常开发中绝大多数的动画需求。动手写几个例子,你会发现 Compose 的动画真的比传统 View 系统爽太多了。

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