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提供了DurationBasedAnimationSpecFloatAnimationSpec两个抽象类,你可以继承它们实现自定义逻辑。

我曾经遇到一个需求:做一个“心跳”动画,要求先快后慢再快,类似正弦波的半周期。内置的插值器都不太对味,于是我自己写了一个。

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动画的核心脉络,从底层到上层,一目了然:

Compose动画核心体系 Animatable(动画状态容器) AnimationSpec(动画规格) TweenAnimation (补间动画) SpringAnimation (弹簧动画) 自定义AnimationSpec (继承/组合) Tween参数 • durationMillis:持续时间 • delayMillis:延迟时间 • easing:插值器(如FastOutSlowIn) Spring参数 • dampingRatio:阻尼比 • stiffness:刚度 • 无固定时长,物理驱动

五、实战:组合使用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,给了你无限的可能——只要你能写出数学公式,就能做出任何动画效果。

好了,这一章就到这里。记住:多动手写,多调参数,动画的感觉是调出来的,不是看出来的。

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