Jetpack Compose 动画笔记4——Transition

原生 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

FiniteAnimationSpecAnimationSpec 的子接口,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 不像其他的 TweenSpecSpringSpec 那样能够在反向运动时被复用,因为 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 -> ...
        ...
    }
相关推荐
Kapaseker1 小时前
你不看会后悔的2025年终总结
android·kotlin
alexhilton4 小时前
务实的模块化:连接模块(wiring modules)的妙用
android·kotlin·android jetpack
ji_shuke5 小时前
opencv-mobile 和 ncnn-android 环境配置
android·前端·javascript·人工智能·opencv
sunnyday04267 小时前
Spring Boot 项目中使用 Dynamic Datasource 实现多数据源管理
android·spring boot·后端
幽络源小助理8 小时前
下载安装AndroidStudio配置Gradle运行第一个kotlin程序
android·开发语言·kotlin
inBuilder低代码平台8 小时前
浅谈安卓Webview从初级到高级应用
android·java·webview
豌豆学姐8 小时前
Sora2 短剧视频创作中如何保持人物一致性?角色创建接口教程
android·java·aigc·php·音视频·uniapp
白熊小北极9 小时前
Android Jetpack Compose折叠屏感知与适配
android
HelloBan9 小时前
setHintTextColor不生效
android