19、Compose动画之自定义动画:Animator接口、AnimationVector、自定义AnimationSpec
说实话,Compose 的动画系统刚出来那会儿,我还有点不习惯。毕竟从 View 时代的 Animation 和 Animator 一路摸爬滚打过来,突然面对一套全新的声明式 API,心里多少有点嘀咕:「这玩意儿能搞定我那些奇奇怪怪的动画需求吗?」
后来我发现,Compose 不仅搞定了,还给了我们一套更底层的扩展机制。今天我们就来聊聊这套机制的核心——自定义动画。
为什么需要自定义动画?
你想想看,Compose 内置的 animateFloatAsState、animateColorAsState 确实好用,但总有那么些场景,它覆盖不到。比如:
- 你想让一个 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:控制动画的「节奏」
内置的 tween、spring、keyframes 已经能满足大部分需求。但如果你想要一种「先快后慢再快」的诡异曲线,或者想模拟某种物理效果,那就得自己写 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 是引擎用来做「动画衔接」的关键,千万别忽略它。
知识体系总览
下面这张图,是我梳理的自定义动画核心脉络:
实战:一个完整的自定义动画例子
说了这么多,我们来写一个完整的例子。假设我想做一个「加载进度条」,但进度条的颜色会随着进度变化,而且进度值本身也有一个「弹性效果」。
// 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。
个人习惯:我一般会把自定义的 AnimationSpec 和 TwoWayConverter 放在一个单独的文件中,命名为 CustomAnimations.kt。这样项目里其他人也能复用,而且不会把 Composable 文件搞得太臃肿。
总结
自定义动画其实没那么玄乎。你只需要记住:
- Animator 接口是引擎,负责跑起来
- AnimationVector是燃料,让引擎认识你的数据类型
- AnimationSpec是方向盘,控制怎么跑
搞懂这三者的关系,你就能在 Compose 里做出任何你想要的动画效果。嗯,下次遇到「这个动画框架做不了」的需求时,先别急着换方案——试试自定义动画,说不定有惊喜。
公众号:蓝海资料掘金营,微信deep3321