Jetpack Compose 动画2——Animatable

825 阅读9分钟

Jetpack Compose 动画笔记1——animate*AsState

Animatable

使用 ObjectAnimator 时,ObjectAnimator.ofFloat(circleView, "radius", 100f),只填一个 value 值,执行动画时会先调用 circleView.getRadius() 获取当前 radius 值作为动画起点,再运动至终点 100f。这和 animateFloatAsState 是非常相似的,执行运动时,都是以当前状态值为起点,运动至目标值。

如果使用 ObjectAnimator 时填入多个 value 值:ObjectAnimator.ofFloat(circleView, "radius", 20f, 100f),那么第一个值会被作为起点(初始值),最后一个值会被作为终点。注意了,初始值不一定就是当前状态值:例如,当前一刻圆的半径为 50f,我要在下一帧开始动画,要求动画初始值是 20f,目标值是 100f。与 ObjectAnimator.ofFloat(circleView, "radius", 20f, 100f) 对应的 Compose 动画应该怎么写呢?

animateFloatAsState() 是无法指定动画起始值的,它的动画起点只能是当前状态值。也就是说 animateFloatAsState 适用场景仅限于状态之间的来回切换(一个状态的终点是另一个状态的起点)。想要对动画做更多的定制,指定动画起始值,就要使用更底层的动画 API —— Animatable。

class Animatable<T, V : AnimationVector>(
    initialValue: T,
    val typeConverter: TwoWayConverter<T, V>,
    private val visibilityThreshold: T? = null,
    val label: String = "Animatable"
)

可以看到 Animatable 的构造函数有两个必填参数:初始值 initialValue 与 类型转换器 typeConverter。我们先不讨论怎么用 Animatable 指定动画初始值,从简单的开始,用 Animatable 来实现 animateDpAsState 在两个状态间来回切换的效果:

animateDpAsState_sample.gif

var isSmall by remember { mutableStateOf(true) }
// val size by animateDpAsState(targetValue = if (small) 100.dp else 200.dp)
val animatableSize = remember {
    Animatable(
        initialValue = if (isSmall) 100.dp else 200.dp, 
        typeConverter = Dp.VectorConverter
    )
}

Box(
    Modifier
    .size(animatableSize.value)
    .background(MaterialTheme.colorScheme.primary)
    .clickable { isSmall = !isSmall }
)
  • 首先,用 animateXxxAsState 就是自动挡,它的内部已经包装了一层 remember,Animatable 是手动挡,需要自己包装一层 remember;
  • 其次,创建 Animatable 对象实例时,不能也不应该能用 by 属性委托,应该直接用 =。想在这里使用 by 的人,无非是想着后续使用变量时,能直接写 animatable 获取到动画值 而不用 animatable.value,不过我们后续是要用到 Animatable 的一些方法的,所以这里不要用 by
    // 假设这里可以用 by 属性委托
    val animatableSize: Dp by remember { Animatable(...) }
    // 注意 animatableSize 现在是一个 Dp 实例,而不是 Animatable 实例
    // 后续会需要用到 Animatable 实例,以调用 Animatable 的一些方法,
    // 但因为属性委托的原因,已经没机会获取到 Animatable 实例了
    

现在运行代码:

没有设置 targetValue.gif

没有效果,回过头看填入数值的地方,你会发现 animateDpAsState(targetValue = ...) 的形参是 targetValue,而构造函数 Animatable(initialValue = ...) 构造函数的参数名是 initialValue。点击导致 isSmall 状态变化,只是重新设置了 Animatable 的初始值,而没有设置动画的目标值。

animateTo()

我们需要手动调用 Animatable.animateTo(targetValue = ...) 来设置动画的目标值,第一次使用时你会发现这个方法居然还是个挂起函数

animateTo.jpg
.clickable {
    small = !small
    lifecycleScope.launch {
        animatableSize.animateTo(if (small) 100.dp else 200.dp)
    }
}

即使套上一层 lifecycleScope.launch{} 也是不行的,在 Compose 中不能直接使用 lifecycleScope.launch{},运行时会报错,即使不报错,也不应该这么写,在 .clickable{} 里用 animateTo() 设置动画本身就是不合理的。为什么呢?单纯的从设计理念的角度看,Compose 是声明式 UI 框架,状态驱动界面。事件产生处 .clickable{} 不应该直接和界面/动画打交道,而应该是修改状态,让状态驱动界面/动画

var isSmall by remember { mutableStateOf(true) }
val animatableSize = remember {
    Animatable(
        initialValue = if (isSmall) 100.dp else 200.dp, 
        typeConverter = Dp.VectorConverter
    )
}

// 状态驱动动画
remember(isSmall) {
    协程.launch {
        animatableSize.animateTo(if (isSmall) 100.dp else 200.dp)
    }
}

Box(
    Modifier
    .size(animatableSize.value)
    .background(MaterialTheme.colorScheme.primary)
    .clickable {
        // 修改状态
        isSmall = !isSmall
    }
)

在 Compose 里面,有一个函数专门用于启动协程:LaunchedEffect,它的作用是在 Compose 的生命周期中启动协程

fun LaunchedEffect(
    key1: Any?,
    block: suspend CoroutineScope.() -> Unit
)

参数是 key 用于标识这个协程,当 key 变化时,会取消之前的协程,启动新的协程。所以我们可以在 LaunchedEffect 里面调用挂起函数 animateTo(),完整代码如下:

var isSmall by remember { mutableStateOf(true) }
val size = remember(isSmall) { if (isSmall) 100.dp else 200.dp }
val animatableSize = remember {
    Animatable(
        initialValue = size,
        typeConverter = Dp.VectorConverter
    )
}

// 状态驱动动画
LaunchedEffect(isSmall) {
    animatableSize.animateTo(targetValue = size)
}

Box(
    Modifier
    .size(animatableSize.value)
    .background(MaterialTheme.colorScheme.primary)
    .clickable {
        // 修改状态
        isSmall = !isSmall
    }
)
大小状态切换.gif

snapTo()

animateTo() 会让动画从当前值逐渐运动至目标值,如果想让动画直接跳到目标值,可以使用 snapTo()。回到文章一开始提到的需求,要设置动画起始值。在调用 animateTo() 开始动画之前,先调用 snapTo() 跳到起点。如此就做到了让动画从指定的起始值运动至目标值。

...
LaunchedEffect(isSmall) {
    if (!isSmall) {
        // 要变大时,先让动画跳到 0.dp,再运动至目标值
        animatableSize.snapTo(targetValue = 0.dp)
    }
    animatableSize.animateTo(targetValue = size)
}
...
snapTo.gif

可以看到两个状态虽然是 100.dp 和 200.dp,不过在 100dp -> 200dp 的过程中,我们指定了动画的起始值是 0.dp,所以动画是会从 100.dp 跳到 0.dp,再运动至 200.dp。


动画的串行与并行

串行

对一个正方形,先后顺序执行以下动画:

  1. 边长从 100 dp -> 200 dp;
  2. 圆角从 0 dp -> 20 dp。

只需将各自 Animatable 的动画放在同一个协程中去执行。因为 aninmateTo() 是挂起函数,一个挂起函数运行时,会将当前协程挂起,执行完毕后,下一行代码才能得到执行,如此就做到了顺序执行多个动画。

val animatableSize = remember {
    Animatable(initialValue = 100.dp, typeConverter = Dp.VectorConverter)
}
val animatableRoundCornerSize = remember {
    Animatable(initialValue = 0.dp, typeConverter = Dp.VectorConverter)
}

LaunchedEffect(Unit) {
    animatableSize.animateTo(targetValue = 200.dp)
    animatableRoundCornerSize.animateTo(targetValue = 20.dp)
}

Box(modifier = Modifier
    .size(animatableSize.value)
    .clip(RoundedCornerShape(animatableRoundCornerSize.value))
    .background(MaterialTheme.colorScheme.primary)
   )
串行动画.gif

并行

对一个正方形,不分先后,同时并行执行以下动画:

  • 边长从 100 dp -> 200 dp;

  • 圆角从 0 dp -> 20 dp。

将不同 Animatable 的动画分别放到不同的协程中执行,保证挂起函数之间不会相互干扰。

val animatableSize = remember {
    Animatable(initialValue = 100.dp, typeConverter = Dp.VectorConverter)
}
val animatableRoundCornerSize = remember {
    Animatable(initialValue = 0.dp, typeConverter = Dp.VectorConverter)
}
LaunchedEffect(Unit) {
    delay(2000)
    launch {
        animatableSize.animateTo(targetValue = 200.dp)
    }
    launch {
        animatableRoundCornerSize.animateTo(targetValue = 20.dp)
    }
}
Box(
    modifier = Modifier
    .size(animatableSize.value)
    .clip(RoundedCornerShape(animatableRoundCornerSize.value))
    .background(MaterialTheme.colorScheme.primary)
)
并行动画.gif

监听动画

在使用 Animatable.animateTo() 的时候,通过参数 block 可以对动画过程进行监听,里面的代码会在动画过程中的每一帧得到执行。

suspend fun animateTo(
    targetValue: T,
    animationSpec: AnimationSpec<T> = defaultSpringSpec,
    initialVelocity: T = velocity,
    block: (Animatable<T, V>.() -> Unit)? = null
): AnimationResult<T, V>

还是用上面的例子

animatableSize.animateTo(targetValue = size) { // 拥有 Animatable 上下文
    Log.d("MainActivity", "animatableSize: ${this.value}")
}
大小状态切换.gif logcat.jpg

动画的取消(结束)

可以调用 Animatable.stop() 来主动结束动画,这会导致正在进行的动画抛出一个 CancellationException 异常,一般情况下不需要处理,除非你要在动画被取消时做一些清理善后工作。

LaunchedEffect(Unit) {
  try {
    animatable.animateTo(targetValue = /* ... */)
  } catch(e: CancellationException) {
    // 动画被取消/中断
  }
}

LaunchedEffect(Unit) {
  // 0.5 秒后取消动画
  delay(500L)
  animatable.stop()
}

注意 Animatable.stop() 也是一个挂起函数,需要在协程中调用。

除了使用 Animatable.stop() 来主动结束动画,动画也可能被动地结束,在调用 animateTo()snapTo() 时,如果有另一个动画正在进行,那么正在进行动画就会被迫地打断,然后从当前值执行新的动画。同样的,动画被打断时也会抛出 CancellationException

动画的边界

调用 Animatable.animateTo() 且动画期间没有被取消或打断时,函数最后会返回一个 AnimationResult

class Animatable<T, V : AnimationVector>(...) {
  suspend fun animateTo(
    targetValue: T,
    animationSpec: AnimationSpec<T> = defaultSpringSpec,
    initialVelocity: T = velocity,
    block: (Animatable<T, V>.() -> Unit)? = null
  ): AnimationResult<T, V> // 📌
}

通过 AnimationResult 可以获取到动画结束时的值以及速度。❓🤔❓ 仔细想想好像不对劲,既然动画是正常结束的,那么结束时动画的值,不就是调用 animateTo() 时传入的 targetValue 吗?结束时的速度不就是 0 吗?那这个 AnimationResult 意义何在?

其实是这样的,我们可以调用 Animatable.updateBounds() 给动画设置一个边界。

class Animatable<T, V : AnimationVector>(...) {
  fun updateBounds(
    lowerBound: T? = this.lowerBound, 
    upperBound: T? = this.upperBound
  )
}

假设我现在有一个 Animatable<DpOffset> 对象,我将它的动画值设置为组件的偏移,并且设置动画上界为父组件宽高 - 组件宽高 (100.dp)。

BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
  val offset = remember { Animatable(DpOffset.Zero, DpOffset.VectorConverter) }
  val boxSize = 100.dp

  LaunchedEffect(Unit) {
    offset.updateBounds(
      upperBound = DpOffset(x = maxWidth - boxSize, y = maxHeight - boxSize)
    )
  }

  LaunchedEffect(Unit) {
    delay(1000L)
    val targetOffsetX = 2 * maxWidth
    val targetOffsetY = maxHeight - boxSize
    offset.animateTo(
      targetValue = DpOffset(targetOffsetX, targetOffsetY),
      animationSpec = tween(durationMillis = 500)
    )
  }

  Box(
    modifier = Modifier
    .offset(x = offset.value.x, y = offset.value.y)
    .background(Color.DarkGray)
    .size(boxSize)
  )
}

延迟 1 秒,触发 offset 动画,X 方向运动到超出边界,Y 方向刚好运动到边界内。

未命名绘图.drawio.png

来看看实际运行效果:

动画边界.gif

动画可以是多维的,但只要有任一维度触达到边界,整个动画就会停止(不同于取消或打断,触达边界的停止是不会抛异常的)。这个行为有点不太符合现实世界的逻辑,比如上面的例子,只是 X 方向触达到了边界,我们是希望继续在 Y 方向运动的。

这时候 AnimationResult 终于派上用场了,因为我们可以拿到触达边界时的动画值和速度,然后再开启一个新的动画,继续朝着 Y 方向运动:

// Animatable.kt
class AnimationResult<T, V : AnimationVector>(
  val endState: AnimationState<T, V>, // 动画结束时的状态
  val endReason: AnimationEndReason   // 动画结束的原因(正常结束 / 触达边界)
)


// AnimationEndReason.kt
enum class AnimationEndReason {
  BoundReached, // 动画因触达边界而结束
  Finished      // 动画正常结束,期间没有被打断
}


// AnimationState.kt
class AnimationState<T, V : AnimationVector>(...) : State<T> {
  override var value: T ... // 当前动画值
  val velocity: T ...       // 速度
  var lastFrameTimeNanos: Long ... // 动画最后一帧的时间
  /* ...... */
}

那么我们可以修改上面的代码:

LaunchedEffect(Unit) {
  delay(1000L)
  val targetOffsetX = 1.5 * maxWidth
  val targetOffsetY = maxHeight - boxSize
+  val startTimeNano = System.nanoTime() // 记录动画开始时间
+  val result: AnimationResult = 
     offset.animateTo(
       targetValue = DpOffset(targetOffsetX, targetOffsetY),
       animationSpec = tween(durationMillis = 500)
     )

+  // 动画因触达边界而停止
+  if (result.endReason == AnimationEndReason.BoundReached) {
+    val passedTimeMilli = (result.endState.lastFrameTimeNanos - startTimeNano) / 1_000_000 // 上次动画已经用掉的时间
+    val endValue = result.endState.value       // 触达边界时的动画值
+    val endVelocity = result.endState.velocity // 触达边界时的动画速度
+    // Y 方向既没有达到目标值,也没有达到边界
+    if (endValue.y < targetOffsetY && endValue.y < offset.upperBound!!.y) {
+      // 开启一个新的动画,继续朝着 Y 方向运动到目标值
+      offset.animateTo(
+        targetValue = DpOffset(y = targetOffsetY, x = endValue.x),
+        initialVelocity = endVelocity.copy(x = 0.dp), // 设置 Y 方向的初始速度为触达边界时的速度
+        animationSpec = tween(
+          durationMillis = (500 - passedTimeMilli).toInt(),
+          easing = LinearOutSlowInEasing // 减速模型
+        )
+      )
+    }
+  }
}

动画边界后开启新的动画.gif

上面的例子只是为了展示 AnimationResult 的作用,对于这种可能触及边界的多维度动画,如果希望某一维度触及边界不会影响其他维度的运动,最佳的做法是:把多个维度进行拆分,每个维度用单独的 Animatable 来表示。