Jetpack Compose 动画4——Transition

872 阅读7分钟

原生 Android 里有一个 Transition 框架,提供开始布局和结束布局,Transition 可以在这两个场景切换时创建动画效果。

原生Transition.jpg

Jetpack Compose 里的 Transition 和 上面的 Transition 并不是同一个东西,虽然它们都和动画有关系。

Compose 里面的 Transition 用于在状态级别上管理所有子动画。

先来看一下这段使用 Animatable 同时对"横向位移"和"圆角大小"做动画的代码:

BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
    val boxSize = 100.dp
    val maxOffsetX = maxWidth - boxSize
    var horizontalAlign by remember { mutableStateOf(HorizontalAlign.Left) }
    val offsetX = when (horizontalAlign) {
        HorizontalAlign.Left -> 0.dp
        HorizontalAlign.Right -> maxOffsetX
    }
    val roundCornerSize = when (horizontalAlign) {
        HorizontalAlign.Left -> 0.dp
        HorizontalAlign.Right -> 20.dp
    }
    val animatableOffsetX = remember {
        Animatable(initialValue = offsetX, typeConverter = Dp.VectorConverter)
    }
    val animatableRoundCornerSize = remember {
        Animatable(initialValue = roundCornerSize, typeConverter = Dp.VectorConverter)
    }

    LaunchedEffect(horizontalAlign) {
        launch { 
            animatableOffsetX.animateTo(targetValue = offsetX)
        }
        launch { 
            animatableRoundCornerSize.animateTo(targetValue = roundCornerSize) 
        }
    }

    Box(Modifier.size(boxSize)
            .offset(x = animatableOffsetX.value)
            .clip(RoundedCornerShape(animatableRoundCornerSize.value))
            .background(MaterialTheme.colorScheme.primary)
            .clickable {
                horizontalAlign = when (horizontalAlign) {
                    HorizontalAlign.Left -> HorizontalAlign.Right
                    HorizontalAlign.Right -> HorizontalAlign.Left
                }
            }
    )
}
Animatable并行动画.gif

像上面同时对两个值做动画还好,如果要同时对 10 个值做动画,那岂不是需要写十遍:

launch {
	animatableXXXX.animateTo(targetValue = ...)
}

太丑陋了,而且创建多个子协程,协程的创建和销毁也是一种资源消耗。有更优雅的方式吗?有!那就是 Transition

updateTransition()

BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
    val boxSize = 100.dp
    val maxOffsetX = maxWidth - boxSize
    var horizontalAlign by remember { mutableStateOf(HorizontalAlign.Left) }
    val horizontalAlignTransition = updateTransition(targetState = horizontalAlign)
    val offsetX by horizontalAlignTransition.animateDp { horizontalAlign ->
        when (horizontalAlign) {
            HorizontalAlign.Left -> 0.dp
            HorizontalAlign.Right -> maxOffsetX
        }
    }
    val roundCornerSize by horizontalAlignTransition.animateDp { horizontalAlign ->
        when (horizontalAlign) {
            HorizontalAlign.Left -> 0.dp
            HorizontalAlign.Right -> 20.dp
        }
    }

    Box(Modifier
            .size(boxSize)
            .offset(x = offsetX)
            .clip(RoundedCornerShape(roundCornerSize))
            .background(MaterialTheme.colorScheme.primary)
            .clickable {
                horizontalAlign = when (horizontalAlign) {
                    HorizontalAlign.Left -> HorizontalAlign.Right
                    HorizontalAlign.Right -> HorizontalAlign.Left
                }
            }
    )
}

首先,使用 updateTransition(targetState = ...) 创建一个 Transition 对象,传入一个状态,使 Transition 能够感知状态的变化。因为 updateTransition() 内部已经使用了 remember 来缓存 Transition 对象,所以不需要我们手动包装一层 remember

然后,调用 by Transition.animateXxx(targetValueByState = ...) 来创建需要执行动画的值,填入一个函数参数 targetValueByState,根据不同的状态提供不同的动画目标值。

接着...没有接着了,直接使用所创建出来的值就 🆗 了。所创建出来的所有动画值统一归 Transition 管理,Transition 可以感知状态的变化,当状态变化时,Transition 会负责将其管理的所有值从当前值过渡到目标值。

Transition并行动画.gif

与上面的 Animatable 代码相比,Transition 的代码更加简洁、优雅、易读。除了写法上的优势,Transition 也是有那么一点微乎其微的性能优势的,因为 Transition 会将所有动画值统一管理,它会将多个动画的计算放在同一个协程中执行,减少协程的创建和销毁。

参数 transitionSpec

@Composable
inline fun <S> Transition<S>.animateDp(
    noinline transitionSpec: @Composable Transition.Segment<S>.() -> FiniteAnimationSpec<Dp> = {
        spring(visibilityThreshold = Dp.VisibilityThreshold)
    },
    label: String = "DpAnimation",
    targetValueByState: @Composable (state: S) -> Dp
): State<Dp>

在调用 Transition.animateXxx() 的时候,可以通过函数参数 transitionSpec 来配置动画的细节。注意这是一个函数参数,要求返回类型为 FiniteAnimationSpec

FiniteAnimationSpec.jpg

FiniteAnimationSpecAnimationSpec 的子接口,AnimationSpec 接口有两个常见直接子类型:一个是 FiniteAnimationSpec,表示“有限动画的规格”;另一个是 InfiniteRepeatableSpec,表示“无限(循环)动画的规格”。

将参数 transitionSpec 类型设计为 @Composabl Transition.Segment<S>.() -> FiniteAnimationSpec<T> 的意思是:请使用这个参数的开发者提供一段能创建出 FiniteAnimationSpec 实例的代码。

transitionSpec: @Composable Transition.Segment<S>.() -> FiniteAnimationSpec<T>

无语... 什么逻辑啊,说白了你不就是想要一个 FiniteAnimationSpec 实例嘛?我直接给你提供一个 FiniteAnimationSpec 不就完了,为什么要我提供一段能创建出 FiniteAnimationSpec 实例的代码。

不能直接传递FiniteAnimationSpec.jpg

设计成这样不行吗:

@Composable
inline fun <S> Transition<S>.animateDp(
    transitionSpec: FiniteAnimationSpec<Dp> = spring(visibilityThreshold = Dp.VisibilityThreshold),
    ...
): State<Dp>

先来看看如果像上面这样设计的话,会导致什么问题,或者说有什么缺陷。

val offsetX by horizontalAlignTransition.animateDp(
    // 注意这里只是假设,实际上并不行
    transitionSpec = keyframes {
        durationMillis = 1000
        maxOffsetX * 0.4f at 200
    }
) { horizontalAlign ->
    when (horizontalAlign) {
        HorizontalAlign.Left -> 0.dp
        HorizontalAlign.Right -> maxOffsetX
    }
}

复用上面“横向位移”和“圆角大小”同时动画的例子,不过这次把参数 transitionSpec 使用上。调用 transition.animateXxx 时,通过 keyframes { } 创建了一个 FiniteAnimationSpec 实例,直接作为参数 transitionSpec 传递,这样做会有什么问题或者缺陷吗?

当动画从左向右移动时,动画的进度是先快后慢的,因为先用 20% 的时间完成了 40% 的动画进度,然后用 80% 的时间完成了 60% 的动画进度:

左到右.gif

当动画反过来从右向左移动时,预期的动画应该是先慢后快的,因为反过来先用 80% 的时间完成了 60% 的动画进度,然后用 20% 的时间完成了 40% 的动画进度:

右到左-预期.gif

可实际上,我们只配置了一个方向(左->右)的动画关键帧,当反向运动时,再使用同样的动画关键帧,是得不到预期的效果的:

右到左-实际.gif

这时候你会发现在绝大多数情况下,KeyframesSpec 不像其他的 TweenSpecSpringSpec 那样能够在反向运动时被复用,因为 KeyframesSpec 里的关键帧是有顺序的,当反向运动时,关键帧的顺序就不对了,所以不能复用。

我们应该根据不同的起始状态和目标状态来创建不同的 KeyframesSpec 实例,这样才能保证动画的效果是正确的:

// 左 -> 右
keyframes {
    durationMillis = 1000
    maxOffsetX * 0.4f at 200
}

// 右 -> 左
keyframes {
    durationMillis = 1000
    maxOffsetX * 0.4f at 800
}

说回来,参数 transitionSpec 之所以设计成函数参数,就是因为我们很可能需要根据不同的起始状态和目标状态来创建不同的 FiniteAnimationSpec 实例。别忘了函数参数 transitionSpec 是拥有 Transition.Segment<S> 上下文的,我们可以通过 Transition.Segment<S> 来获取起始状态和目标状态。

val offsetX by horizontalAlignTransition.animateDp(
    transitionSpec = {
        when {
            initialState == HorizontalAlign.Left && targetState == HorizontalAlign.Right -> 
            keyframes {
                durationMillis = 1000
                maxOffsetX * 0.4f at 200
            }

            initialState == HorizontalAlign.Right && targetState == HorizontalAlign.Left ->
            keyframes {
                durationMillis = 1000
                maxOffsetX * 0.4f at 800
            }

            else ->  tween(durationMillis = 1000)
        }
    }
) { horizontalAlign ->
   when (horizontalAlign) {
       HorizontalAlign.Left -> 0.dp
       HorizontalAlign.Right -> maxOffsetX
   }
  }

另外,在用 when 判断起始状态和目标状态时,可以使用简便函数 isTransitioningTo

interface Segment<S> {

    val initialState: S

    val targetState: S

    infix fun S.isTransitioningTo(targetState: S): Boolean {
        return this == initialState && targetState == this@Segment.targetState
    }
}

状态判断代码利用简便函数 isTransitioningTo 可以简化成:

transitionSpec = {
    when {
        HorizontalAlign.Left isTransitioningTo HorizontalAlign.Right -> ...
        HorizontalAlign.Right isTransitioningTo HorizontalAlign.Left -> ...
        ...
    }

rememberInfiniteTransition()

 transitionSpec: @Composable Transition.Segment<S>.() -> FiniteAnimationSpec<T>

关于“为什么函数 tansition.animateXxx() 的参数 transitionSpec 要设计成函数参数的形式?”这个问题上面已经讨论过了。不过,不知你是否曾有过另一个疑问,为什么函数参数 transitionSpec 要求返回一个 FiniteAnimationSpec 而不能返回 InfiniteRepeatableSpec?难道是因为 Transition 是用于管理状态之间的一个或多个动画,而 InfiniteRepeatableSpec 表示“无限(循环)动画的规格”,一个动画一直循环不结束,就没办法到达另一个状态,所以不能使用 InfiniteRepeatableSpec ?如果你是这么想的,那就错了。

其实 Transition 也是可以管理一个或多个无限循环动画的,只不过用法和管理有限动画有些不同。在创建 Transition 对象时,要使用 rememberInfiniteTransition() 而不是 updateTransition(),调用 rememberInfiniteTransition() 时也不需要像 updateTransition() 那样传入一个状态。

 val infiniteTransition = rememberInfiniteTransition()

rememberInfiniteTransition() 返回的是一个 InfiniteTransition 对象,这个 InfiniteTransitionTransition 可不是兄弟,他俩啥关系也没有,就好比 Java 和 JavaScript。

InfiniteTransition.jpg

infiniteTransition 实例有了,那怎么用呢?和 Transition 差不多但方法比较少,只有 3 个:

  • animateColor()
  • animateFloat()
  • animateValue()
 @Composable
 fun InfiniteTransition.animateColor(
     initialValue: Color,
     targetValue: Color,
     animationSpec: InfiniteRepeatableSpec<Color>,
     label: String = "ColorAnimation"
 ): State<Color> 

注意,和 Transition.animateColor() 不同,因为创建 InfiniteTransition 的时候就没有传入任何状态,所以这里自然不需要根据状态提供目标值。只需要提供循环动画的起始值与目标值就 OK 了,另外还需要 1 个 InfiniteRepeatableSpec 实例。

这里以一个简单的例子做演示:我们创建 1个无限循环的颜色动画 + 1个无限循环的大小动画。

 val infiniteTransition = rememberInfiniteTransition()
 ​
 var initialColor by remember { mutableStateOf(green) }
 var targetColor by remember { mutableStateOf(purple) }
 val color by infiniteTransition.animateColor(
     initialValue = initialColor,
     targetValue = targetColor,
     animationSpec = InfiniteRepeatableSpec(
         tween(durationMillis = 1500),
         repeatMode = RepeatMode.Reverse
     )
 )
 ​
 val size by infiniteTransition.animateValue(
     initialValue = 150.dp,
     targetValue = 200.dp,
     animationSpec = InfiniteRepeatableSpec(
         tween(durationMillis = 1500),
         repeatMode = RepeatMode.Reverse
     ),
     typeConverter = Dp.VectorConverter
 )
 ​
 Box(modifier = Modifier
     .size(size)
     .background(color)
     .clickable {
         initialColor = red
         targetColor = blue
     })
InfiniteTransition.gif

size 在循环变化,color 开始时在紫色和绿色之间循环,点击后更新了 initialValuetargetValue,变为在红色和蓝色之间循环。