25、Compose动画与自定义:Animatable与AnimationSpec、SpringAnimation与TweenAnimation、自定义AnimationSpec
Compose的动画系统,说实话,跟传统View动画完全是两套思路。View动画你操作的是View对象,改它的alpha、translationX这些属性。但Compose里,你操作的是状态。动画的本质,就是让一个状态值随时间平滑变化。
今天咱们就深入聊聊这个核心机制。我会从最底层的Animatable讲起,再到各种AnimationSpec,最后带你手写一个自定义的动画规格。嗯,这些都是我在实际项目中反复用到的套路。
一、Animatable:动画的原子单位
Animatable是什么?说白了,它是一个可被动画驱动的状态容器。你给它一个目标值,它会从当前值平滑过渡过去。
我记得第一次用Compose动画时,看到Animatable的API,第一反应是:这不就是属性动画的ValueAnimator吗?但用起来才发现,它跟Compose的协程结合得天衣无缝。
// 基本用法
val alpha = remember { Animatable(1f) }
// 触发动画
LaunchedEffect(Unit) {
alpha.animateTo(0.3f, animationSpec = tween(500))
}
// 在UI中使用
Box(
modifier = Modifier
.graphicsLayer { alpha = alpha.value }
.size(100.dp)
.background(Color.Blue)
)
这里有个关键点:Animatable必须用remember包裹,否则重组时会丢失状态。我踩过这个坑——动画跑着跑着突然跳回初始值,排查了半天才发现是忘记加remember了。
核心特性:
- 支持协程挂起:
animateTo是一个挂起函数,动画完成前会阻塞协程 - 自动处理反向动画:调用
animateTo时如果已有动画在运行,会自动衔接 - 支持快进/跳过:如果目标值与当前值相同,动画会立即完成
二、AnimationSpec家族:动画的节奏控制器
AnimationSpec定义了动画的“节奏”——是匀速、加速、弹跳还是弹簧效果。Compose内置了几种常用的规格,咱们一个个看。
2.1 TweenAnimation:最常用的补间动画
tween就是传统的补间动画,有持续时间、延迟和插值器。我个人习惯用它做80%的UI动画,因为可控性最强。
val offsetX = remember { Animatable(0f) }
LaunchedEffect(Unit) {
offsetX.animateTo(
targetValue = 100f,
animationSpec = tween(
durationMillis = 600,
delayMillis = 200,
easing = FastOutSlowInEasing
)
)
}
插值器这块,我建议你记住三个:FastOutSlowInEasing(先快后慢,最自然)、LinearEasing(匀速,适合进度条)、FastOutLinearInEasing(先快后匀速,适合退出动画)。
2.2 SpringAnimation:物理弹簧效果
spring模拟的是物理弹簧的阻尼振荡。它没有持续时间的概念,而是由dampingRatio(阻尼比)和stiffness(刚度)决定动画行为。
val scale = remember { Animatable(1f) }
LaunchedEffect(Unit) {
scale.animateTo(
targetValue = 1.5f,
animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessLow
)
)
}
阻尼比决定了“弹不弹”:
| 阻尼比 | 效果 | 适用场景 |
|---|---|---|
| 0.0 | 无阻尼,永远振荡 | 理论上存在,实际别用 |
| 0.1 - 0.4 | 欠阻尼,有回弹 | 点赞动画、卡片弹起 |
| 0.5 - 0.8 | 临界阻尼,平滑停止 | 列表滚动、拖拽回弹 |
| 1.0 | 过阻尼,无回弹 | 严肃的UI元素 |
刚度则影响“速度”:刚度越大,弹簧越硬,动画越快。我一般用StiffnessLow做弹跳效果,StiffnessHigh做快速反馈。
我的经验:Spring动画最适合做“自然”的交互反馈。比如用户拖拽一个卡片,松手后让它弹回原位——用spring比tween舒服得多。但要注意,spring动画的时长不可控,如果动画链中需要精确计时,还是用tween稳妥。
三、自定义AnimationSpec:打造专属动画曲线
内置的规格不够用怎么办?自己写一个。Compose提供了DurationBasedAnimationSpec和FloatAnimationSpec两个抽象类,你可以继承它们实现自定义逻辑。
我曾经遇到一个需求:做一个“心跳”动画,要求先快后慢再快,类似正弦波的半周期。内置的插值器都不太对味,于是我自己写了一个。
class HeartbeatAnimationSpec(
private val duration: Int = 800
) : DurationBasedAnimationSpec<Float> {
override fun <V : AnimationVector> createVector(
typeConverter: TwoWayConverter<Float, V>,
initialValue: Float,
targetValue: Float
): V {
return typeConverter.convertToVector(initialValue)
}
override fun getDurationMillis(
initialValue: Float,
targetValue: Float,
initialVelocity: Float
): Int {
return duration
}
override fun <V : AnimationVector> getValueFromNanos(
playTimeNanos: Long,
initialValue: V,
targetValue: V,
initialVelocity: V
): CubicBezierEasing.AnimationVectorWithVelocity {
val fraction = playTimeNanos / 1_000_000f / duration
// 自定义插值:先快后慢再快
val customFraction = when {
fraction < 0.3f -> fraction * 2f // 前30%加速
fraction < 0.7f -> 0.6f + (fraction - 0.3f) * 0.5f // 中间40%减速
else -> 0.8f + (fraction - 0.7f) * 2f // 最后30%再加速
}
// 线性插值
val value = initialValue.value + (targetValue.value - initialValue.value) * customFraction
return CubicBezierEasing.AnimationVectorWithVelocity(
vector = initialValue.newVector(value),
velocity = initialVelocity
)
}
}
说实话,这个实现有点粗糙。更优雅的方式是使用Easing接口,自定义一个插值器,然后配合tween使用:
val HeartbeatEasing = Easing { fraction ->
when {
fraction < 0.3f -> fraction * 2f
fraction < 0.7f -> 0.6f + (fraction - 0.3f) * 0.5f
else -> 0.8f + (fraction - 0.7f) * 2f
}
}
// 使用
val spec = tween(800, easing = HeartbeatEasing)
注意:自定义AnimationSpec时,一定要处理好边界情况。比如playTimeNanos可能为0,fraction可能超过1.0。我曾经没处理这些,结果动画在快速重组时出现了闪跳。
四、知识体系总览
下面这张图梳理了Compose动画的核心脉络,从底层到上层,一目了然:
五、实战:组合使用Animatable与AnimationSpec
光说不练假把式。咱们写一个完整的例子:一个卡片,点击时先缩小再弹回,模拟“按压”效果。
@Composable
fun PressableCard() {
var isPressed by remember { mutableStateOf(false) }
val scale = remember { Animatable(1f) }
Card(
modifier = Modifier
.size(200.dp, 120.dp)
.graphicsLayer {
scaleX = scale.value
scaleY = scale.value
}
.clickable {
// 启动动画协程
CoroutineScope(Dispatchers.Main).launch {
// 先缩小
scale.animateTo(
targetValue = 0.9f,
animationSpec = tween(100, easing = FastOutLinearInEasing)
)
// 再弹回
scale.animateTo(
targetValue = 1f,
animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessLow
)
)
}
}
) {
Text(
text = "点我试试",
modifier = Modifier.align(Alignment.Center)
)
}
}
这里有个细节:我用CoroutineScope(Dispatchers.Main).launch启动动画,而不是LaunchedEffect。为什么?因为LaunchedEffect只在组合进入时执行一次,而点击事件需要每次点击都触发动画。嗯,这个坑我刚开始也踩过。
避坑指南:如果你在clickable里直接调用animateTo,记得用rememberCoroutineScope()获取协程作用域,而不是每次都创建新的CoroutineScope。否则动画跑着跑着,协程被取消了都不知道。
六、总结
Compose动画的核心就三件事:
- Animatable:状态容器,负责存储当前值并驱动变化
- AnimationSpec:变化规则,决定怎么变、变多快
- 协程:执行引擎,让动画可以挂起、取消、串联
我个人觉得,Compose动画比View动画更“程序员友好”。因为它把动画抽象成了状态变换,你不需要关心View的生命周期,只需要关心值怎么变。尤其是自定义AnimationSpec,给了你无限的可能——只要你能写出数学公式,就能做出任何动画效果。
好了,这一章就到这里。记住:多动手写,多调参数,动画的感觉是调出来的,不是看出来的。