原生 Android 里有一个 Transition 框架,提供开始布局和结束布局,Transition 可以在这两个场景切换时创建动画效果。
Jetpack Compose 里的 Transition 和 上面的 Transition 并不是同一个东西,虽然它们都和动画有关系。
Compose 里面的 Transition 用于在状态级别上管理所有子动画。
先来看一下这段使用 Animatable 同时对"横向位移"和"圆角大小"做动画的代码:
kotlin
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
}
}
)
}
像上面同时对两个值做动画还好,如果要同时对 10 个值做动画,那岂不是需要写十遍:
kotlin
launch {
animatableXXXX.animateTo(targetValue = ...)
}
太丑陋了,而且创建多个子协程,协程的创建和销毁也是一种资源消耗。有更优雅的方式吗?有!那就是 Transition
updateTransition()
kotlin
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 会负责将其管理的所有值从当前值过渡到目标值。
与上面的 Animatable 代码相比,Transition 的代码更加简洁、优雅、易读。除了写法上的优势,Transition 也是有那么一点微乎其微的性能优势的,因为 Transition 会将所有动画值统一管理,它会将多个动画的计算放在同一个协程中执行,减少协程的创建和销毁。
参数 transitionSpec
kotlin
@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
是 AnimationSpec
的子接口,AnimationSpec
接口有两个常见直接子类型:一个是 FiniteAnimationSpec
,表示"有限动画的规格";另一个是 InfiniteRepeatableSpec
,表示"无限(循环)动画的规格"。
将参数 transitionSpec
类型设计为 @Composabl Transition.Segment<S>.() -> FiniteAnimationSpec<T>
的意思是:请使用这个参数的开发者提供一段能创建出 FiniteAnimationSpec
实例的代码。
kotlin
transitionSpec: @Composable Transition.Segment<S>.() -> FiniteAnimationSpec<T>
无语... 什么逻辑啊,说白了你不就是想要一个 FiniteAnimationSpec
实例嘛?我直接给你提供一个 FiniteAnimationSpec
不就完了,为什么要我提供一段能创建出 FiniteAnimationSpec
实例的代码。
设计成这样不行吗:
kotlin
@Composable
inline fun <S> Transition<S>.animateDp(
transitionSpec: FiniteAnimationSpec<Dp> = spring(visibilityThreshold = Dp.VisibilityThreshold),
...
): State<Dp>
先来看看如果可以直接传递 FiniteAnimationSpec
,会导致什么问题,或者说有什么缺陷。
kotlin
val offsetX by horizontalAlignTransition.animateDp(
transitionSpec = keyframes {
durationMillis = 1000
maxOffsetX * 0.4f at 200
}
) { horizontalAlign ->
when (horizontalAlign) {
HorizontalAlign.Left -> 0.dp
HorizontalAlign.Right -> maxOffsetX
}
}
这里直接通过 keyframes { ... }
创建了一个 FiniteAnimationSpec
实例并直接传递给了 animateDp()
,这样做有什么问题呢?
当动画从左向右移动时,动画的进度是先快后慢的,因为先用 20% 的时间完成了 40% 的动画进度,然后用 80% 的时间完成了 60% 的动画进度:
当动画反过来从右向左移动时,预期的动画应该是先慢后快的,因为反过来先用 80% 的时间完成了 60% 的动画进度,然后用 20% 的时间完成了 40% 的动画进度:
可实际上,我们只配置了一个方向(左->右)的动画关键帧,当反向运动时,再使用同样的动画关键帧,是得不到预期的效果的:
这时候你会发现在绝大多数情况下,KeyframesSpec
不像其他的 TweenSpec
、SpringSpec
那样能够在反向运动时被复用,因为 KeyframesSpec
里的关键帧是有顺序的,当反向运动时,关键帧的顺序就不对了,所以不能复用。
我们应该根据不同的起始状态和目标状态来创建不同的 KeyframesSpec
实例,这样才能保证动画的效果是正确的:
kotlin
// 左 -> 右
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>
来获取起始状态和目标状态。
kotlin
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
kotlin
interface Segment<S> {
val initialState: S
val targetState: S
infix fun S.isTransitioningTo(targetState: S): Boolean {
return this == initialState && targetState == this@Segment.targetState
}
}
状态判断代码利用简便函数 isTransitioningTo
可以简化成:
kotlin
transitionSpec = {
when {
HorizontalAlign.Left isTransitioningTo HorizontalAlign.Right -> ...
HorizontalAlign.Right isTransitioningTo HorizontalAlign.Left -> ...
...
}