19、Compose动画之自定义动画:Animator接口、AnimationVector、自定义AnimationSpec

说实话,Compose 的动画系统刚出来那会儿,我还有点不习惯。毕竟从 View 时代的 Animation 和 Animator 一路摸爬滚打过来,突然面对一套全新的声明式 API,心里多少有点嘀咕:「这玩意儿能搞定我那些奇奇怪怪的动画需求吗?」

后来我发现,Compose 不仅搞定了,还给了我们一套更底层的扩展机制。今天我们就来聊聊这套机制的核心——自定义动画

为什么需要自定义动画?

你想想看,Compose 内置的 animateFloatAsStateanimateColorAsState 确实好用,但总有那么些场景,它覆盖不到。比如:

  • 你想让一个 View 沿着贝塞尔曲线移动
  • 你想同时控制多个属性,并且它们之间有依赖关系
  • 你想用自定义的数据类型做动画,比如一个自定义的「进度+偏移量」组合

这时候,你就得撸起袖子自己上了。

核心思路:Compose 的动画底层其实就三样东西——Animator 接口、AnimationVector 系列、以及 AnimationSpec。搞懂这三样,你就能写出任何你想要的动画。

Animator 接口:动画的「发动机」

先看个最简单的例子。假设我想让一个浮点数从 0 变到 1,用 Animatable 就够了:

val animatable = remember { Animatable(0f) }
LaunchedEffect(Unit) {
    animatable.animateTo(1f, animationSpec = tween(1000))
}

Animatable 本身其实是对 Animator 接口的封装。这个接口长什么样?

interface Animator<T, V : AnimationVector> {
    val value: T
    val velocity: V
    val isRunning: Boolean
    suspend fun animateTo(
        target: T,
        animationSpec: AnimationSpec<T>,
        initialVelocity: V = ...
    ): AnimationResult<T, V>
    suspend fun snapTo(value: T)
    fun stop()
}

嗯,这里要注意:T 是你的数据类型,V 是对应的 AnimationVector。说白了,Animator 就是负责「从当前值跑到目标值」的引擎。

我在项目中遇到过一种情况:需要同时控制一个 View 的缩放和透明度,而且这两个参数的变化曲线不一样。用两个 Animatable 分别控制当然可以,但如果你想在动画中途「暂停并反转」,那就有点麻烦了。这时候我选择自己实现一个 Animator,把两个属性打包成一个自定义类型来处理。

AnimationVector:让任意类型都能动起来

为什么要有 AnimationVector?因为 Compose 的动画引擎需要知道「怎么插值」。对于 Float,它知道怎么线性插值;对于 Color,它知道怎么在 RGB 或 HSV 空间插值。但如果你自己定义了一个类型,比如:

data class ProgressState(
    val progress: Float,
    val offset: Float
)

引擎就懵了——它不知道该怎么在 ProgressState(0f, 0f)ProgressState(1f, 100f) 之间平滑过渡。

这时候就需要 AnimationVector 出场。它本质上是一个「向量」,把自定义类型映射成引擎能理解的数值序列。

Compose 内置了几个 AnimationVector

内置类型 对应 AnimationVector 说明
Float AnimationVector1D 一维向量,只有一个分量
Color AnimationVector4D 四维向量,RGBA 四个分量
IntOffset AnimationVector2D 二维向量,x 和 y
Size AnimationVector2D 二维向量,width 和 height

那自定义类型怎么办?你需要实现 TwoWayConverter,把自定义类型和 AnimationVector 互相转换。

val ProgressStateConverter: TwoWayConverter<ProgressState, AnimationVector2D> =
    TwoWayConverter(
        convertToVector = { state ->
            AnimationVector2D(state.progress, state.offset)
        },
        convertFromVector = { vector ->
            ProgressState(vector.v1, vector.v2)
        }
    )

有了这个转换器,你就可以用 animateValueAsState 了:

val animatedState by animateValueAsState(
    targetValue = ProgressState(1f, 100f),
    typeConverter = ProgressStateConverter,
    animationSpec = tween(1000)
)

小技巧:如果你的自定义类型有 N 个需要动画的浮点字段,就用 AnimationVectorND,N 最大支持到 4。超过 4 个字段?我建议你重新设计数据结构,或者拆成多个 Animatable

自定义 AnimationSpec:控制动画的「节奏」

内置的 tweenspringkeyframes 已经能满足大部分需求。但如果你想要一种「先快后慢再快」的诡异曲线,或者想模拟某种物理效果,那就得自己写 AnimationSpec 了。

自定义 AnimationSpec 需要实现两个核心方法:

class CustomEaseInOutSpec : DurationBasedAnimationSpec<Float>() {
    override val durationMillis: Long = 1000L

    override fun <V : AnimationVector> createVectorAnimator(
        initialValue: V,
        targetValue: V,
        initialVelocity: V
    ): VectorizedAnimationSpec {
        return object : VectorizedAnimationSpec {
            override val isInfinite: Boolean = false

            override fun getValueFromNanos(
                playTimeNanos: Long,
                initialValue: V,
                targetValue: V,
                initialVelocity: V
            ): V {
                val fraction = playTimeNanos.toFloat() / (durationMillis * 1_000_000)
                // 自定义插值逻辑
                val customFraction = fraction * fraction * (3 - 2 * fraction) // smoothstep
                return initialValue + (targetValue - initialValue) * customFraction
            }

            override fun getVelocityFromNanos(
                playTimeNanos: Long,
                initialValue: V,
                targetValue: V,
                initialVelocity: V
            ): V {
                // 计算速度,这里省略
                return initialVelocity
            }
        }
    }
}

说实话,这个接口有点啰嗦。但好处是——你拥有了完全的控制权。我曾经在做一个「翻页动画」时,需要模拟纸张翻过的物理手感,内置的 spring 阻尼感不对,我就自己写了一个带「惯性衰减」的 AnimationSpec,效果出奇的好。

避坑指南:我曾经在自定义 AnimationSpec 时,忘记处理 initialVelocity,结果动画在连续触发时出现了「抖动」。记住,initialVelocity 是引擎用来做「动画衔接」的关键,千万别忽略它。

知识体系总览

下面这张图,是我梳理的自定义动画核心脉络:

Compose 自定义动画核心架构 Animator 接口 动画执行引擎,驱动值变化 AnimationVector 将自定义类型映射为数值向量 AnimationSpec 定义动画的插值曲线和时长 TwoWayConverter 转换器 DurationBasedAnimationSpec animateValueAsState / Animatable createVectorAnimator 实现 三者配合,实现任意自定义动画

实战:一个完整的自定义动画例子

说了这么多,我们来写一个完整的例子。假设我想做一个「加载进度条」,但进度条的颜色会随着进度变化,而且进度值本身也有一个「弹性效果」。

// 1. 定义数据类型
data class LoadingState(
    val progress: Float,
    val hue: Float  // 色相,0~360
)

// 2. 定义转换器
val LoadingStateConverter = TwoWayConverter<LoadingState, AnimationVector2D>(
    convertToVector = { state ->
        AnimationVector2D(state.progress, state.hue)
    },
    convertFromVector = { vector ->
        LoadingState(vector.v1, vector.v2)
    }
)

// 3. 自定义 AnimationSpec:带弹性效果的进度
class BounceProgressSpec : DurationBasedAnimationSpec<LoadingState>() {
    override val durationMillis: Long = 800L

    override fun <V : AnimationVector> createVectorAnimator(
        initialValue: V,
        targetValue: V,
        initialVelocity: V
    ): VectorizedAnimationSpec {
        return object : VectorizedAnimationSpec {
            override val isInfinite: Boolean = false

            override fun getValueFromNanos(
                playTimeNanos: Long,
                initialValue: V,
                targetValue: V,
                initialVelocity: V
            ): V {
                val fraction = (playTimeNanos / 1_000_000).toFloat() / durationMillis
                val clamped = fraction.coerceIn(0f, 1f)
                // 弹性公式:overshoot 然后回弹
                val overshoot = 1f + 0.3f * kotlin.math.sin(clamped * Math.PI.toFloat() * 3)
                val eased = clamped * overshoot
                return initialValue + (targetValue - initialValue) * eased.coerceIn(0f, 1.2f)
            }

            override fun getVelocityFromNanos(...): V {
                // 简化处理
                return initialVelocity
            }
        }
    }
}

// 4. 在 Composable 中使用
@Composable
fun BouncingProgressBar() {
    val targetState = LoadingState(0.8f, 200f)
    val animatedState by animateValueAsState(
        targetValue = targetState,
        typeConverter = LoadingStateConverter,
        animationSpec = BounceProgressSpec()
    )

    val color = Color.HSV(
        animatedState.hue.coerceIn(0f, 360f),
        0.8f,
        0.9f
    )

    LinearProgressIndicator(
        progress = animatedState.progress.coerceIn(0f, 1f),
        color = color,
        modifier = Modifier.fillMaxWidth().height(8.dp)
    )
}

这个例子虽然简单,但把三个核心概念都用上了:Animator(通过 animateValueAsState 间接使用)、AnimationVector(通过 TwoWayConverter)、以及自定义 AnimationSpec

个人习惯:我一般会把自定义的 AnimationSpecTwoWayConverter 放在一个单独的文件中,命名为 CustomAnimations.kt。这样项目里其他人也能复用,而且不会把 Composable 文件搞得太臃肿。

总结

自定义动画其实没那么玄乎。你只需要记住:

  • Animator 接口是引擎,负责跑起来
  • AnimationVector是燃料,让引擎认识你的数据类型
  • AnimationSpec是方向盘,控制怎么跑

搞懂这三者的关系,你就能在 Compose 里做出任何你想要的动画效果。嗯,下次遇到「这个动画框架做不了」的需求时,先别急着换方案——试试自定义动画,说不定有惊喜。


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