前言

回顾上篇文章我们学习了 compose 中的 animateTo 、 snapTo 如何使用,和 animationSpec 的参数配置等等,这篇文章,我们学习 Animatable 中另外一个方法 animateDecay

animateDecay

衰减型动画, 它只有一个实现类 DecayAnimationSpecImpl

有了上一篇文章的经验,这里就直接上代码了,大体上都基本类似,直接上栗子

exponentialDecay

指数型衰减动画,它是一种自然衰减,以固定的比例逐渐减小时,称之为指数衰减

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
@Composable
fun DecayAnimationTest() {
    val anim = remember {
        Animatable(0.dp, Dp.VectorConverter)
    }

    /**
     * 样条衰减动画
     * 他是针对于设置像素的动画的,因为他内部使用了 density 密度值来根据不同的设备做了转换计算了, 像这里使用 dp 就是错了, 因为 dp
     * 它本身就是已经是转换成像素了,已经做了一层转换了, 如果再使用 SplineBasedDecay<Dp> 那么他会再做一次转换, 就多余, 同时这个动画如果针对角度来做的,也不能
     * 使用它, 因为角度无论在任意的设备上角度都是一样的,和密度值无关,如果你使用它了,会导致在不同的设备上,旋转的角度不一样了
     *
     * 所以使用 dp 不要使用它, 非 dp,仅仅是像素的可以使用它 (最简单的区别)
     */
    val splineBasedDecay = rememberSplineBasedDecay<Dp>()

    /**
     * 指数型衰减动画
     *
     * 非 Dp 的衰减动画, 和 SplineBasedDecay 刚好相反的,和设备密度值无关
     * 几个参数设置:
     * frictionMultiplier:摩擦系数,默认1f,数值越大,惯性滑动的摩擦力越大,滑动的距离越短
     * absVelocityThreshold:速度阈值的绝对值, 默认 0.1f,它是一个速度模型, 可以理解为在小于 0.1f 的时候, 动画就会停止下来,如果是 0.5, 那么速度在小于 0.5f 的时候就会停止下来,也就是停止的更早
     */
    val exponentialDecay = remember { exponentialDecay<Dp>(frictionMultiplier = 1f, absVelocityThreshold = 9f) }

    LaunchedEffect(Unit) {
        delay(1000)

        /**
         * animateDecay 衰减型动画,惯性滑动慢慢减速的场景
         * 其实有一种情况, animateTo  也可以做, 因为本质是开始是线性速度, 后面慢慢开始减速 LinearOutSlowInEasing, 但是本质不同是,一个需要设置终点值, 一个是根据速度来计算的
         */

        /**
         * initialVelocity 速度值, 一般会用 Velocitytrack 计算速度
         * animationSpec: 衰减型动画, 只有一个实现类
         */
        anim.animateDecay(1000.dp, exponentialDecay)
    }


    Box(
        modifier = Modifier
            .padding(top = anim.value)
            .size(80.dp)
            .background(Color.Green)
    ) {
        Text(text = anim.value.toString(), modifier = Modifier.align(Alignment.Center))
    }
}

这段代码在启动后,页面被加载后 1000 毫秒后,然后调用 animateDecay 方法,这里 animateDecay 的 1000 毫秒是一个 initialVelocity 初始速度值,这里的速度值,一般是用于对惯性滑动场景来做的,一般在列表会使用到,在上篇的文章也有介绍,可以看下上篇文章,然后第二个参数仍然是 animationSpec 的设置,这里我们设置的是 exponentialDecay

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
fun <T> exponentialDecay(
    /*@FloatRange(
        from = 0.0,
        fromInclusive = false
    )*/
    frictionMultiplier: Float = 1f,
    /*@FloatRange(
        from = 0.0,
        fromInclusive = false
    )*/
    absVelocityThreshold: Float = 0.1f
): DecayAnimationSpec<T> =
    FloatExponentialDecaySpec(frictionMultiplier, absVelocityThreshold).generateDecayAnimationSpec()

其中可以设置的参数有两个,他们的区别如下

  • frictionMultiplier 摩擦系数,默认1f,数值越大,惯性滑动的摩擦力越大,滑动的距离越短
  • absVelocityThreshold:速度阈值的绝对值, 默认 0.1f,它是一个速度模型, 可以理解为在小于 0.1f 的时候, 动画就会停止下来,如果是 0.5, 那么速度在小于 0.5f 的时候就会停止下来,也就是停止的更早

代码运行后是这样的

splineBasedDecay

样条衰减动画,是基于屏幕像素进行的动画速度衰减,当像素密度越大动画减速越快,动画的时长越短,动画惯性滑动的距离越短;可以理解屏幕像素密度越大摩擦力越大,所以惯性滑动的距离就越短,使用这个动画,在不同的设备上可能会呈现不同的效果

其余的代码不变,我们仅仅是把 anim.animateDecay(1000.dp, exponentialDecay) 换成 anim.animateDecay(1000.dp, splineBasedDecay) 再运行一下

可以发现,我们设置的初始速度是一样的,但是动画运动后,停止的目标值位置是不一样的,这是因为 splineBasedDecay 他内部还做了一层的换算,他会拿 density 做一层换算,而我们这里的使用的初始值已经是一个 dp 值,已经是经过换算的了,所以就反而多余了

看看 splineBasedDecay 源码就知道了

1
2
3
4
5
6
7
8
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()
    }
}

可以看到这里使用了到 density 并传入到 SplineBasedFloatDecayAnimationSpec 中了,其中就在这个地方对 density 和 value 值做了再一次的转换,这里的就不再贴源码了,有兴趣的可以自己去看一下

所以 splineBasedDecay 和 exponentialDecay 的区别就是如果本身设置的初始值已经是转换过的了,就使用指数型衰减动画,如果需要动画的运动速度曲线和设备密度相关联,那就使用样条衰减动画

监听动画的每一帧数

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
@Composable
fun FrameAnimTest() {
    val topAnim = remember {  Animatable(0.dp, Dp.VectorConverter)   }

    var top2 = remember { mutableStateOf(0.dp) }

    val exponentialDecay = remember { exponentialDecay<Dp>()}

    LaunchedEffect(Unit) {
        topAnim.animateDecay(1000.dp, animationSpec = exponentialDecay) {
            // 监听动画的每一帧数
            top2.value = this.value
        }
    }

    Row(modifier = Modifier.fillMaxSize()) {
        Box(
            modifier = Modifier
                .padding(top = topAnim.value)
                .size(100.dp)
                .background(Color.Green)
        )

        /** 监听动画的每一帧变化 **/
        Box(
            modifier = Modifier
                .padding(top = top2.value)
                .size(100.dp)
                .background(Color.Yellow)
        )

    }
}

这段代码的其实是使用 topAnim 开启了一个 animateDecay 的衰减型动画,然后在这个 block 的回调函数中,将当前的动画运行的每一帧赋值给 top2 变量,而top2 变量又是作用在黄色的 Box 上的 padding 上面的,所以在绿色方块的动画执行后,黄色的方块也会执行,代码运行后是这样的

动画的打断

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
fun AnimInterruptionTest() {
    val topAnim = remember {  Animatable(0.dp, Dp.VectorConverter) }

    val exponentialDecay = remember {  exponentialDecay<Dp>() }

    /** 场景一**/
    LaunchedEffect(Unit) {
        println("@@@@ <top>.AnimInterruptionTest start")
        topAnim.animateDecay(2000.dp, animationSpec = exponentialDecay)
        println("@@@@ <top>.AnimInterruptionTest animateDecay")
        /** 这样写的话,动画会执行完后,才会执行 stop方法,这样等于没用了,因为动画已经结束了,这是为什么? **/
        /**
         * 这是因为  LaunchedEffect 是一个协程函数, 这里的方法都是按照顺序执行的,然后 animateDecay 是一个挂起函数,所以只有等 animateDecay 函数执行完成后,才会执行stop方法了
         */
        topAnim.stop()
        println("@@@@ <top>.AnimInterruptionTest topAnim.stop()")
    }

    /** 场景二 **/
//    LaunchedEffect(Unit) {
//        try {
//            /** 当调用 stop() 方法后, 会抛出在这个协程中抛出异常,那么这个协程中后面的代码会无法执行  **/
//            topAnim.animateDecay(2000.dp, animationSpec = exponentialDecay)
//            println("@@@@ <top>.AnimInterruptionTest  后面的代码")
//        } catch (e: CancellationException) {
//            println("@@@@ <top>.AnimInterruptionTest ${e.message}")
//            e.printStackTrace()
//        }  
//      println("@@@@ <top>.AnimInterruptionTest  动画停止了") // 去掉 try catch,停止后,这行代码会无法执行
//    }

    LaunchedEffect(Unit) {
        delay(100)
        /** 100 毫秒后打断它 **/
//        topAnim.stop()
    }

    Box(
        modifier = Modifier
            .padding(top = topAnim.value)
            .size(100.dp)
            .background(Color.Green)
    )
}

动画的打断停止,就是调用 stop 方法,唯一需要注意的是(场景一),animateDecay 和 stop 都是 suspend 挂起函数,会阻塞当前协程,所以在调用的时候,需要避免一前一后的方式,这样会等上一个挂起函数执行完成后,才执行 stop 方法,这个就没有停止的效果了, 其二需要注意的是,调用 stop 的方式如果动画正在执行的话,会在当前协程抛出异常,所以需要注意,如果有后续的代码,可能会导致无法执行了。这段代码和上面运行有的动画基本是一致的,就不放图了

动画的边界, 上限和下限

动画的边界设置其实就是为了设置不超过某个值,比如在上面的动画代码中, 如果初始速度足够大的话,那么他就会超出屏幕了, 而设置边界的方法就是 updateBounds 方法, 他的内部有两个参数

1
fun updateBounds(lowerBound: T? = this.lowerBound, upperBound: T? = this.upperBound)
  • lowerBound 动画的下限
  • upperBound 动画的上限

这两个参数也比较简单,我们看下如下这段代码, 顺便使用一下这个参数

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
@Composable
fun boundsTest() {
    BoxWithConstraints {
        val anim = remember {   Animatable(0.dp, Dp.VectorConverter)        }

        val animOffset = remember {    Animatable(DpOffset.Zero, DpOffset.VectorConverter)        }

        /** 设置动画的边界, 不超过屏幕大小 **/
        
        /**
         * 动画的上限和下限意思是,动画往那个方向偏移的界限,只能在这个区域中
         * 如果是垂直平移,下限就是 0, 上限就是屏幕宽度, 水平的时候则相反
         * 但是,如果是斜着的时候,界限就是 x,y的值了
         * lowerBound: 动画的下限
         * upperBound: 动画的上限
         */
        anim.updateBounds(lowerBound = 0.dp, upperBound = this.maxHeight - 100.dp)

        /** 垂直平移动画 **/
        LaunchedEffect(Unit) {
            anim.animateDecay(3000.dp, exponentialDecay())
        }

        /** 斜着平移的 offset动画, 设置上界和下界, 防止超出屏幕  **/
        val upperBound = remember {   DpOffset(x = this.maxWidth - 100.dp, y = this.maxHeight - 100.dp)        }
        LaunchedEffect(Unit) {
            /** 设置动画的边界 **/
            animOffset.updateBounds(lowerBound = DpOffset.Zero, upperBound = upperBound)

            /** initialVelocity设置初始速度,注:并不是目标位置  **/
            animOffset.animateDecay(DpOffset(x = 1000.dp, y = 3600.dp), exponentialDecay())
        }

        /** 垂直平移 **/
        Box(
            modifier = Modifier
                .padding(top = anim.value)
                .size(100.dp)
                .background(Color.Blue)
        )

        /** 斜方向平移 **/
        Box(
            modifier = Modifier
                .padding(top = animOffset.value.y, start = animOffset.value.x)
                .size(100.dp)
                .background(Color.Red)
        )
    }
}

这里的代码是绿色方块设置了一个垂直移动的动画, 并且设置了下限 0 dp, 上限不超过屏幕大小, 所以最终只会在屏幕内进行移动, 然后触边后停止动画, 还有一个红色方块, 基本也是类似的, 唯一不一样的就是初始速度的设置是 offset 坐标, 它是以 2 个坐标点进行偏移的, 同时也设置的下限和上限是在屏幕内中, 最终运行的代码是这样的

动画的结果

动画的结果就是调用 animateDecay 的返回值 AnimationResult

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
@Composable
fun AnimEndResultTest() {
    BoxWithConstraints {
        val anim = remember {     Animatable(initialValue = 0.dp, Dp.VectorConverter)  }

        LaunchedEffect(Unit) {
            /** 设置边界  **/
            anim.updateBounds(lowerBound = 0.dp, upperBound = this@BoxWithConstraints.maxWidth - 100.dp)
            // animationResult 动画的结果
            var animationResult = anim.animateDecay(4600.dp, exponentialDecay())
            
            /**
             * animationResult 内部属性
             * endReason: 结束原因, finish,动画完成了, BoundReached, 动画到达边界了
             * endState:  结束状态
             **/
         
//            if (animationResult.endReason == AnimationEndReason.BoundReached) { // BoundReached 动画到达边界了
//                println("@@@@ <top>.AnimEndResultTest  到达边界了 ${animationResult.endState.isRunning}   撞边的速度${animationResult.endState.velocity}")
//
//                /** 拿到撞边的速度,可以做一个弹簧动画 **/
//                /** 这样只会回弹一次,可以让他反复回弹 **/
//                 anim.animateDecay(-animationResult.endState.velocity, exponentialDecay())
//            }


            /** 上面是一次回弹,使用 while 做一个反复回弹的效果 **/
            while (animationResult.endReason == AnimationEndReason.BoundReached) { // 撞边
                // velocity:拿到结束的那一刻的动画速度,设置一个反方向速度,让其不断的反弹
                animationResult = anim.animateDecay(-animationResult.endState.velocity, exponentialDecay())
            }

        }

        Box(
            modifier = Modifier
                .padding(start = anim.value)
                .size(100.dp)
                .background(Color.Blue)
        )

    }
}

上面的动画代码就是开启了一个横向的偏移的动画,不断的设置 padding 值,且设置了一个边界是在屏幕之内回弹,最终在动画撞边后,获取撞边停止时刻的速度,再开启一个反方向的动画,不断的反弹,直到速度衰减到停止下来

其中 anim.animateDecay 返回值 AnimationResult 就是动画的结果,也比较简单,看下他的源码,后面再看下动画运行后的效果

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
    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)
    }

AnimationResult

1
2
3
4
5
6
7
8
class AnimationResult<T, V : AnimationVector>(
    
    val endState: AnimationState<T, V>,
    
    val endReason: AnimationEndReason
) {
    override fun toString(): String = "AnimationResult(endReason=$endReason, endState=$endState)"
}

其中 AnimationResult 是一个类,同时内部维护了 endState 和 endReason 结束状态和结束原因,同时他们也是类,我们先看看 endState

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
class AnimationState<T, V : AnimationVector>(
    val typeConverter: TwoWayConverter<T, V>, // 用于转换动画的值和动画的速度向量。
    initialValue: T, // 表示动画开始时的值
    initialVelocityVector: V? = null, // 是动画开始时的速度向量
    lastFrameTimeNanos: Long = AnimationConstants.UnspecifiedTime, // 表示上一帧的时间
    finishedTimeNanos: Long = AnimationConstants.UnspecifiedTime, // 表示动画结束的时间
    isRunning: Boolean = false // 表示动画是否正在运行
) : State<T> {
    // 当前动画的值
    override var value: T by mutableStateOf(initialValue)
        internal set

    // 当前动画的速度向量
    var velocityVector: V =
        initialVelocityVector?.copy() ?: typeConverter.createZeroVectorFrom(initialValue)
        internal set

    // 上一帧的时间
    @get:Suppress("MethodNameUnits")
    var lastFrameTimeNanos: Long = lastFrameTimeNanos
        internal set
	// 动画结束的时间
    @get:Suppress("MethodNameUnits")
    var finishedTimeNanos: Long = finishedTimeNanos
        internal set

   // 动画是否正在运行
    var isRunning: Boolean = isRunning
        internal set

    // 当前动画的速度,是从速度向量转换得来的
    val velocity: T
        get() = typeConverter.convertFromVector(velocityVector)
}

基本上就是动画的一些运行状态,速度等等,有需要的时候临时翻一下源码也很好理解,参数不多,接着来看看 endReason

1
2
3
4
5
6
enum class AnimationEndReason {
    // 撞边,动画达到设置的边界
    BoundReached,
    // 动画已成功完成,没有任何中断(即是自然完成的,调用 stop 的情况,不会有 finishEd 结果返回)
    Finished
}

endReason 就更加简单了,如注释那样,最后看看上面代码运行的效果