动画的“非正常”终止机制
有时,我们经常会遇到动画还没播放完就被打断的情况。
那动画为什么会被打断呢?原因有三个。
同一 Animatable 被多次触发
为什么动画会被打断?
在 Compose 中,同一个 Animatable 对象如果在动画的执行过程中被再次启动新的动画,那么前一个动画就会被立即取消。
因为两个同时进行的动画,会同时去操作同一属性,会导致冲突,这不是我们想要的。所以 Compose 帮我们做了这种冲突的处理——互斥机制(MutatorMutex),就是取消前一个正在执行的动画,保留并执行最新的动画。
动画冲突示例
比如:
@Composable
fun CancelLastAnimateDemo() {
Box(Modifier.fillMaxSize()) {
val anim = remember { Animatable(0.dp, Dp.VectorConverter) }
val decay = rememberSplineBasedDecay<Dp>()
// 第一个动画:1秒后启动
LaunchedEffect(Unit) {
delay(1000)
anim.animateDecay(2000.dp, decay)
}
// 第二个动画:1.5秒后启动,会打断第一个动画
LaunchedEffect(Unit) {
delay(1500)
anim.animateDecay((-1500).dp, decay)
}
Box(
Modifier
.padding(top = anim.value)
.size(96.dp)
.background(Color.Green)
)
}
}
执行流程:
首先过了1秒后,第一个动画启动,方块向下滑动。又过0.5秒,此时第一个动画还未结束,第二个动画启动,前一个动画会被取消,绿色方块立即向上滑动。
运行结果:
绿色方块被第二个动画打断后立刻反向移动。
⚠️ 注意:
animateTo()、animateDecay()、snapTo()函数都是可以互相打断的。
异常处理
被取消了的动画,并不是自然完成的,而是被打断的,在这种情况下,会在其所在的协程中抛出一个 CancellationException 异常,表示协程被取消。
异常是由 animateDecay() 函数抛出的,我们使用 try...catch 捕获一下异常:
LaunchedEffect(Unit) {
delay(1000)
try {
anim.animateDecay(2000.dp, decay)
} catch (e:CancellationException){
println("动画被打断了,报错信息: ${e.message}")
}
}
输出结果:
动画被打断了,报错信息: Mutation interrupted
这种异常捕获,通常是不需要的,因为这是设计好的,我们就是要取消前一个动画。
主动终止正在执行的动画
有时,我们想主动去终止一个正在进行的 Animatable 动画,只需调用 Animatable.stop() 函数就可以了,这也会使动画被打断。
suspend fun stop() {
//..
}
stop() 函数的定义前有 suspend 关键字,所以 stop() 函数是一个挂起函数,需要在协程中调用。
无效的stop()
并且 animateTo()、animateDecay()、snapTo() 也都是挂起函数,所以不能在同一个协程中顺序调用来打断动画,会导致stop() 函数无效,动画不会被打断。
像这样:
LaunchedEffect(Unit) {
delay(1000)
anim.animateDecay(2000.dp, decay)
anim.stop() // 错误:stop()在同协程中,只有动画自然结束后才会执行,无意义
}
因为在同一个协程中,执行顺序是同步的,stop() 会等到 animateDecay() 执行完动画后,再去打断动画,但此时动画已经结束,所以是没有意义的。
正确终止动画
所以 stop() 应该放在另一个单独的协程中,比如:
LaunchedEffect(Unit) {
delay(1000)
anim.animateDecay(2000.dp, decay)
}
LaunchedEffect(Unit) {
delay(1300)
anim.stop()
Log.d("AnimateStop","我是主动打断动画的,我就是要这么做")
}
不过一般不使用 LaunchedEffect() 来启动 stop 协程,因为 LaunchedEffect 是与 Compose 的生命周期相结合的,它会在进入组合时启动协程,在离开组合时自动取消协程,并且是否重启协程由参数 key 控制。
所以我们一般会使用 rememberCoroutineScope() 获取的作用域来启动协程,比如在点击事件中主动打断动画:
val scope = rememberCoroutineScope()
Button(
onClick = {
scope.launch {
anim.stop()
Log.d("AnimateStop", "我是主动打断动画的,我就是要这么做")
}
}
) {
Text("点击取消动画")
}
运行结果:
你会发现动画才刚刚起步,就被我打断了。
另外主动停止的动画也会抛出 CancellationException 异常。
动画到达边界自动停止
越界终止与边界设置
最后一种场景,动画不是被打断的,而是因为到达边界而自然停止。
比如你猛滑一个列表,它会在到达列表最后一项时停止:
动画默认无边界(null),但是我们可以使用 Animatable.updateBounds() 函数去设置动画的上界和下界。
anim.updateBounds(lowerBound = , upperBound = )
怎么判断是否是上界?关键在于变量的值是增加还是减少,增加就是上界;反之,就是下界。
比如,我让方块向右滑动,并将屏幕的边缘设置为边界:
@Composable
fun AnimateBoundsDemo() {
BoxWithConstraints(Modifier.fillMaxSize()) {
val animX =
remember { Animatable(0.dp, Dp.VectorConverter) }
animX.updateBounds(lowerBound = 0.dp, upperBound = maxWidth - 96.dp)
val decay = rememberSplineBasedDecay<Dp>()
LaunchedEffect(Unit) {
delay(1000)
animX.animateDecay(3000.dp, decay)
}
Box(
Modifier
.padding(start = animX.value)
.size(96.dp)
.background(Color.Green)
)
}
}
其中用到了
BoxWithConstraints组件,它可以给我们提供了约束限制,如maxWidth、minWidth。
运行效果:
可以看到方块在“碰到”屏幕边缘的时候,急停住了。
这种因为到达边界而停止的动画被归类为了正常停止,正常播放完成的动画被归类为正常结束。我们前面两种动画属于意外停止的动画,会抛异常,而这种正常停止的动画是不会抛异常的。
实际上动画函数是有返回值的,返回值类型是 AnimationResult,表示动画执行结果。
class AnimationResult<T, V : AnimationVector>(
val endState: AnimationState<T, V>, // 动画停止时的状态,比如有速度
val endReason: AnimationEndReason // 停止原因,取值有两个:BoundReached(撞边)、Finished(完成)
)
endState 正是为了正常停止的场景服务的,让我们可以去获取动画的状态信息,从而实现各种效果。
多维动画的边界
前面的都是一维的动画,Compose 最多支持四维动画。一个多维动画,如果有任何一个维度到达边界,那么整个动都会停止。
比如,让方块从左上角向右下角滑动:
@Composable
fun TwoVectorAnimateDemo() {
BoxWithConstraints(Modifier.fillMaxSize()) {
val anim =
remember { Animatable(DpOffset(0.dp, 0.dp), DpOffset.VectorConverter) }
val decay = rememberSplineBasedDecay<DpOffset>()
anim.updateBounds(upperBound = DpOffset(maxWidth - 96.dp, maxHeight - 96.dp))
LaunchedEffect(Unit) {
delay(1000)
anim.animateDecay(DpOffset(3000.dp, 3000.dp), decay)
}
Box(
Modifier
.padding(start = anim.value.x, top = anim.value.y)
.size(96.dp)
.background(Color.Green)
)
}
}
运行效果:
方块在撞击到右边界时会停止,此时并没有达到下边界。
多维动画的拆分
在上述示例中,按照我们对现实世界的理解,方块在撞击到右边界时,应该向下滑动。
那该怎么办呢?
你可以拿到动画的返回值,判断如果动画的停止原因是 BoundReached,就再启动一个新的动画(其他维度)去移动方块,但是这有点麻烦。
更简单的方式是将多维拆分成多个一维,每一个维度都是一个动画,只有每个维度都到边界时,整个动画才会停止。
现在我们修改一下上面的示例:
@Composable
fun SeparateVectorAnimateDemo() {
BoxWithConstraints(Modifier.fillMaxSize()) {
val animX =
remember { Animatable(0.dp, Dp.VectorConverter) }
val animY =
remember { Animatable(0.dp, Dp.VectorConverter) }
val decay = rememberSplineBasedDecay<Dp>()
animX.updateBounds(lowerBound = 0.dp, upperBound = maxWidth - 96.dp)
animY.updateBounds(lowerBound = 0.dp, upperBound = maxHeight - 96.dp)
LaunchedEffect(Unit) {
delay(1000)
animX.animateDecay(2500.dp, decay)
}
LaunchedEffect(Unit) {
delay(1000)
animY.animateDecay(3000.dp, decay)
}
Box(
Modifier
.padding(start = animX.value, top = animY.value)
.size(96.dp)
.background(Color.Green)
)
}
}
运行效果:
反弹动画
反弹效果
现在来实现方块撞墙后反弹的效果,很简单,只要在动画到达边界后,拿到停止时的速度,将速度反向一下并启动新动画就可以了。
@Composable
fun ReboundAnimateDemo() {
BoxWithConstraints(Modifier.fillMaxSize()) {
val animX =
remember { Animatable(0.dp, Dp.VectorConverter) }
val animY =
remember { Animatable(0.dp, Dp.VectorConverter) }
val decay = rememberSplineBasedDecay<Dp>()
animX.updateBounds(lowerBound = 0.dp, upperBound = maxWidth - 96.dp)
animY.updateBounds(lowerBound = 0.dp, upperBound = maxHeight - 96.dp)
// X 轴反弹
LaunchedEffect(Unit) {
delay(1000)
var animationResult = animX.animateDecay(3000.dp, decay)
// 如果结束原因是触边
while (animationResult.endReason == AnimationEndReason.BoundReached){
// 速度反向再启动动画
animationResult = animX.animateDecay(-animationResult.endState.velocity,decay)
}
}
// Y 轴反弹
LaunchedEffect(Unit) {
delay(1000)
var animationResult = animY.animateDecay(4000.dp,decay)
// 如果结束原因是触边
while (animationResult.endReason == AnimationEndReason.BoundReached){
// 速度反向再启动动画
animationResult = animY.animateDecay(-animationResult.endState.velocity,decay)
}
}
Box(
Modifier
.padding(start = animX.value, top = animY.value)
.size(96.dp)
.background(Color.Green)
)
}
}
使用 while 判断,能够多次反弹,而不仅仅是只反弹一次。并且每次动画的返回值都使用 animationResult 对象保存,确保每次重启动画时,能够获取上一次因触边停止的动画的速度。
运行效果:
精度问题与优化
上面的代码还存在一个问题:就是反弹效果并不精确。
因为我们获取的速度精度不够高。
Compose 在动画的每一帧中,都去获取动画的位置,再通过时间来推算出实时的速度,而每一帧的间隔是毫秒级别的(120hz是8ms、60hz是16ms)。所以获取的速度不是实时速度,而是每一帧的平均速度。
经过多次反弹会有一定误差。虽然肉眼看不出,但是要实现完全精准的反弹效果,就要用数学公式直接计算偏移量。
@Composable
fun ReboundAnimateDemo() {
BoxWithConstraints(Modifier.fillMaxSize()) {
val animX =
remember { Animatable(0.dp, Dp.VectorConverter) }
val animY =
remember { Animatable(0.dp, Dp.VectorConverter) }
val decay = rememberSplineBasedDecay<Dp>()
val paddingX = remember(animX.value) { // 实际的水平偏移
var usedValue = animX.value
while (usedValue >= (maxWidth - 96.dp) * 2) {
usedValue -= (maxWidth - 96.dp) * 2
}
if (usedValue < maxWidth - 96.dp) {
usedValue
} else {
(maxWidth - 96.dp) * 2 - usedValue
}
}
val paddingY = remember(animY.value) { // 实际的垂直偏移
var usedValue = animY.value
while (usedValue >= (maxHeight - 96.dp) * 2) {
usedValue -= (maxHeight - 96.dp) * 2
}
if (usedValue < maxHeight - 96.dp) {
usedValue
} else {
(maxHeight - 96.dp) * 2 - usedValue
}
}
LaunchedEffect(Unit) {
delay(1000)
animX.animateDecay(5000.dp, decay)
}
LaunchedEffect(Unit) {
delay(1000)
animY.animateDecay(5000.dp, decay)
}
Box(
Modifier
.padding(start = paddingX, top = paddingY)
.size(96.dp)
.background(Color.Green)
)
}
}
运行效果:
总结
Jetpack Compose 动画的非正常终止主要有三种:动画冲突打断、主动终止、越界停止。
多维动画建议拆分为多个一维动画,复杂的需求可结合物理建模和数学公式处理。