Jetpack Compose 动画笔记1——animate*AsState
Animatable
使用 ObjectAnimator 时,ObjectAnimator.ofFloat(circleView, "radius", 100f),只填一个 value 值,执行动画时会先调用 circleView.getRadius() 获取当前 radius 值作为动画起点,再运动至终点 100f。这和 animateFloatAsState 是非常相似的,执行运动时,都是以当前状态值为起点,运动至目标值。
如果使用 ObjectAnimator 时填入多个 value 值:ObjectAnimator.ofFloat(circleView, "radius", 20f, 100f),那么第一个值会被作为起点(初始值),最后一个值会被作为终点。注意了,初始值不一定就是当前状态值:例如,当前一刻圆的半径为 50f,我要在下一帧开始动画,要求动画初始值是 20f,目标值是 100f。与 ObjectAnimator.ofFloat(circleView, "radius", 20f, 100f) 对应的 Compose 动画应该怎么写呢?
用 animateFloatAsState() 是无法指定动画起始值的,它的动画起点只能是当前状态值。也就是说 animateFloatAsState 适用场景仅限于状态之间的来回切换(一个状态的终点是另一个状态的起点)。想要对动画做更多的定制,指定动画起始值,就要使用更底层的动画 API —— Animatable。
class Animatable<T, V : AnimationVector>(
initialValue: T,
val typeConverter: TwoWayConverter<T, V>,
private val visibilityThreshold: T? = null,
val label: String = "Animatable"
)
可以看到 Animatable 的构造函数有两个必填参数:初始值 initialValue 与 类型转换器 typeConverter。我们先不讨论怎么用 Animatable 指定动画初始值,从简单的开始,用 Animatable 来实现 animateDpAsState 在两个状态间来回切换的效果:

var isSmall by remember { mutableStateOf(true) }
// val size by animateDpAsState(targetValue = if (small) 100.dp else 200.dp)
val animatableSize = remember {
Animatable(
initialValue = if (isSmall) 100.dp else 200.dp,
typeConverter = Dp.VectorConverter
)
}
Box(
Modifier
.size(animatableSize.value)
.background(MaterialTheme.colorScheme.primary)
.clickable { isSmall = !isSmall }
)
- 首先,用
animateXxxAsState就是自动挡,它的内部已经包装了一层 remember,Animatable 是手动挡,需要自己包装一层 remember; - 其次,创建 Animatable 对象实例时,不能也不应该能用 by 属性委托,应该直接用 =。想在这里使用 by 的人,无非是想着后续使用变量时,能直接写
animatable获取到动画值 而不用animatable.value,不过我们后续是要用到 Animatable 的一些方法的,所以这里不要用by!// 假设这里可以用 by 属性委托 val animatableSize: Dp by remember { Animatable(...) } // 注意 animatableSize 现在是一个 Dp 实例,而不是 Animatable 实例 // 后续会需要用到 Animatable 实例,以调用 Animatable 的一些方法, // 但因为属性委托的原因,已经没机会获取到 Animatable 实例了
现在运行代码:
没有效果,回过头看填入数值的地方,你会发现 animateDpAsState(targetValue = ...) 的形参是 targetValue,而构造函数 Animatable(initialValue = ...) 构造函数的参数名是 initialValue。点击导致 isSmall 状态变化,只是重新设置了 Animatable 的初始值,而没有设置动画的目标值。
animateTo()
我们需要手动调用 Animatable.animateTo(targetValue = ...) 来设置动画的目标值,第一次使用时你会发现这个方法居然还是个挂起函数
.clickable {
small = !small
lifecycleScope.launch {
animatableSize.animateTo(if (small) 100.dp else 200.dp)
}
}
即使套上一层 lifecycleScope.launch{} 也是不行的,在 Compose 中不能直接使用 lifecycleScope.launch{},运行时会报错,即使不报错,也不应该这么写,在 .clickable{} 里用 animateTo() 设置动画本身就是不合理的。为什么呢?单纯的从设计理念的角度看,Compose 是声明式 UI 框架,状态驱动界面。事件产生处 .clickable{} 不应该直接和界面/动画打交道,而应该是修改状态,让状态驱动界面/动画:
var isSmall by remember { mutableStateOf(true) }
val animatableSize = remember {
Animatable(
initialValue = if (isSmall) 100.dp else 200.dp,
typeConverter = Dp.VectorConverter
)
}
// 状态驱动动画
remember(isSmall) {
协程.launch {
animatableSize.animateTo(if (isSmall) 100.dp else 200.dp)
}
}
Box(
Modifier
.size(animatableSize.value)
.background(MaterialTheme.colorScheme.primary)
.clickable {
// 修改状态
isSmall = !isSmall
}
)
在 Compose 里面,有一个函数专门用于启动协程:LaunchedEffect,它的作用是在 Compose 的生命周期中启动协程
fun LaunchedEffect(
key1: Any?,
block: suspend CoroutineScope.() -> Unit
)
参数是 key 用于标识这个协程,当 key 变化时,会取消之前的协程,启动新的协程。所以我们可以在 LaunchedEffect 里面调用挂起函数 animateTo(),完整代码如下:
var isSmall by remember { mutableStateOf(true) }
val size = remember(isSmall) { if (isSmall) 100.dp else 200.dp }
val animatableSize = remember {
Animatable(
initialValue = size,
typeConverter = Dp.VectorConverter
)
}
// 状态驱动动画
LaunchedEffect(isSmall) {
animatableSize.animateTo(targetValue = size)
}
Box(
Modifier
.size(animatableSize.value)
.background(MaterialTheme.colorScheme.primary)
.clickable {
// 修改状态
isSmall = !isSmall
}
)
snapTo()
animateTo() 会让动画从当前值逐渐运动至目标值,如果想让动画直接跳到目标值,可以使用 snapTo()。回到文章一开始提到的需求,要设置动画起始值。在调用 animateTo() 开始动画之前,先调用 snapTo() 跳到起点。如此就做到了让动画从指定的起始值运动至目标值。
...
LaunchedEffect(isSmall) {
if (!isSmall) {
// 要变大时,先让动画跳到 0.dp,再运动至目标值
animatableSize.snapTo(targetValue = 0.dp)
}
animatableSize.animateTo(targetValue = size)
}
...
可以看到两个状态虽然是 100.dp 和 200.dp,不过在 100dp -> 200dp 的过程中,我们指定了动画的起始值是 0.dp,所以动画是会从 100.dp 跳到 0.dp,再运动至 200.dp。
动画的串行与并行
串行
对一个正方形,先后顺序执行以下动画:
- 边长从 100 dp -> 200 dp;
- 圆角从 0 dp -> 20 dp。
只需将各自 Animatable 的动画放在同一个协程中去执行。因为 aninmateTo() 是挂起函数,一个挂起函数运行时,会将当前协程挂起,执行完毕后,下一行代码才能得到执行,如此就做到了顺序执行多个动画。
val animatableSize = remember {
Animatable(initialValue = 100.dp, typeConverter = Dp.VectorConverter)
}
val animatableRoundCornerSize = remember {
Animatable(initialValue = 0.dp, typeConverter = Dp.VectorConverter)
}
LaunchedEffect(Unit) {
animatableSize.animateTo(targetValue = 200.dp)
animatableRoundCornerSize.animateTo(targetValue = 20.dp)
}
Box(modifier = Modifier
.size(animatableSize.value)
.clip(RoundedCornerShape(animatableRoundCornerSize.value))
.background(MaterialTheme.colorScheme.primary)
)
并行
对一个正方形,不分先后,同时并行执行以下动画:
-
边长从 100 dp -> 200 dp;
-
圆角从 0 dp -> 20 dp。
将不同 Animatable 的动画分别放到不同的协程中执行,保证挂起函数之间不会相互干扰。
val animatableSize = remember {
Animatable(initialValue = 100.dp, typeConverter = Dp.VectorConverter)
}
val animatableRoundCornerSize = remember {
Animatable(initialValue = 0.dp, typeConverter = Dp.VectorConverter)
}
LaunchedEffect(Unit) {
delay(2000)
launch {
animatableSize.animateTo(targetValue = 200.dp)
}
launch {
animatableRoundCornerSize.animateTo(targetValue = 20.dp)
}
}
Box(
modifier = Modifier
.size(animatableSize.value)
.clip(RoundedCornerShape(animatableRoundCornerSize.value))
.background(MaterialTheme.colorScheme.primary)
)
监听动画
在使用 Animatable.animateTo() 的时候,通过参数 block 可以对动画过程进行监听,里面的代码会在动画过程中的每一帧得到执行。
suspend fun animateTo(
targetValue: T,
animationSpec: AnimationSpec<T> = defaultSpringSpec,
initialVelocity: T = velocity,
block: (Animatable<T, V>.() -> Unit)? = null
): AnimationResult<T, V>
还是用上面的例子
animatableSize.animateTo(targetValue = size) { // 拥有 Animatable 上下文
Log.d("MainActivity", "animatableSize: ${this.value}")
}
动画的取消(结束)
可以调用 Animatable.stop() 来主动结束动画,这会导致正在进行的动画抛出一个 CancellationException 异常,一般情况下不需要处理,除非你要在动画被取消时做一些清理善后工作。
LaunchedEffect(Unit) {
try {
animatable.animateTo(targetValue = /* ... */)
} catch(e: CancellationException) {
// 动画被取消/中断
}
}
LaunchedEffect(Unit) {
// 0.5 秒后取消动画
delay(500L)
animatable.stop()
}
注意
Animatable.stop()也是一个挂起函数,需要在协程中调用。
除了使用 Animatable.stop() 来主动结束动画,动画也可能被动地结束,在调用 animateTo() 或 snapTo() 时,如果有另一个动画正在进行,那么正在进行动画就会被迫地打断,然后从当前值执行新的动画。同样的,动画被打断时也会抛出 CancellationException。
动画的边界
调用 Animatable.animateTo() 且动画期间没有被取消或打断时,函数最后会返回一个 AnimationResult。
class Animatable<T, V : AnimationVector>(...) {
suspend fun animateTo(
targetValue: T,
animationSpec: AnimationSpec<T> = defaultSpringSpec,
initialVelocity: T = velocity,
block: (Animatable<T, V>.() -> Unit)? = null
): AnimationResult<T, V> // 📌
}
通过 AnimationResult 可以获取到动画结束时的值以及速度。❓🤔❓ 仔细想想好像不对劲,既然动画是正常结束的,那么结束时动画的值,不就是调用 animateTo() 时传入的 targetValue 吗?结束时的速度不就是 0 吗?那这个 AnimationResult 意义何在?
其实是这样的,我们可以调用 Animatable.updateBounds() 给动画设置一个边界。
class Animatable<T, V : AnimationVector>(...) {
fun updateBounds(
lowerBound: T? = this.lowerBound,
upperBound: T? = this.upperBound
)
}
假设我现在有一个 Animatable<DpOffset> 对象,我将它的动画值设置为组件的偏移,并且设置动画上界为父组件宽高 - 组件宽高 (100.dp)。
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
val offset = remember { Animatable(DpOffset.Zero, DpOffset.VectorConverter) }
val boxSize = 100.dp
LaunchedEffect(Unit) {
offset.updateBounds(
upperBound = DpOffset(x = maxWidth - boxSize, y = maxHeight - boxSize)
)
}
LaunchedEffect(Unit) {
delay(1000L)
val targetOffsetX = 2 * maxWidth
val targetOffsetY = maxHeight - boxSize
offset.animateTo(
targetValue = DpOffset(targetOffsetX, targetOffsetY),
animationSpec = tween(durationMillis = 500)
)
}
Box(
modifier = Modifier
.offset(x = offset.value.x, y = offset.value.y)
.background(Color.DarkGray)
.size(boxSize)
)
}
延迟 1 秒,触发 offset 动画,X 方向运动到超出边界,Y 方向刚好运动到边界内。
来看看实际运行效果:
动画可以是多维的,但只要有任一维度触达到边界,整个动画就会停止(不同于取消或打断,触达边界的停止是不会抛异常的)。这个行为有点不太符合现实世界的逻辑,比如上面的例子,只是 X 方向触达到了边界,我们是希望继续在 Y 方向运动的。
这时候 AnimationResult 终于派上用场了,因为我们可以拿到触达边界时的动画值和速度,然后再开启一个新的动画,继续朝着 Y 方向运动:
// Animatable.kt
class AnimationResult<T, V : AnimationVector>(
val endState: AnimationState<T, V>, // 动画结束时的状态
val endReason: AnimationEndReason // 动画结束的原因(正常结束 / 触达边界)
)
// AnimationEndReason.kt
enum class AnimationEndReason {
BoundReached, // 动画因触达边界而结束
Finished // 动画正常结束,期间没有被打断
}
// AnimationState.kt
class AnimationState<T, V : AnimationVector>(...) : State<T> {
override var value: T ... // 当前动画值
val velocity: T ... // 速度
var lastFrameTimeNanos: Long ... // 动画最后一帧的时间
/* ...... */
}
那么我们可以修改上面的代码:
LaunchedEffect(Unit) {
delay(1000L)
val targetOffsetX = 1.5 * maxWidth
val targetOffsetY = maxHeight - boxSize
+ val startTimeNano = System.nanoTime() // 记录动画开始时间
+ val result: AnimationResult =
offset.animateTo(
targetValue = DpOffset(targetOffsetX, targetOffsetY),
animationSpec = tween(durationMillis = 500)
)
+ // 动画因触达边界而停止
+ if (result.endReason == AnimationEndReason.BoundReached) {
+ val passedTimeMilli = (result.endState.lastFrameTimeNanos - startTimeNano) / 1_000_000 // 上次动画已经用掉的时间
+ val endValue = result.endState.value // 触达边界时的动画值
+ val endVelocity = result.endState.velocity // 触达边界时的动画速度
+ // Y 方向既没有达到目标值,也没有达到边界
+ if (endValue.y < targetOffsetY && endValue.y < offset.upperBound!!.y) {
+ // 开启一个新的动画,继续朝着 Y 方向运动到目标值
+ offset.animateTo(
+ targetValue = DpOffset(y = targetOffsetY, x = endValue.x),
+ initialVelocity = endVelocity.copy(x = 0.dp), // 设置 Y 方向的初始速度为触达边界时的速度
+ animationSpec = tween(
+ durationMillis = (500 - passedTimeMilli).toInt(),
+ easing = LinearOutSlowInEasing // 减速模型
+ )
+ )
+ }
+ }
}
上面的例子只是为了展示 AnimationResult 的作用,对于这种可能触及边界的多维度动画,如果希望某一维度触及边界不会影响其他维度的运动,最佳的做法是:把多个维度进行拆分,每个维度用单独的 Animatable 来表示。