Compose 实践与探索五 —— 动画进阶

上一篇文章我们了解了单值动画 animate*AsState() 以及基于协程的单值动画 Animatable 的用法,并对两种动画都会用到的 AnimationSpec 做了系统的介绍,本篇我们会介绍衰减动画以及动画的流程控制。

1、衰减动画 animateDecay()

animateDecay() 本是 Animatable 中的挂起函数,从类的归属上来讲,应该在上一篇介绍 Animatable 时放在一起讲。但是受限于篇幅,且衰减动画与上一篇的主体结构并不搭配,因此才拿到这里讲。

1.1 适用场景

说到衰减动画,其含义无非是动画的速度越来越慢,直到停止。这种动画使用 animateTo(),在指定 TweenSpec 时选择一个最后会减速的缓动也可以实现:

anim.animateTo(96.dp, tween(easing = LinearOutSlowInEasing))

那为什么 Animatable 还要提供一个 animateDecay() 呢?因为它们的设计目标和底层机制有本质区别,这种区别源于两种动画的物理模型和目标驱动方式的不同:

  1. animateTo():目标导向的动画
    • 核心逻辑:从当前值向固定目标值运动,通过插值或弹簧模型实现动画
    • 关键参数:targetValue(必须指定最终目标值)+ AnimationSpec(如 tween()、spring())
    • 适用场景:需要精确控制动画终点(如按钮展开到固定宽度、颜色渐变到指定色值)
    • 详细解释:即便使用 LinearOutSlowInEasing,动画最终也只会停在 targetValue 指定的目标值上,无法动态改变目标值
  2. animateDecay():速度衰减的物理动画
    • 核心逻辑:基于初始速度摩擦力动态计算动画轨迹,直到速度衰减为 0 时自动停止
    • 关键参数:initialVelocity(初始速度) + DecayAnimationSpec(如 splineBasedDecay)
    • 适用场景:手势交互后的惯性动画(如滑动列表松手后的惯性滚动、拖拽卡片释放后的减速滑动)
    • 详细解释:动画的终点由初始速度和衰减参数动态计算,速度随时间逐渐衰减为 0,动画自然停止

因此,animateDecay() 的使用场景就是惯性衰减,最常见的就是手指滑动列表后抬起,列表惯性滑动一段时间后自己停下来。

1.2 基本用法

animateDecay() 是一个挂起函数,需要在协程中使用:

    /**
     * 启动一个衰减动画(即从给定的[initialVelocity]初始速度开始,逐渐减速至0的动画,
     * 起始点为当前[Animatable.value]。若已有动画正在运行,该动画将被立即取消。
     * 衰减动画通常用于快速滑动(fling)手势后的惯性效果。
     *
     * [animationSpec] 定义用于此动画的衰减动画规格。可选的[animationSpec]包括:
     * [splineBasedDecay][androidx.compose.animation.splineBasedDecay](基于样条的衰减)
     * 和 [exponentialDecay](指数衰减)。[block]闭包将在每帧动画时被调用。
     *
     * 返回一个[AnimationResult]对象,其中包含动画结束的[原因][AnimationEndReason]及最终状态。
     * 若动画顺利完成未被中断,结束原因为[Finished]。
     * 若动画在任一维度达到[lowerBound](下限)或[upperBound](上限),结束原因为[BoundReached]。
     *
     * 若动画被以下操作中断:
     * 1) 调用新动画(如[animateTo]/[animateDecay])
     * 2) [Animatable.stop]
     * 3) [Animatable.snapTo]
     * 被取消的动画将抛出[CancellationException](因协程任务被取消),
     * 这将导致调用方协程中后续操作被取消。此行为通常符合预期。
     * 若需在动画取消时执行清理操作,建议在`try-catch`代码块中启动动画。
     *
     * __注意__:动画结束后,速度将被重置为0。若需在动画被中断或触达边界前保持动量延续,
     * 建议使用返回的[AnimationResult.endState]中的速度值启动新动画。
     */
	suspend fun animateDecay(
        initialVelocity: T,
        animationSpec: DecayAnimationSpec<T>,
        block: (Animatable<T, V>.() -> Unit)? = null
    ): AnimationResult<T, V> {
        val anim = DecayAnimation(
            animationSpec = animationSpec,
            initialValue = value,
            initialVelocityVector = typeConverter.convertToVector(initialVelocity),
            typeConverter = typeConverter
        )
        return runAnimation(anim, initialVelocity, block)
    }

animateDecay() 有三个参数:

  1. initialVelocity:初速度。通常需要借助 Compose 提供的 VelocityTracker 计算手指抬起时的速度作为初速度,速度的单位取决于创建 Animatable 对象时传入的目标值的单位。比如传入的是 xxx.dp,那么调用 animateDecay() 时给 initialVelocity 传的就应该是 xx.dp 表示初速度为每秒 xx dp
  2. animationSpec:类型是 DecayAnimationSpec,该接口的唯一实现类 DecayAnimationSpecImpl 是 private 的,因此无法通过构造函数直接创建对象,只能通过 exponentialDecay() 和 splineBasedDecay() 来获取 DecayAnimationSpec 的实例,它们需要被包含在 remember() 内使用
  3. block:animateTo() 中也有这个函数参数,会在动画执行的每一帧刷新时被调用,相当于是对动画的监听。

Compose 无法直接使用传统 View 体系下的 VelocityTracker,因此 Compose 又单独实现了一份。它实现了速度计算中最难的两件事:记录位置与时间、用位置差与时间间距做除法算速度,因此使用它还是能很方便地计算出速度值的。

splineBasedDecay()

splineBasedDecay() 中的 spline 译为“样条”,是一个数学概念。Android 自带的惯性滑动曲线的算法就用到了它,像 Android 原生的滑动组件,如 ListView、RecyclerView、ScrollView 的惯性滑动曲线都是它。这些组件的惯性滑动都是用辅助类 OverScroller 来进行滑动相关的计算,比如惯性滑动的实时位移的计算。但在 Compose 中不能使用 OverScroller,因为 Compose 是独立于平台的,需要完全隔离,因此至少在上层是不能直接使用 Android 原生的东西的。但 OverScroller 的算法被照搬到 splineBasedDecay() 中了,因此用该函数就可以实现与原生相同的惯性滑动了。

splineBasedDecay() 有一个参数 density 表示屏幕的像素密度:

fun <T> splineBasedDecay(density: Density): DecayAnimationSpec<T> =
    SplineBasedFloatDecayAnimationSpec(density).generateDecayAnimationSpec()

可以直接使用简便方法 rememberSplineBasedDecay():

@Composable
actual fun <T> rememberSplineBasedDecay(): DecayAnimationSpec<T> {
    // This function will internally update the calculation of fling decay when the density changes,
    // but the reference to the returned spec will not change across calls.
    val density = LocalDensity.current
    return remember(density.density) {
        SplineBasedFloatDecayAnimationSpec(density).generateDecayAnimationSpec()
    }
}

rememberSplineBasedDecay() 内部会通过 CompositionLocal 获取 LocalDensity 并传入,使用它不仅可以省去传 Density 的麻烦,也省去了调用 splineBasedDecay() 时必须包含在 remember() 中的麻烦。

有一个问题需要特别注意,就是 splineBasedDecay() 和 rememberSplineBasedDecay() 虽然都是泛型函数,看似可以传入任意的类型,但实际上,它是面向像素的函数,不能用于计算 DP。因为针对不同像素密度的设备会有不同的减速曲线,它会有优化与修正,在像素密度越大的屏幕上减速就越快(相同初速度的两个屏幕应该惯性滑动相同的像素个数,但由于像素密度越大,同样的物理尺寸内的像素点就越多,所以在屏幕上滑动的物理距离就越短)。不论你传的泛型是 Float 还是 Int,它都会按照像素进行计算,并根据当前屏幕的像素密度计算出一条最合适的减速曲线,计算时会进行修正工作,将面向像素转换为面向 DP。正是因为有这个修正,泛型就不能传 DP,因为你传了 DP,它也是认为你传的是像素,也会进行修正,相当于多修正了一次,从而得到错误的结果。因此,这里的泛型只能传像素单位,不能传 DP。

类似地,在设计角度衰减动画时也不能使用上述两个函数,因为角度是与像素密度无关的,在任何屏幕上,角度的衰减速度都应该是一致的。如果将它们用于角度衰减的动画,会出现屏幕密度高的设备动画较快停止的问题。

综上,splineBasedDecay() 和 rememberSplineBasedDecay() 的适用场景很单一,只能用于面向像素的移动或放缩的动画,否则就会出现在不同像素密度的设备上表现不一致的问题。正是因为只能面向像素的限制,因此实际开发中用的很少。

exponentialDecay()

使用另一个函数 exponentialDecay() 可以解决 spline 的短板。exponential 是指数的意思,exponentialDecay() 就是指数衰减函数,它不会像 spline 的两个函数那样针对像素做出修正,因此如果使用它做面向像素的动画,就会出问题。但是反而它可以用于像素以外的其他单位,如 Dp,没有偏差。角度也可以用这个,颜色衰减也可以用这个。

exponentialDecay() 的适用范围更广,且可以通过参数进行调节:

fun <T> exponentialDecay(
    frictionMultiplier: Float = 1f,
    absVelocityThreshold: Float = 0.1f
): DecayAnimationSpec<T> =
    FloatExponentialDecaySpec(frictionMultiplier, absVelocityThreshold).generateDecayAnimationSpec()

frictionMultiplier 是摩擦力系数,值越大衰减越快;absVelocityThreshold 是速度阈值的绝对值,要结合指数衰减的图像来理解这个参数:

请添加图片描述

横轴是时间,纵轴是速度,速度随着时间增长无限趋近为 0 但不会为 0,如果按照这个曲线速度一直不为 0 那么动画就不会停止,因此我们需要设置一个值,当速度衰减到这个值时就让动画停止,这个值就是速度阈值的绝对值 absVelocityThreshold。

与 splineBasedDecay() 有 remember 版本的简便函数 rememberSplineBasedDecay() 相比,exponentialDecay() 没有一个对应的 remember 版本的函数,这是因为 splineBasedDecay() 使用屏幕像素密度 Density,你需要保证在重组时,如果像素密度没变,不重复创建 DecayAnimationSpec 对象,但是在变化时,要创建该对象,所以使用使用 remember() 将 Density 作为 key 传入了:

@Composable
actual fun <T> rememberSplineBasedDecay(): DecayAnimationSpec<T> {
    // This function will internally update the calculation of fling decay when the density changes,
    // but the reference to the returned spec will not change across calls.
    val density = LocalDensity.current
    // 在像素密度发生变化时才重新创建 DecayAnimationSpec 对象
    return remember(density.density) {
        SplineBasedFloatDecayAnimationSpec(density).generateDecayAnimationSpec()
    }
}

而 exponentialDecay() 并不需要像素密度,因此也就没有对应的 remember 版本了。

block 实现动画监听

block 会在动画执行的每一帧刷新时被调用,相当于是对动画的监听。举个简单例子,让红色的方块跟随绿色方块一起滑动相同的距离:

@Composable
fun BlockSample() {
    val animatable = remember { Animatable(0.dp, Dp.VectorConverter) }
    var paddingTopRed by remember { mutableStateOf(animatable.value) }
    val decay = remember { exponentialDecay<Dp>() }

    LaunchedEffect(Unit) {
        // 延迟一下,运行程序后能看到完整动画
        delay(2000)
        // 在 block 中将绿色方块执行动画的值同步给红色方块的顶部 padding
        animatable.animateDecay(1000.dp, decay) { // this: Animatable
            paddingTopRed = value
        }
    }

    Row {
        Box(
            Modifier
                .padding(0.dp, animatable.value, 0.dp, 0.dp)
                .size(100.dp)
                .background(Color.Green)
        )

        Box(
            Modifier
                .padding(0.dp, paddingTopRed, 0.dp, 0.dp)
                .size(100.dp)
                .background(Color.Red)
        )
    }
}

效果如下:

请添加图片描述

2、动画的停止

一个动画没有自然完成就被停止,通常有三种情况:

  1. 主动启动新动画:新动画的执行会导致正在执行的动画被立即取消
  2. 显式调用停止函数:主动结束一个动画的执行
  3. 动画到达边界限制:动画值到达边界,触发边界条件后结束,属于正常结束,前两种属于打断

2.1 主动启动新动画

当调用 animateToanimateDecaysnapTo 时,正在运行的动画会被立即取消,比如:

@Composable
fun BreakSample() {
    val animatable = remember { Animatable(0.dp, Dp.VectorConverter) }
    val decay = remember { exponentialDecay<Dp>() }

    LaunchedEffect(Unit) {
        delay(1000)
        try {
            animatable.animateDecay(2000.dp, decay)
        } catch (e: CancellationException) {
            // 协程取消时会抛出 CancellationException,为了演示效果才捕获这个异常,
            // 正常开发中一定不要捕获它,否则会影响协程的结构化取消
            println("糟糕!被打断了")
        }
    }

    // 第二个协程比第一个协程多延时一些时间以打断第一个动画的执行
    LaunchedEffect(Unit) {
        delay(1500)
        animatable.animateDecay((-1000).dp, decay)
    }

    Box(
        modifier = Modifier
            .padding(0.dp, animatable.value, 0.dp, 0.dp)
            .size(100.dp)
            .background(Color.Green)
    )
}

效果如下:

请添加图片描述

Box 在向下滑动过程中会向上移动,并且打印"糟糕!被打断了"。说明第一个协程由于确实被取消而抛出了 CancellationException,协程取消就会导致协程内部执行的动画取消。

2.2 显式调用停止函数

业务开发难免会有主动停止动画执行的场景,调用 Animatable 的 stop() 即可停止动画,该函数是一个挂起函数,也应该在协程环境中调用。使用时需要注意它不应该紧接着一个执行动画的函数后面调用,例如:

LaunchedEffect(Unit) {
    // 挂起函数,执行完毕才轮到 stop() 执行
    anim.animateDecay((-1000).dp, decay)
    anim.stop()
}

因为执行动画的函数也是挂起函数,在该挂起函数运行完毕之前,stop() 得不到执行。也就是说,这样调用 stop() 无法中断动画,失去了调用 stop() 的意义。因此通常情况下 stop() 应该是在另一个协程中被调用。

此外,还需注意,stop() 一般是与业务逻辑相关的,而不是与 Compose 界面相关的。比如点击了界面的某个按钮,触发了相应的业务流程后结束动画,这时使用传统的启动协程的方式就可以了,比如 lifecycleScope.launch(),在这里面调用 stop(),而不是在 LaunchedEffect() 中。

调用 stop() 也会引发正在执行中的动画所在的协程抛出 CancellationException,示例代码:

@Composable
fun StopSample() {
    val animatable = remember { Animatable(0.dp, Dp.VectorConverter) }
    val decay = remember { exponentialDecay<Dp>() }

    LaunchedEffect(Unit) {
        delay(1000)
        try {
            animatable.animateDecay(2000.dp, decay)
        } catch (e: CancellationException) {
            println("糟糕!被打断了")
        }
    }

    LaunchedEffect(Unit) {
        delay(1200)
        // 停止动画
        animatable.stop()
    }

    Box(
        modifier = Modifier
            .padding(0.dp, animatable.value, 0.dp, 0.dp)
            .size(100.dp)
            .background(Color.Green)
    )
}

2.3 动画到达边界限制

动画正常运行完毕以及动画运行到边界条件后停止被 Compose 认为是正常的动画停止,这个信息可以通过 animateTo() 和 animateDecay() 的返回值 AnimationResult 中查看到:

class AnimationResult<T, V : AnimationVector>(
    val endState: AnimationState<T, V>,
    val endReason: AnimationEndReason
)

enum class AnimationEndReason {
    /**
     * 动画值到达上限或下限时会被强制结束,这种状态下结束通常会比初始目标值短一些,
     * 并且剩余速度通常不为零。可以通过 [AnimationResult] 获取结束值和剩余速度。
     */
    BoundReached,
    /**
     * 动画已经成功完成,没有任何中断。
     */
    Finished
}

举一个简单示例,让 Box 水平向右滑动到边界后停止的动画:

@Composable
fun BoundsSample() {
    // 用以获取屏幕的最大宽度进而设置动画的上限值
    BoxWithConstraints {
        val animatable = remember { Animatable(0.dp, Dp.VectorConverter) }
        val decay = remember { exponentialDecay<Dp>() }

        LaunchedEffect(Unit) {
            delay(1000)
            animatable.animateDecay(2000.dp, decay)
        }

        // 更新动画的上限值,由于 animatable 动画值是作为 Box 的左边界,
        // 因此上限就是屏幕宽度减去 Box 的宽度
        animatable.updateBounds(upperBound = maxWidth - 100.dp)

        Box(
            Modifier
                .padding(animatable.value, 0.dp, 0.dp, 0.dp)
                .size(100.dp)
                .background(Color.Green)
        )
    }
}

效果如下:

请添加图片描述

以上是一维动画,Compose 支持最多至四维的多维动画。在多维空间中,只要有一个维度到达边界,Compose 就会停止动画。但假如需求是多维动画的所有维度均到达边界后才停止动画,该如何实现呢?

一种比较直观的想法是在一个维度到达边界后,通过 AnimationResult 的 endReason 先判断是因为到达边界引发的动画停止,然后获取 endState 从中获取尚未到达边界的维度数据,然后用这个数据在该维度重新开启一段动画,直到其到达边界。以二维 Offset 为例的代码如下:

@Composable
fun BoundsSample1() {
    BoxWithConstraints {
        // 第一个动画是 Offset 动画,XY 轴同时滑动
        val animatable = remember { Animatable(DpOffset.Zero, DpOffset.VectorConverter) }
        val decay = remember { exponentialDecay<DpOffset>() }
        // 第二个动画是 Y 轴动画
        val animatableY = remember { Animatable(0.dp, Dp.VectorConverter) }
        val decayY = remember { exponentialDecay<Dp>() }
        // Offset 动画是否已经结束并开始 Y 轴动画了
        var startY by remember { mutableStateOf(false) }

        LaunchedEffect(Unit) {
            delay(1000)
            val result = animatable.animateDecay(DpOffset(2000.dp, 3000.dp), decay)
            if (result.endReason == AnimationEndReason.BoundReached) {
                // 开始 Y 轴动画,用 Offset 动画结束时 Y 轴速度作为 Y 轴衰减动画的初速度
                startY = true
                val initVelocityY = result.endState.velocity.y
                animatableY.animateDecay(initVelocityY, decayY)
            }
        }

        // 更新动画的边界,上边界应该是最大宽度减去 Box 的宽度,即是 Box 左边的最大位移值
        animatable.updateBounds(upperBound = DpOffset(maxWidth - 100.dp, maxHeight - 100.dp))
        // Y 轴动画边界最大值应该是屏幕高度减去 Box 高度再减去 Offset 动画在 Y 轴上的位移
        animatableY.updateBounds(upperBound = maxHeight - 100.dp - animatable.value.y)

        Box(
            Modifier
                .padding(
                    animatable.value.x,
                    if (!startY) animatable.value.y else (animatable.value.y + animatableY.value),
                    0.dp,
                    0.dp
                )
                .size(100.dp)
                .background(Color.Green)
        )
    }
}

经过上述一通操作实现了如下效果:

请添加图片描述

上述操作实际上是有些繁琐的,可以借鉴原生的 OverScroller 将多个维度的动画分开实现:

public class OverScroller {
    private final SplineOverScroller mScrollerX;
    @UnsupportedAppUsage
    private final SplineOverScroller mScrollerY;
    
	public void fling(int startX, int startY, int velocityX, int velocityY,
            int minX, int maxX, int minY, int maxY, int overX, int overY) {
        // Continue a scroll or fling in progress
        if (mFlywheel && !isFinished()) {
            float oldVelocityX = mScrollerX.mCurrVelocity;
            float oldVelocityY = mScrollerY.mCurrVelocity;
            if (Math.signum(velocityX) == Math.signum(oldVelocityX) &&
                    Math.signum(velocityY) == Math.signum(oldVelocityY)) {
                velocityX += oldVelocityX;
                velocityY += oldVelocityY;
            }
        }

        mMode = FLING_MODE;
        // 两个方向上分别惯性滑动
        mScrollerX.fling(startX, velocityX, minX, maxX, overX);
        mScrollerY.fling(startY, velocityY, minY, maxY, overY);
    }
}

因此上面的例子也可以采用这种方式,代码会方便很多:

@Composable
fun BoundsSample2() {
    BoxWithConstraints {
        // X 轴动画
        val animX = remember { Animatable(0.dp, Dp.VectorConverter) }
        val decayX = remember { exponentialDecay<Dp>() }
        // Y 轴动画
        val animY = remember { Animatable(0.dp, Dp.VectorConverter) }
        val decayY = remember { exponentialDecay<Dp>() }

        LaunchedEffect(Unit) {
            delay(1000)
            animX.animateDecay(2000.dp, decayX)
        }

        LaunchedEffect(Unit) {
            delay(1000)
            animY.animateDecay(3000.dp, decayY)
        }

        animX.updateBounds(upperBound = maxWidth - 100.dp)
        animY.updateBounds(upperBound = maxHeight - 100.dp)

        Box(
            Modifier
                .padding(animX.value, animY.value, 0.dp, 0.dp)
                .size(100.dp)
                .background(Color.Green)
        )
    }
}

下面再看一个复杂一点的需求,让 Box 碰到边缘后反弹:

请添加图片描述

示例代码如下:

@Composable
fun BoundsSample3() {
    BoxWithConstraints {
        // X 轴动画
        val animX = remember { Animatable(0.dp, Dp.VectorConverter) }
        val decayX = remember { exponentialDecay<Dp>() }
        // Y 轴动画
        val animY = remember { Animatable(0.dp, Dp.VectorConverter) }
        val decayY = remember { exponentialDecay<Dp>() }

        LaunchedEffect(Unit) {
            delay(1000)
            var result = animX.animateDecay(4000.dp, decayX)
            // 每次碰到边界都反速运行动画
            while (result.endReason == AnimationEndReason.BoundReached) {
                result = animX.animateDecay(-result.endState.velocity, decayX)
            }
        }

        LaunchedEffect(Unit) {
            delay(1000)
            animY.animateDecay(2000.dp, decayY)
        }

        // 需要更新一下下界,因为动画 value 要作为 padding 不能是负值
        animX.updateBounds(upperBound = maxWidth - 100.dp, lowerBound = 0.dp)
        animY.updateBounds(upperBound = maxHeight - 100.dp)

        Box(
            Modifier
                .padding(animX.value, animY.value, 0.dp, 0.dp)
                .size(100.dp)
                .background(Color.Green)
        )
    }
}

这样做能实现一个大致准确的动画,之所以说大致准确,是因为速度 AnimationResult.endState.velocity 的精度不够高。Compose 在动画的每一帧中去采集图形的位置,再通过计算时间段进行相除来计算出实时速度。这个计算出来的速度显然与 Box 解除到边界的一瞬间的速度是有毫厘之差的。

我们可以自己进行数学计算,得到一个没有误差的精确值,这里仅以 X 轴方向为例,主要的改进在于 paddingX 的计算上:

@Composable
fun BoundsSample4() {
    BoxWithConstraints {
        // X 轴动画
        val animX = remember { Animatable(0.dp, Dp.VectorConverter) }
        val decayX = remember { exponentialDecay<Dp>() }
        // Y 轴动画
        val animY = remember { Animatable(0.dp, Dp.VectorConverter) }
        val decayY = remember { exponentialDecay<Dp>() }

        LaunchedEffect(Unit) {
            delay(1000)
            var result = animX.animateDecay(4000.dp, decayX)
            while (result.endReason == AnimationEndReason.BoundReached) {
                result = animX.animateDecay(-result.endState.velocity, decayX)
            }
        }

        LaunchedEffect(Unit) {
            delay(1000)
            animY.animateDecay(2000.dp, decayY)
        }

        animY.updateBounds(upperBound = maxHeight - 100.dp)

        // 自己计算 X 轴的距离
        val paddingX = remember(animX.value) {
            var usedValue = animX.value
            // 将从左到右,再从右到左这一个来回视为一次循环,碰撞后的位移,就是总的动画值
            // 这个位移对一次循环求模的结果
            while (usedValue >= (maxWidth - 100.dp) * 2) {
                usedValue -= (maxWidth - 100.dp) * 2
            }
            if (usedValue < maxWidth - 100.dp) {
                // 从左到右移动过程中,paddingX 应该就是求模后的 usedValue
                usedValue
            } else {
                // 从右向左移动过程中,paddingX 应该是一个来回的距离减去已经移动过的距离
                (maxWidth - 100.dp) * 2 - usedValue
            }
        }

        Box(
            Modifier
            	// 使用 paddingX 作为 start 值
                .padding(paddingX, animY.value, 0.dp, 0.dp)
                .size(100.dp)
                .background(Color.Green)
        )
    }
}

实测发现精确值与前面通过 AnimationResult 提供的速度值计算出的结果确实存在一定偏差。

2.4 其他情况

除了上面说的三种主要情况,还有一些其他情况也会停止动画,比如协程作用域被取消,常见于:

  • 组件退出组合(DisposableEffect 清理)
  • LaunchedEffectkey 参数变化触发重组
  • 父协程被取消

此外,重组导致副作用失效也可能导致动画中断。比如,组件重组时未能正确管理状态,导致动画重启。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值