14、Compose动画之显式动画:Animatable的使用、AnimationSpec详解、动画的取消与重置
显式动画,说白了就是你自己掌控动画的每一个细节。跟隐式动画那种「我帮你搞定」的思路不同,显式动画把方向盘交到你手里——什么时候开始、持续多久、中间怎么变,全由你说了算。
我个人习惯把显式动画理解为「手动挡」。虽然操作起来比自动挡麻烦一点,但你能玩出的花样也多得多。今天我们就来聊聊 Compose 里最核心的显式动画工具:Animatable。
Animatable:动画的底层引擎
Animatable 是 Compose 动画体系里最基础的动画值容器。它不像 animate*AsState 那样帮你自动触发,而是需要你手动调用 animateTo 或 snapTo 来驱动。
先看一个最简单的例子:
@Composable
fun SimpleAnimatableExample() {
val animatable = remember { Animatable(0f) }
LaunchedEffect(Unit) {
animatable.animateTo(100f, animationSpec = tween(1000))
}
Text("当前值: ${animatable.value}")
}
嗯,这里要注意:Animatable 必须用 remember 包裹,否则每次重组都会重新创建。我在项目中遇到过有人忘了加 remember,结果动画永远停在初始值——排查了半天才发现是这个问题。
AnimationSpec 详解:动画的「配方」
AnimationSpec 决定了动画怎么「动」。它不是一个具体的类,而是一组规范,Compose 内置了几种常用的实现:
| 类型 | 特点 | 适用场景 |
|---|---|---|
tween |
线性插值,可设置时长和缓动曲线 | 大多数常规动画 |
spring |
弹簧效果,有弹性和阻尼 | 需要自然回弹的交互 |
keyframes |
关键帧动画,可定义多个时间点的值 | 复杂的分段动画 |
repeatable |
重复执行某个动画 | 循环动画、加载指示器 |
infiniteRepeatable |
无限重复 | 持续旋转、脉冲效果 |
你想想看,tween 就像匀速开车,spring 像撞到弹簧上弹回来,keyframes 则像分段导航——每个时间点设定一个目标值。
来个 spring 的例子:
val animatable = remember { Animatable(0f) }
LaunchedEffect(Unit) {
animatable.animateTo(
100f,
animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessLow
)
)
}
我曾经在做一个下拉刷新组件时,用了 spring 来实现回弹效果。阻尼比调得太小,结果列表弹得像果冻一样晃个不停——用户反馈说「这 App 是不是坏了」。后来我把 dampingRatio 调到 0.7 左右,效果才自然。
动画的取消与重置
显式动画的另一个优势是:你可以随时叫停。
Animatable 提供了几个关键方法:
animateTo:启动动画到目标值snapTo:瞬间跳转到某个值,没有过渡stop:立即停止当前动画
为什么要取消动画?最常见的场景是:用户快速滑动列表时,之前的动画还没结束,新的动画又开始了。如果不取消,两个动画会打架,导致值乱跳。
看这段代码:
val animatable = remember { Animatable(0f) }
// 用户点击时触发
scope.launch {
// 先取消当前动画
animatable.stop()
// 再启动新动画
animatable.animateTo(newValue)
}
这里有个坑:stop() 必须在协程里调用,因为它是一个挂起函数。我刚开始用的时候,直接在点击事件里调 stop(),编译器直接报错——嗯,挂起函数不能在非协程环境调用。
重置动画就更简单了:
// 重置到初始值
animatable.snapTo(initialValue)
snapTo 不会触发任何过渡,值瞬间改变。适合用来「复位」动画状态。
核心要点:Animatable 的 stop() 和 animateTo 都是挂起函数,必须在协程作用域内调用。推荐用 LaunchedEffect 或 rememberCoroutineScope 来管理协程。
实战:一个可中断的进度条
光说不练假把式。我们做一个可中断的进度条动画:
@Composable
fun InterruptibleProgressBar() {
val progress = remember { Animatable(0f) }
val scope = rememberCoroutineScope()
Column(modifier = Modifier.padding(16.dp)) {
// 进度条
LinearProgressIndicator(
progress = { progress.value },
modifier = Modifier.fillMaxWidth().height(20.dp)
)
Spacer(modifier = Modifier.height(8.dp))
// 控制按钮
Row {
Button(onClick = {
scope.launch {
progress.animateTo(1f, tween(3000))
}
}) {
Text("开始")
}
Spacer(modifier = Modifier.width(8.dp))
Button(onClick = {
scope.launch {
progress.stop()
}
}) {
Text("暂停")
}
Spacer(modifier = Modifier.width(8.dp))
Button(onClick = {
scope.launch {
progress.snapTo(0f)
}
}) {
Text("重置")
}
}
}
}
这个例子把 animateTo、stop、snapTo 都用上了。你点「开始」进度条慢慢走,点「暂停」立刻停住,点「重置」瞬间归零。
小技巧:如果你想让暂停后继续从当前位置开始,可以记录 progress.value,下次 animateTo 时从当前值开始。我做过一个下载进度条,就是靠这个思路实现的「断点续传」效果。
SVG:显式动画知识体系
下面这张图帮你理清显式动画的核心脉络:
注意事项:Animatable 的 animateTo 如果被多次调用,后一次会打断前一次。如果你希望排队执行,需要用 Job 来管理协程。我曾经在快速点击按钮时,动画值来回跳,就是因为没处理好并发问题。
显式动画虽然需要你多写几行代码,但它带来的控制力是隐式动画无法比拟的。尤其是当你需要处理用户交互、中断、重置这些场景时,Animatable 几乎是唯一的选择。
好了,这一章的内容就到这里。记住三个关键词:animateTo 启动、stop 停止、snapTo 跳转。掌握了它们,你就能在 Compose 里随心所欲地控制动画了。