前言
状态转移型动画animateXxxAsState写起来很简单,但也有一些局限性,比如:无法为每次动画单独设置初始值。它只允许设置第一次动画的初始值,而后续动画都只是在设置目标值。
这本质上是animateXxxAsState无法对动画的过程做随心所欲的控制。
如果要对动画的过程做随心所欲的控制,就需要使用Animatable
对象。
Animatable()和animateXxxAsState的区别
我们先来看看Animatable()和animateXxxAsState的区别,只有了解它们之间的区别,我们才能在实际场景中选择最合适的方式来完成动画效果。
我们点进去animateDpAsState()函数的源码:
kotlin
val size by animateDpAsState(targetValue = if (big) 96.dp else 48.dp) // 点击animateDpAsState
kotlin
// AnimateAsState.kt
@Composable
fun animateDpAsState(
targetValue: Dp,
animationSpec: AnimationSpec<Dp> = dpDefaultSpring,
label: String = "DpAnimation",
finishedListener: ((Dp) -> Unit)? = null
): State<Dp> {
return animateValueAsState( // 点击animateValueAsState
targetValue,
Dp.VectorConverter,
animationSpec,
label = label,
finishedListener = finishedListener
)
}
@Composable
fun <T, V : AnimationVector> animateValueAsState(
targetValue: T,
typeConverter: TwoWayConverter<T, V>,
animationSpec: AnimationSpec<T> = remember { spring() },
visibilityThreshold: T? = null,
label: String = "ValueAnimation",
finishedListener: ((T) -> Unit)? = null
): State<T> {
val toolingOverride = remember { mutableStateOf<State<T>?>(null) }
val animatable = remember { Animatable(targetValue, typeConverter, visibilityThreshold, label) }
...
}
发现animateXxxAsState()
函数内部使用的还是Animatable
,那么我们就明白了:
==> animateXxxAsState
是对Animatable
的高级封装。
animateXxxAsState简化了API,使得我们写状态转换动画特别简单,但同时也有意地牺牲了部分功能:比如不能设置动画的初始值。
为什么要抛弃这部分功能呢?
这并非是不得已而为之,而是经过了精心考量的设计决策。因为animateXxxAsState的使用场景中不需要这些功能,所以抛弃就抛弃了呗,反正也用不到。animateXxxAsState专用于状态转换场景,它假设你使用它,只会关心"从当前状态动画到新状态"这一需求。在这种情况下,初始值就是组件的当前状态,所以无需额外指定。
Animatable
作为底层API,功能最为强大,适用于各种复杂场景。而animateXxxAsState
则是针对特定常见场景提供的简化方案。
如果你发现你的需求,使用animateXxxAsState
难以实现,就应该使用更灵活的Animatable
。
所以现在你明白了吧!实战中的选择:如果是状态转移型场景,就使用animateXxxAsState()
,否则使用Animatable
;能用animateXxxAsState()
完成需求,就使用animateXxxAsState()
,难以实现,就使用Animatable
。
Animatable
创建Animatable对象
接下来我们看看是如何创建Animatable对象的,有两种方式。
第一种方式是调用Animatable()函数:
kotlin
// Animatable.kt
fun Animatable(
initialValue: Float, // 初始值
visibilityThreshold: Float = Spring.DefaultDisplacementThreshold
) = Animatable(
initialValue,
Float.VectorConverter,
visibilityThreshold
)
kotlin
// SingleValueAnimation.kt
// 重载
fun Animatable(initialValue: Color /*初始值*/): Animatable<Color, AnimationVector4D> =
Animatable(initialValue, (Color.VectorConverter)(initialValue.colorSpace))
第二种方式是直接使用Animatable类的构造函数进行创建:
kotlin
class Animatable<T, V : AnimationVector>(
initialValue: T, // 初始值
val typeConverter: TwoWayConverter<T, V>, // 在实际值(T)和动画向量(V)之间转换的转换器
private val visibilityThreshold: T? = null,
val label: String = "Animatable"
)
可以看到Animatable()
函数体中,都使用了Animatable构造函数来创建对象,并且如果动画的初始值不是Float、Color类型,就必须使用Animatable的构造函数。
我们等下创建的动画的初始值都是Dp类型,所以我们来了解一下Animatable的构造函数。
使用Animatable构造函数创建对象,有两个必填参数,一个是initialValue初始值,另一个是typeConverter类型转换器。 类型转换器(TwoWayConverter
)它是动画系统中的一个概念,解决了一个基本问题:如何让各种不同类型的值(如颜色、尺寸、位置等)都能进行平滑动画?
kotlin
// VectorConverters.kt
interface TwoWayConverter<T, V : AnimationVector> {
val convertToVector: (T) -> V // 将特定类型 T(如 Color、Dp)转换为动画向量 V
val convertFromVector: (V) -> T // 将动画向量 V 转换回特定类型 T
}
因为动画的本质是数学运算,需要在向量空间进行插值计算。有了类型转换器,就可以将这些复杂类型转换为可计算的向量形式。使得动画系统可以:
- 将输入值分解为数值向量
- 在向量空间执行插值计算
- 将结果向量转换回原始类型
所以需要类型转换器这个参数。
Compose 提供了不同维度的动画向量:
- AnimationVector1D: 单值(如透明度)
- AnimationVector2D: 二维值(如位置)
- AnimationVector3D: 三维值
- AnimationVector4D: 四维值(如 RGBA 颜色)
并且对于常见的类型,Compose 已经实现了转换器,我们无需手动实现了,比如:Dp.VectorConverter 、Float.VectorConverter。
言归正传,回到创建Animatable
对象上面来,创建一个Animatable
对象非常简单,只需要这样:
kotlin
val anim = remember { // remember => 防止重组时的重复初始化
Animatable(
initialValue = 48.dp, // 设置初始值
typeConverter = Dp.VectorConverter // 设置类型转换器
)
}
animateTo()
创建好Animatable
对象,就可以开始对动画进行配置了,具体的操作就是调用它的animateTo()
函数。
这也是创建Animatable
对象时不使用 by
关键字进行属性委托的原因。
使用 by
委托的状态会自动解包,让你直接使用它的内部值,但是我们需要调用Animatable
的方法并且访问它的属性,而不仅仅是获取其值,因此不适合使用属性委托。
并且即使你使用了by
,也会报错:
perl
Type 'TypeVariable(T)' has no method 'getValue(Nothing?, KProperty<*>)' and thus it cannot serve as a delegate
说明Compose根本就没有实现Animatable
类的 getValue()
方法,它不是为作为属性委托而设计的。
调用animateTo()
函数时,发现报错了,报错信息:
perl
Suspend function 'animateTo' should be called only from a coroutine or another suspend function
这表明它是一个挂起(suspend)函数,需要在协程的作用域或其他 suspend 函数中调用,那我们现在来创建一个协程。
swift
协程可以理解为"轻量级的线程",我们将耗时的操作,比如网络请求、数据库访问或动画放进去执行,这样不会阻塞UI线程。
kotlin
LaunchedEffect(Unit) {
delay(2000) // 动画默认的时长是300ms,防止动画速度太快,没看清效果
anim.animateTo(targetValue = 96.dp)
}
LaunchedEffect是 Compose 专门设计的协程 API,当 key 参数变化时会自动重启协程,我们这里只想它启动一次,所以就将参数填入一个不变的值Unit,这也是最常用的填法,你也可以填true、false、常量。
我们来看看效果如何:

我并没有点击它,它是在界面启动后2s,自动执行的动画。
完整代码如下:
kotlin
val anim = remember { // remember => 防止重组时的重复初始化
Animatable(
initialValue = 48.dp,
typeConverter = Dp.VectorConverter
)
}
LaunchedEffect(Unit) {
delay(2000)
anim.animateTo(targetValue = 96.dp)
}
Box(Modifier
.size(anim.value)
.background(Color.Green))
现在实现点击绿色矩形,绿色矩形会变大或缩小,只需在上面的基础上简单改改,就可以了:
kotlin
var big by remember { mutableStateOf(false) }
val size by remember(big) { mutableStateOf(if (big) 96.dp else 48.dp) }
val anim = remember {
Animatable(
initialValue = size,
typeConverter = Dp.VectorConverter
)
}
LaunchedEffect(big) {
anim.animateTo(size)
}
Box(Modifier
.size(anim.value)
.background(Color.Green)
.clickable {
big = !big
})
运行效果:

snapTo()
snapTo()
函数可以将 Animatable
对象的值设置为指定值,并不产生动画效果。适用于需要立即改变值而不需要平滑过渡的场景
回到本文的开头目标,来实现设置动画的初始值,其实也很简单只需加上一行代码:
diff
LaunchedEffect(big) {
+ anim.snapTo(targetValue = if (big) 144.dp else 24.dp) // 瞬间完成值的突变
anim.animateTo(size) // 动画完成值的渐变
}

第一次点击时,绿色矩形的大小会先突变到144.dp,然后以动画的形式渐变到96.dp;再次点击,会先突变到24.dp, 会以动画的形式渐变到48.dp。
并且初始运行时,会有一次动画:绿色矩形的大小会突变到24.dp,然后渐变到48.dp。
这是由于首次组合时,会触发LaunchedEffect 执行 ,即使 big
没有变化,首次组合时也会执行其中的代码块。这是LaunchedEffect的设计特性------当包含它的可组合函数首次进入组合时,它会立即启动协程并执行其中的代码。
完整代码:
kotlin
@Composable
fun AnimatedBox(){
var big by remember { mutableStateOf(false) }
val size by remember(big) { mutableStateOf(if (big) 96.dp else 48.dp) }
val anim = remember {
Animatable(
initialValue = size,
typeConverter = Dp.VectorConverter
)
}
LaunchedEffect(big) {
anim.snapTo(targetValue = if (big) 144.dp else 24.dp)
anim.animateTo(size)
}
Box(Modifier
.size(anim.value)
.background(Color.Green)
.clickable {
big = !big
})
}