Jetpack Compose Decay Animation

1,023 阅读11分钟

Jetpack Compose Decay Animation

Animatable.animateTo() 用于从当前值动画到目标值,Animatable.snapTo() 则是从当前值跳变到目标值,这两个函数相信大家都比较熟悉了,今天要介绍的是 animateDecay()

// Animatable.kt
class Animatable<T, V : AnimationVector>(...) {
  suspend fun animateDecay(
    initialVelocity: T,
    animationSpec: DecayAnimationSpec<T>,
    block: (Animatable<T, V>.() -> Unit)? = null
  )
}

Decay “衰减,衰变”.

顾名思义,animareDecay() 是用于衰减型动画的。和 animateTo() / snapTo() 不同,它俩是将 Animatable 从当前值变化到给定的目标值,而衰减型动画,是给定一个初始速度,然后值会做减速运动直至速度降到 0,比如有一个幸运转盘组件,我们可以把转动角度作为动画值,将手指甩动松手一刻的速度作为初始速度,开始一个衰减动画。

第一个参数 initialVelocity: T 就是初始速度,单位是 T 每秒(T 是动画类型),第二个必填参数 DecayAnimationSpec 用于配置衰减动画(规格)…… 等等 🤔 为什么是必填?记得用 animateTo() 的时候,AnimationSpec 参数只是选填呀,关于这个问题我们先按下不表,先看看 DecayAnimationSpec 的继承关系。

DecayAnimationSpec.png

注意这个 DecayAnimationSpecAnimationSpec 没有任何血缘关系(像 JavaScript 和 Java),它只有一个实现类 AnimationSpecImpl

private class DecayAnimationSpecImpl<T>(
  private val floatDecaySpec: FloatDecayAnimationSpec
)

fun <T> FloatDecayAnimationSpec.generateDecayAnimationSpec(): DecayAnimationSpec<T> {
  return DecayAnimationSpecImpl(this)
}

DecayAnimationSpecImpl 是一个私有类,外部只能通过 FloatDecayAnimationSpec 的拓展函数 generateDecayAnimationSpec() 来获得一个 DecayAnimationSpecImpl 实例。

那我们继续挖,看看 FloatDecayAnimationSpec

FloatDecayAnimationSpec.png

可以看到 FloatDecayAnimationSpec 有 3 个实现类,其中两个同名同姓,不过我们只要关注 compose.animation 包下面的两个就行了,分别是 SplineBasedFloatDecayAnimationSpecFloatExponentialDecaySpec

结构图.png

SplineBasedFloatDecayAnimationSpec

// SplineBasedFloatDecayAnimationSpec.kt
class SplineBasedFloatDecayAnimationSpec(density: Density) : FloatDecayAnimationSpec

spline “样条” .

SplineBasedFloatDecayAnimationSpec,基于样条的浮点数衰减动画规格。什么鬼?

地铁老人手机.png

Spline 样条 是数学中一种由多项式分段定义的函数。你可以简单地把它理解成一条光滑的函数曲线。

样条曲线.png

在很久以前,还没有计算机的时候,造船的工人以及其他的一些工程绘图员为了绘制出光滑的曲线,会把柔性的木材削薄,作为绘制光滑曲线的辅助工具,这种工具就叫做”Spline 样条”,这就是样条函数名字的由来。
样条.png

在 Android 中,Spline(样条曲线)被用于动画和插值。这条曲线就是动画过程中的时间-速度曲线,因为它很光滑,用来描述运动就能让动画看起来更加自然连贯。比如我们经常使用的 RecycleView,快速滑动然后松手,它会继续滑动一段距离再逐渐地停下,这个减速过程的动画曲线就是 Spline。

animateDecay() 的参数 animationSpec: DecayAnimationSpec<T> 开始讲起,兜兜转转,原来它只是让我们定制衰减动画的速度曲线啊。

别扯远了,先来实际动手试试:

Box(modifier = Modifier.fillMaxSize()) {
  val offsetY = remember { 
    Animatable(initialValue = 0, typeConverter = Int.VectorConverter) 
  }

  Box(
    modifier = Modifier
      .offset { IntOffset(x = 0, y = offsetY.value) }
      .background(MaterialTheme.colorScheme.primary)
      .size(150.dp)
  )

  val density = LocalDensity.current
  val splineBasedFloatDecayAnimationSpec = remember(density) {
	  SplineBasedFloatDecayAnimationSpec(density)
  }
  val decayAnimationSpec = remember(splineBasedFloatDecayAnimationSpec) {
    splineBasedFloatDecayAnimationSpec.generateDecayAnimationSpec<Int>()
  }

  LaunchedEffect(Unit) {
    delay(1000)
    offsetY.animateDecay(
      initialVelocity = 4000, // 初始速度:4000 px/s
      animationSpec = decayAnimationSpec // 使用 spline 减速曲线
    )
  }
}

这里我们模拟向下甩动 Box,给一个模拟的初始速度 4000 px/s,开启衰减型动画,效果如下:

demo1.gif

和像素密度 Density 有什么关系?

不知道你有没有想过:为什么构造 SplineBasedFloatDecayAnimationSpec 的实例需要传入像素密度?Spline 是一个描述减速运动的动画曲线,和像素密度 Density 有什么关系吗?

class SplineBasedFloatDecayAnimationSpec(density: Density)

通常来说,平板的像素密度会比手机小一些,因为同样多的像素分布在更大的面积上。

现在假设你面前有一台手机和一台平板,你的手指以同样的物理速度滑动两台设备中的 LazyColumn 并松手,假设松手时的物理速度都为 20 cm/s,列表 item 因为惯性滑动而移动的物理距离是一样的,注意我这里说的是物理距离,不是像素,你可以思考一下。就类似你以相同的速度投掷纸飞机(不考虑任何其他变量),纸飞机的落点总是一致的。

以同样的物理速度滑动两台设备中的 LazyColumn.png

而相同的物理距离,在不同设备上对应的像素并不一致(同样 1 cm,在高像素密度设备上对应的像素更多,低像素密度设备上对应的像素更少),换句话说,虽然我们以相同的物理速度甩动列表,但高像素密度设备上的列表移动的像素会更多一些。像素移动得更多,肯定需要一个更大的 px/s 初始速度。

两部手机的传感器返回的是相同的物理速度,那怎么由一个相同的物理速度,在高像素密度设备上得到一个更大的 px/s 速度,而在低像素密度设备得到一个更小的 px/s 速度呢?答案很明显,利用 Density。

传入 Density 是为了让 SplineBasedFloatDecayAnimationSpec 能够根据设备的屏幕密度调整动画的运动效果,确保减速运动在不同分辨率的设备上都能表现出一致的动画流畅度和视觉效果。

如果不依靠 Desity 来修正初始速度的话,可能出现的问题是:用户以相同的物理速度在手机和平板上滑动列表,会感觉手机上的列表摩擦力很大,很难滑动或滑出一小段距离就停下了;又或者是感觉平板上的列表过于“滑”了,轻轻的一甩就飞出去好远,总之就是体验不一致。

滑师傅.png

也正是因为 SplineBasedFloatDecayAnimationSpec 内部会根据 Density 来修正初始速度,它的动画值注定是像素,如果你看到有人用 SplineBasedFloatDecayAnimationSpec 来给“角度”开启衰减型动画,那他肯定错了,角度和 Density 又无关,初始速度经过修正后,反而成了错的。

// 👇👇👇动画值 animatable 一定是像素
animatable.animateDecay(
  initialVelocity = ...;
  animationSpec = splineBasedFloatDecayAnimationSpec.generateDecayAnimationSpec()
)

FloatExponentialDecaySpec

Exponential "指数的"、"越来越快的".

很明显了,FloatExponentialDecaySpec 就是用指数函数来描述减速动画过程的,动画曲线大概长这样子:

指数函数.jpg
// FloatDecayAnimationSpec.kt
class FloatExponentialDecaySpec(
  @FloatRange(from = 0.0, fromInclusive = false)
  frictionMultiplier: Float = 1f,
  @FloatRange(from = 0.0, fromInclusive = false)
  absVelocityThreshold: Float = 0.1f
) : FloatDecayAnimationSpec

第一个参数 frictionMultiplier 是摩檫力系数,值越大,减速过程就越快;第二个参数 absVelocityThreshold 是速度阈值,这是干什么用的呢?

不和 X 轴相交.png

我们的速度曲线不会和 X 轴相交,也就是说减速时,速度会无限趋近于 0,但永远到达不了 0,absVelocityThreshold 的作用就是,当速度小于某个阈值时,就直接停止运动。

示例代码就不写了,不难。

怎么选?

SplineBasedFloatDecayAnimationSpec 和 FloatExponentialDecaySpec 怎么选呢?什么时候应该用哪个?很简单,开启衰减型动画,和像素有关的 Animatable,用 SplineBasedFloatDecayAnimationSpec,因为它会根据像素密度来修正初始速度,保证在各个设备上体验一致;其他的 Animatable 则用 FloatExponentialDecaySpec。

便捷函数

无论是 SplineBasedFloatDecayAnimationSpec 和 FloatExponentialDecaySpec,使用构造函数创造出实例后,我们还得调用它们的拓展函数 generateDecayAnimationSpec(), 有点麻烦对不对,Compose 为此提供了几个便捷函数:

  • splineBasedDecay()
  • rememberSplineBasedDecay()
  • exponentialDecay()

splineBasedDecay() 很简单,就只是帮我们省了调用 generateDecayAnimationSpec() 而已:

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

rememberSplineBasedDecay() 会默认帮我们填上默认 density 参数,也就是设备的像素密度,因为这个参数我们一般是不会改的,除非你想通过 density 来修改摩檫力:

// SplineBasedFloatDecayAnimationSpec.android.kt
@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()
  }
}

exponentialDecay()splineBasedDecay() 类似,只是省了调用 generateDecayAnimationSpec()

// DecayAnimationSpec.kt
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()

为什么 exponentialDecay() 没有 remember 版本的便捷函数呢?之所以有一个 rememberSplineBasedDecay(),是因为它需要默认填 density 参数,获取 density 需要 Composable 环境,而 exponentialDecay() 的参数是不需要在 Composable 环境中获取的,自然也就没有 remember 版本的便捷函数了。

话虽如此,我们还是应该在 remember { ... } 里调用 splineBasedDecay()exponentialDecay(),这样才能将实例缓存下来。

提前计算落点

因为衰减型动画只是提供初始速度,然后进行减速运动直至速度为 0,那有没有办法能在动画开始前,就提前知道停下来的具体位置呢?

有!我们可以使用 DecayAnimationSpec.calculateTargetValue() 来计算“落点位置”:

// DecayAnimationSpec.kt
fun <T, V : AnimationVector> DecayAnimationSpec<T>.calculateTargetValue(
  typeConverter: TwoWayConverter<T, V>,
  initialValue: T,
  initialVelocity: T
): T

比如,我在对 offsetX 使用衰减动画,我想先在动画开始之前,先用初始速度计算一下“落点位置”,如果初始速度足够大,能够让组件减速后停在屏幕最右侧,那么我就开启动画,如果初始速度不够,组件最后停在屏幕中间,那我就不开启动画了,这种需求不过分吧,这时候我们就可以用 calculateTargetValue() 函数了。

下面我简单地演示一下在动画开始前计算最终停靠的位置:

Box(modifier = Modifier.fillMaxSize()) {
  val offsetY = remember { 
  Animatable(initialValue = 0, typeConverter = Int.VectorConverter) }

  Box(
  modifier = Modifier
    .offset { IntOffset(x = 0, y = offsetY.value) }
    .background(MaterialTheme.colorScheme.primary)
    .size(150.dp)
  )

  val decayAnimationSpec = rememberSplineBasedDecay<Int>()

  val context = LocalContext.current
  LaunchedEffect(Unit) {
     val initVelocity = 4000 // 初始速度:4000 px/s
     val target = decayAnimationSpec.calculateTargetValue( // 计算目标值
       typeConverter = Int.VectorConverter,
       initialVelocity = initVelocity,
       initialValue = 0 // 初始偏移:0 px
     )
     Toast.makeText(context, "1s 后开始动画,target: $target", Toast.LENGTH_SHORT).show()

     delay(1000)
     offsetY.animateDecay(
       initialVelocity = initVelocity, 
       animationSpec = decayAnimationSpec
     )
  }
}

calculateTargetValue.gif

和动画边界配合使用

衰减型动画,一般的使用场景都是惯性滑动、甩动,这类动画一般都是具有边界的,比如我给某个组件一个向右的比较大的初始速度,不设置边界的话它会飞出屏幕外很远,用户也看不见,这时可以给动画设置边界,如果动画触达边界了就按反方向反弹:

BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
  val size = 150.dp
  val maxOffsetXInPx = with(LocalDensity.current) { (maxWidth - size).roundToPx() }
  val offsetX = remember {
    Animatable(
      initialValue = 0, 
      typeConverter = Int.VectorConverter
    ).apply {
        // 设置边界
        updateBounds(lowerBound = 0, upperBound = maxOffsetXInPx)
      }
    }

  Box(
    modifier = Modifier
      .offset { IntOffset(x = offsetX.value, y = 0) }
      .background(MaterialTheme.colorScheme.primary)
      .size(size)
  )

  val decayAnimationSpec = rememberSplineBasedDecay<Int>()
  LaunchedEffect(Unit) {
    delay(1000)
    var result: AnimationResult<Int, AnimationVector1D> ? = null
    do {
      result = offsetX.animateDecay(
        initialVelocity = result?.endState?.velocity?.unaryMinus() ?: 7000,
        animationSpec = decayAnimationSpec
      )
    } while (result?.endReason == AnimationEndReason.BoundReached)
     // 如果是因为触达边界而结束,那就再次开启动画,取反最后的速度
  }
}

和动画边界配合使用.gif

本文中的初始速度都是写死模拟的,实际场景中,应该使用 VelocityTracker 计算实际的初始速度。

总结

总结一下,可以使用 animateDacay() 对 Animatable 做衰减型动画。如果 animatable 是像素,就使用 SplineBasedFloatDecayAnimationSpec,其他时候使用 FloatExponentialDecaySpec,一般通过便捷函数来创建 DecayAnimationSpec 的实例。另外,可以通过 DecayAnimationSpec.calculateTargetValue() 来计算衰减动画的“落点位置”。