动画为什么会被打断?原理、场景与最佳实践

436 阅读8分钟

动画的“非正常”终止机制

有时,我们经常会遇到动画还没播放完就被打断的情况。

那动画为什么会被打断呢?原因有三个。

同一 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秒,此时第一个动画还未结束,第二个动画启动,前一个动画会被取消,绿色方块立即向上滑动。

运行结果:

image.gif

绿色方块被第二个动画打断后立刻反向移动。

⚠️ 注意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("点击取消动画")
}

运行结果:

image.png

你会发现动画才刚刚起步,就被我打断了。

另外主动停止的动画也会抛出 CancellationException 异常。

动画到达边界自动停止

越界终止与边界设置

最后一种场景,动画不是被打断的,而是因为到达边界而自然停止。

比如你猛滑一个列表,它会在到达列表最后一项时停止:

Screenrecording_20250506_174753.gif

动画默认无边界(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 组件,它可以给我们提供了约束限制,如 maxWidthminWidth

运行效果:

image.gif

可以看到方块在“碰到”屏幕边缘的时候,急停住了。

这种因为到达边界而停止的动画被归类为了正常停止,正常播放完成的动画被归类为正常结束。我们前面两种动画属于意外停止的动画,会抛异常,而这种正常停止的动画是不会抛异常的。

实际上动画函数是有返回值的,返回值类型是 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)
        )
    }
}

运行效果:

image.gif

方块在撞击到右边界时会停止,此时并没有达到下边界。

多维动画的拆分

在上述示例中,按照我们对现实世界的理解,方块在撞击到右边界时,应该向下滑动。

那该怎么办呢?

你可以拿到动画的返回值,判断如果动画的停止原因是 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)
        )
    }
}

运行效果:

image.gif

反弹动画

反弹效果

现在来实现方块撞墙后反弹的效果,很简单,只要在动画到达边界后,拿到停止时的速度,将速度反向一下并启动新动画就可以了。

@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 对象保存,确保每次重启动画时,能够获取上一次因触边停止的动画的速度。

运行效果:

image.gif
精度问题与优化

上面的代码还存在一个问题:就是反弹效果并不精确。

因为我们获取的速度精度不够高。

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)
        )
    }
}

运行效果:

image.gif

总结

Jetpack Compose 动画的非正常终止主要有三种:动画冲突打断、主动终止、越界停止

多维动画建议拆分为多个一维动画,复杂的需求可结合物理建模和数学公式处理。