21、Compose动画入门:Compose动画体系概述、animate*AsState系列函数、Animatable与AnimatableAsState
说实话,刚接触Compose动画时,我有点懵。毕竟做了好几年View动画,突然换了一套全新的思路,心里难免犯嘀咕。但用了一段时间后,我得说——Compose的动画设计,确实比View那套优雅太多了。
今天我们就来聊聊Compose动画的入门三板斧:动画体系整体长什么样、animate*AsState怎么用、以及Animatable和AnimatableAsState到底有什么区别。
一、Compose动画体系:一张图看懂
先别急着写代码,我们得先搞清楚Compose动画到底有哪些东西。我习惯把Compose动画分成三个层次:
下面这张图是我自己整理的,基本涵盖了Compose动画的核心脉络:
说白了,这三个东西就是Compose动画的「三驾马车」。你写动画时,90%的场景都逃不出它们的手掌心。下面我们一个一个来看。
二、animate*AsState:最常用的动画入口
如果你只想让某个值变化时有个平滑过渡,用animate*AsState就对了。它是一系列函数的统称,包括:
animateFloatAsState— 浮点数动画animateColorAsState— 颜色动画animateDpAsState— 尺寸动画animateIntAsState— 整数动画animateOffsetAsState— 偏移动画
用法极其简单。我给你看个例子:
@Composable
fun FadeInBox() {
var visible by remember { mutableStateOf(false) }
// 当 visible 变化时,alpha 会自动从 0f 过渡到 1f
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返回的是一个State<Float>,所以要用by委托来解包。我在项目中第一次写的时候忘了加by,结果动画没生效,排查了半天才发现是这个问题。
animationSpec参数。我个人最常用的是spring(),因为它做弹性效果特别自然,不需要手动算时长。
三、Animatable:更底层的控制力
animate*AsState虽然方便,但有个限制——它只能响应状态变化。如果你想手动控制动画的启停、重复、反向,那就得用Animatable了。
我举个例子你就明白了。假设你要做一个「呼吸灯」效果,让一个圆持续放大缩小:
@Composable
fun BreathingLight() {
val animatable = remember { Animatable(1f) }
LaunchedEffect(Unit) {
while (true) {
// 放大到1.5倍
animatable.animateTo(1.5f, tween(1000))
// 缩小回原样
animatable.animateTo(1f, tween(1000))
}
}
Box(
modifier = Modifier
.size(100.dp)
.graphicsLayer(scaleX = animatable.value, scaleY = animatable.value)
.background(Color.Red, shape = CircleShape)
)
}
看到区别了吗?Animatable可以让你在LaunchedEffect里用协程的方式手动控制动画流程。你可以写循环、写条件判断、甚至同时控制多个Animatable实例。
Animatable必须在remember中创建,否则每次重组都会重新创建,动画就白写了。我曾经踩过这个坑,调试了一下午才发现是忘记加remember了。
四、AnimatableAsState:两者的结合体
那AnimatableAsState又是什么?说白了,它是Animatable的「Compose化」版本。它把Animatable包装成了一个State对象,让你既能享受Animatable的灵活性,又能像animate*AsState那样声明式使用。
看个对比就清楚了:
| 特性 | animate*AsState | Animatable | AnimatableAsState |
|---|---|---|---|
| 声明式使用 | ✅ | ❌ | ✅ |
| 手动控制 | ❌ | ✅ | ✅ |
| 协程支持 | ❌ | ✅ | ✅ |
| 生命周期管理 | 自动 | 手动 | 自动 |
不过说实话,AnimatableAsState在实际项目中用得并不多。我个人觉得它的定位有点尴尬——简单场景用animate*AsState就够了,复杂场景直接用Animatable更灵活。AnimatableAsState夹在中间,反而让人选择困难。
五、实战:三种方式实现同一个效果
为了让你更直观地感受区别,我用三种方式实现同一个效果——点击按钮让一个方块旋转180度。
方式一:animateFloatAsState
@Composable
fun RotateBoxSimple() {
var rotated by remember { mutableStateOf(false) }
val rotation by animateFloatAsState(
targetValue = if (rotated) 180f else 0f,
animationSpec = tween(600)
)
Box(
modifier = Modifier
.size(80.dp)
.rotate(rotation)
.background(Color.Green)
.clickable { rotated = !rotated }
)
}
方式二:Animatable
@Composable
fun RotateBoxManual() {
val animatable = remember { Animatable(0f) }
Box(
modifier = Modifier
.size(80.dp)
.rotate(animatable.value)
.background(Color.Blue)
.clickable {
CoroutineScope(Dispatchers.Main).launch {
val target = if (animatable.value < 90f) 180f else 0f
animatable.animateTo(target, tween(600))
}
}
)
}
方式三:AnimatableAsState
@Composable
fun RotateBoxHybrid() {
var rotated by remember { mutableStateOf(false) }
val rotation by AnimatableAsState(
targetValue = if (rotated) 180f else 0f,
animationSpec = tween(600)
)
Box(
modifier = Modifier
.size(80.dp)
.rotate(rotation)
.background(Color.Orange)
.clickable { rotated = !rotated }
)
}
你想想看,这三种方式都能实现同样的效果。但实际项目中,我90%的情况都用第一种——简单、直接、不容易出错。只有需要循环动画或精细控制时,我才会掏出Animatable。
animate*AsState,把它的各种变体(Float、Color、Dp、Int、Offset)都玩熟。等你觉得「不够用了」,再去看Animatable。别一上来就想着用最底层的API,那样反而容易把自己绕晕。
六、避坑指南
最后分享几个我踩过的坑,希望能帮你省点时间:
- 动画不生效?检查一下
targetValue是否真的变了。Compose动画只会在目标值变化时触发,如果值没变,动画不会执行。 - 动画卡顿?看看是不是在动画过程中频繁重组了。可以用
derivedStateOf或remember缓存中间计算结果。 - Animatable的animateTo不执行?确保你在
LaunchedEffect或协程作用域里调用它。直接在Composable函数体里调用会报错。 - 颜色动画闪白?用
animateColorAsState时,记得指定ColorSpace,否则默认的插值方式可能导致中间颜色发白。
好了,Compose动画的入门就聊到这里。这三种方式你记住了吗?animate*AsState是日常主力,Animatable是进阶武器,AnimatableAsState是中间过渡。下次写动画时,先想想你的场景适合哪一种,选对了工具,事半功倍。