Android Compose 动画使用详解(十一)Transition动画的使用

2,361 阅读10分钟

前言

欢迎来到本专栏的第十一篇,前面我们已经介绍了许多关于 Android Compose 动画的知识。本篇文章将继续介绍 Compose 中 Transition 动画的使用,这是一种非常有用的动画类型,可以帮助您轻松地实现复杂的过渡效果。通过使用 Compose 的 Transition API,您可以在应用中创建各种各样的动画效果,从而增强用户体验并提高应用的吸引力。本文将深入探讨如何使用 Compose 的 Transition API 来创建各种动画效果,并提供实际的代码示例,希望能够帮助您更好地了解和使用这一功能。

Transition

Transition 动画是一种可以帮助应用程序实现平滑过渡效果的动画类型。在应用中,过渡效果通常指的是从一个场景(或状态)到另一个场景(或状态)的过渡。 在 Compose 中我们可以使用 updateTransition依赖一个状态来创建一个 Transition 实例,然后通过 animateXxx生成一个关联动画的变量,将生成的变量应用到 Compose UI 组件中时,当状态发生改变后即可执行对应的动画,简单实用如下:

// 创建状态 通过状态驱动动画
var moveToRight by remember { mutableStateOf(false) }
// Transition 实例
val transition = updateTransition(targetState = moveToRight)

// animateDp 创建动画
val paddingStart by transition.animateDp { state ->
    if (state) {
        200.dp
    } else {
        10.dp
    }
}

Box {
    Box(
        Modifier
            // 使用动画
            .padding(start = paddingStart, top = 30.dp)
            .size(100.dp, 100.dp)
            .background(Color.Blue)
            .clickable {
                // 修改状态
                moveToRight = !moveToRight
            }
    )
}

看一下运行效果:

111.gif

看代码和实现效果,好像跟本专栏最开始介绍的 animateXxxAsState差不多啊,甚至从代码上看好像还稍微复杂一些,还要多一个创建 Transition 实例的步骤。 那么 Transition 相对于animateXxxAsState动画来说到底有什么好处呢? Transition可以根据一个状态创建多个动画,比如上面的动画,我们可以在移动的基础上再加上一个圆角和颜色的变化,代码如下:

 // 创建状态 通过状态驱动动画
var moveToRight by remember { mutableStateOf(false) }
// 动画实例
val transition = updateTransition(targetState = moveToRight)

val paddingStart by transition.animateDp { state ->
    if (state) {
        200.dp
    } else {
        10.dp
    }
}
// 创建圆角动画
val corner by transition.animateDp { state ->
    if (state) {
        20.dp
    } else {
        0.dp
    }
}
// 创建颜色动画
val color by transition.animateColor { state ->
    if (state) {
        Color.Green
    } else {
        Color.Blue
    }
}

Box {
    Box(
        Modifier
            // 使用动画值
            .padding(start = paddingStart, top = 30.dp)
            // 圆角
            .clip(RoundedCornerShape(corner))
            .size(100.dp, 100.dp)
            // 背景颜色
            .background(color)
            .clickable {
                // 修改状态
                moveToRight = !moveToRight
            }
    )
}

运行效果:

112.gif

上面的代码我们用同一个 Transition 实例又创建了两个动画,实际上使用 animateXxxAsState 可以达到同样的效果,并且从代码复杂度和实现效果来说两者基本没有区别。 他们的区别主要在底层的实现上,Transition 的在底层是用的一个协程实现的,即无论用同一个 Transition 实例创建了多少个动画都是运行在一个协程中的,而使用 animateXxxAsState 要达到同样的效果则会创建多个协程,所以从效率上来说 Transition 会比 animateXxxAsState 更高。

updateTransition

上面介绍了通过 updateTransition 创建 Transition 实例以及通过 animateXxx创建对应动画的变化值,下面就分别来看一下他们有哪些可用的参数以及每个参数的作用。 首先来看一下 updateTransition的定义:

@Composable
fun <T> updateTransition(
    targetState: T,
    label: String? = null
): Transition<T>

只有两个参数,targetStatelabel,其中 targetState是动画的目标状态,即当该状态发生改变时会触发对应的动画;label是动画的标签,是用来标识具体动画的,关于它的作用后面将详细介绍。 关于updateTransition方法的使用上面已经介绍过了,除此之外他还有一个重载方法,如下:

@Composable
fun <T> updateTransition(
    transitionState: MutableTransitionState<T>,
    label: String? = null
): Transition<T>

区别在于第一个参数是一个 MutableTransitionState类型,其源码如下:

class MutableTransitionState<S>(initialState: S) {

    var currentState: S by mutableStateOf(initialState)
        internal set

    var targetState: S by mutableStateOf(initialState)

    val isIdle: Boolean
        get() = (currentState == targetState) && !isRunning

    // Updated from Transition
    internal var isRunning: Boolean by mutableStateOf(false)
}

可以传入一个初始状态,并且有两个成员属性 currentState:当前状态、targetState:目标状态,默认都为初始状态。 那么是不是就可以通过MutableTransitionState将初始状态和目标状态设置成不一样,这样 UI 加载的时候就会有初始动画了呢?来试试,代码如下:

// 创建状态 通过状态驱动动画
var moveToRight by remember { mutableStateOf(true) }
// 创建 MutableTransitionState,初始状态为 false
val mutableTransitionState = remember { MutableTransitionState(initialState = !moveToRight) }
// 目标状态设置为 moveToRight 即为 true
mutableTransitionState.targetState = moveToRight
// 创建 Transition
val transition = updateTransition(mutableTransitionState)

val paddingStart by transition.animateDp {
    if (it) 200.dp else 10.dp
}
Box {
    Box(
        Modifier
            // 使用动画值
            .padding(start = paddingStart, top = 30.dp)
            .size(100.dp, 100.dp)
            .background(Color.Blue)
    )
}

运行看一下效果:

115.gif

这样就实现了在界面加载的时候就有一个初始动画效果。

animateXxx

再来看一下animateXxx,这里的 Xxx 有哪些呢?跟前面 animateXxxAsState 动画的 Xxx 的值是一样的,作用于不同的单位,如下图: iShot_2023-04-08_18.06.42.png animateXxx 定义都基本一样,这里就以 animateDp为例看一下方法定义的源码:

@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>

方法有三个参数,其中 label参数的作用与上面 updateTransitionlabel作用一样,后面详细介绍。 先来最后一个参数 targetValueByState是一个函数类型参数,根据变量名就能看出来是根据状态返回目标值,传入 state 返回对应类型的值,比如这里是 animateDp 则返回 Dp 类型的值。而这里的 state 就是 updateTransition 传入的 state ,所以我们可以根据这个状态动态的返回目标值,从而实现状态改变时值发生改变而产生动画。 最后再来看第一个参数 transitionSpec即 Transition 的动画规格,与之前介绍的动画规格不一样,这里的参数是一个函数类型参数,扩展自 Transition.Segment 并返回 FiniteAnimationSpec类型,注意这里并不是返回的 AnimationSpec,还记得FiniteAnimationSpec有哪些子类么?回顾一下之前介绍动画规格的图:

image.png

实际上 FiniteAnimationSpec 的子类包含了之前介绍的动画规格中除了 InfiniteRepeatableSpec无限循环动画规格之外的其他几个。 再来看一下 Transition.Segment的源码:

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

包含两个变量和一个中缀函数,其中 initialState是初始状态,targetState是目标状态,isTransitioningTo函数则是表示从一个状态到另一个状态,如从初始状态到目标状态。 同时因为 transitionSpec 是一个函数参数,所以我们可以在函数中通过状态的变换决定动画的不同规格,示例如下:

val paddingStart by transition.animateDp(
    transitionSpec = {
        if(false == initialState && targetState == true){
            spring()
        }else{
            tween()
        }
    }
) { state ->
   ...
}

当初始状态为 false 目标状态为 true 时动画规格是 spring 否则是 tween,同时可通过 Segment 提供的中缀函数简化上面的表达式,如下:

if (false isTransitioningTo true) {
    spring()
} else {
    tween()
}

label

前面看到不管是 updateTransition 还是 animateXxx 都有一个 label 参数,那么它有什么用呢,实际上如果不传这个参数编辑器还会有警告,如下图所示: image.png 提示说应该设置 label 参数以便于在动画预览时更方便的查看过渡动画,那从哪里看动画预览呢? 当我们的 Compose 函数使用 @Preview注解时,我们就可以在 Android Studio 上预览 UI,点开编辑器上的 SplitDesign选项即可看到,如下图:

image.png

同时如果我们使用了 Transition 动画,则可以点击 Start Animation Preview 按钮进行动画预览,如图所示:

image.png

点进去以后就可以对动画进行预览,并且可以看到每一帧动画值的变化情况,效果如下:

113.gif

在预览界面可以对动画进行播放预览,也可以拖动关键帧查看每一帧动画的情况以及对应帧各动画的数值。 仔细观察在上面的预览里左边显示的是动画的状态变化,比如这里的从 false 变化为 true,上面显示的是 Boolean ,点击展开后在右边会显示三个具体的动画,分别是 DpAnimation、DpAnimation 和 ColorAnimation,我们无法区分到底是哪个具体的动画,只能根据动画值来进行判断,这就是不设置 label 的效果,下面我设置 label 以后再看看效果,代码如下:

    val transition = updateTransition(label = "moveAnimation", targetState = moveToRight)

    val paddingStart by transition.animateDp(label = "paddingStart") { state ->
    	...
    }
    val corner by transition.animateDp(label = "corner") { state ->
    	...
    }
    val color by transition.animateColor(label = "color") { state ->
    	...
    }

然后再来看一下预览界面: image.png 显示的就是我们设置的 label 值了,这样就能很方便的知道是哪个动画了,这就是 label 参数的作用。

rememberInfiniteTransition

前面说了animateXxx的动画规格不能设置InfiniteRepeatableSpec即无限循环动画,那是不是 Transition 就不能实现无限循环了呢?并不是,只是不能通过 updateTransition来实现,而是专门提供了一个 rememberInfiniteTransition来实现。 来看一下rememberInfiniteTransition方法的定义:

@Composable
fun rememberInfiniteTransition(label: String = "InfiniteTransition"): InfiniteTransition 

只有一个参数 label,作用跟上面将的 label 的作用一样。 参考上面 updateTransition 的使用方式,应该是通过 rememberInfiniteTransition 创建一个 Transition 然后调用他的animateXxx,那就来看看他有哪些animateXxx方法,是不是跟 updateTransition一样? image.png 发现只有三个 animateXxx 方法,并没有类型animateDpanimateSize等等,但是却有一个通用的 animateValue方法,看看他的定义:

@Composable
fun <T, V : AnimationVector> InfiniteTransition.animateValue(
    initialValue: T,
    targetValue: T,
    typeConverter: TwoWayConverter<T, V>,
    animationSpec: InfiniteRepeatableSpec<T>,
    label: String = "ValueAnimation"
): State<T>

有四个参数:

  • initialValue:初始值
  • targetValue:目标值
  • typeConverter:类型转换,前面讲自定义动画上时介绍过,作用是将自定义类型转比如 Dp 换为动画所需要的 AnimationVector
  • animationSpec:动画规格,返回的是 InfiniteRepeatableSpec 类型
  • label:动画标签

其他几个参数都好理解或者前面都介绍过,这里着重看一下 animationSpec参数,他返回的是一个 InfiniteRepeatableSpec类型,来看看这个类的定义:

class InfiniteRepeatableSpec<T>(
    val animation: DurationBasedAnimationSpec<T>,
    val repeatMode: RepeatMode = RepeatMode.Restart,
    val initialStartOffset: StartOffset = StartOffset(0)
) : AnimationSpec<T>

有三个参数:

  • animation:动画规格,类型是 DurationBasedAnimationSpec,回顾一下之前介绍的动画规格知道他有三个子类,分别是:KeyframesSpecSnapSpecTweenSpec
  • repeatMode:重复的模式,有两个值Restart:重新开始、Reverse:反向执行
  • initialStartOffset:初始动画偏移,StartOffset 类型,偏移的对象是动画时间,默认是不偏移,StartOffset除了可传入偏移时长外还可以传入偏移的类型,可选值为:DelayFastForward,默认为 Delay
    • StartOffsetType.Delay:延迟,即延迟多少时间后再开启动画
    • StartOffsetType.FastForward:快进,即直接跳过动画的指定时长后进行后续动画

了解完 API 后下面就看看具体的使用代码:

// Transition 实例
val transition = rememberInfiniteTransition("infiniteMove")
// 创建重复动画规格
val animationSpec = infiniteRepeatable<Dp>(
    // 动画规格
    tween(),
    // 重复模式
    repeatMode = RepeatMode.Reverse,
    // 初始动画偏移
    initialStartOffset = StartOffset(300, StartOffsetType.Delay)
)
// 使用 animateValue 创建动画
val paddingStart by transition.animateValue(
    initialValue = 10.dp,
    targetValue = 200.dp,
    typeConverter = Dp.VectorConverter,
    animationSpec = animationSpec
)
Box {
    Box(
        Modifier
            // 使用动画值
            .padding(start = paddingStart, top = 30.dp)
            .size(100.dp, 100.dp)
            .background(Color.Blue)
    )
}

运行效果如下: 114.gif

实战

我们将之前的上传按钮动画改成使用Transition动画来实现,核心代码如下:

@Composable
private fun updateTransitionUpload(uploadState: UploadState): UploadValue {
    val transition = updateTransition(targetState = uploadState, label = "upload")
    val textAlpha by transition.animateFloat(label = "textAlpha") {
        when (it) {
            UploadState.Start, UploadState.Uploading -> 0f
            else -> 1f
        }
    }
    val boxWidth by transition.animateDp(label = "boxWidth") {
        when (it) {
            UploadState.Start, UploadState.Uploading -> 48.dp
            else -> 180.dp
        }
    }
    val progress by transition.animateInt(transitionSpec = {
        when {
            UploadState.Start isTransitioningTo UploadState.Uploading -> tween(durationMillis = 1000)
            else -> spring()
        }
    }, label = "progress") {
        when (it) {
            UploadState.Uploading, UploadState.Success -> 100
            else -> 0
        }
    }
    val progressAlpha by transition.animateFloat(label = "progressAlpha") {
        when (it) {
            UploadState.Start, UploadState.Uploading -> 1f
            else -> 0f
        }
    }
    val backgroundColor by transition.animateColor(label = "backgroundColor") {
        when (it) {
            UploadState.Normal -> Color.Blue
            UploadState.Start, UploadState.Uploading -> Color.Gray
            else -> Color.Red
        }
    }
    val text = when (uploadState) {
        UploadState.Success -> "Success"
        else -> "Upload"
    }
    return UploadValue(textAlpha, boxWidth, progress, progressAlpha, backgroundColor, text)

}
@Preview
@Composable
fun UploadDemo() {
    val originWidth = 180.dp
    val circleSize = 48.dp
    var uploadState by remember { mutableStateOf(UploadState.Normal) }
    val uploadValue = updateTransitionUpload(uploadState = uploadState)

    Box(
        modifier = Modifier
            .padding(start = 10.dp, top = 10.dp)
            .width(originWidth),
        contentAlignment = Alignment.Center
    ) {
        Box(
            modifier = Modifier
                .clip(RoundedCornerShape(circleSize / 2))
                .background(uploadValue.backgroundColor)
                // 替换为使用 uploadValue.boxWidth
                .size(uploadValue.boxWidth, circleSize)
                .clickable {
                    uploadState = when (uploadState) {
                        UploadState.Normal -> UploadState.Start
                        UploadState.Start -> UploadState.Uploading
                        UploadState.Uploading -> UploadState.Success
                        UploadState.Success -> UploadState.Normal
                    }
                },
            contentAlignment = Alignment.Center,
        ) {
            Box(
                // 替换为使用 uploadValue.progress
                modifier = Modifier
                    .size(circleSize)
                    .clip(ArcShape(uploadValue.progress))
                    // 替换为使用 uploadValue.progressAlpha
                    .alpha(uploadValue.progressAlpha)
                    .background(Color.Blue)
            )
            Box(
                modifier = Modifier
                    .size(40.dp)
                    .clip(RoundedCornerShape(20.dp))
                    // 替换为使用 uploadValue.progressAlpha
                    .alpha(uploadValue.progressAlpha)
                    .background(Color.White)
            )
            // 替换为使用 uploadValue.text、uploadValue.textAlpha
            Text(
                uploadValue.text,
                color = Color.White,
                modifier = Modifier.alpha(uploadValue.textAlpha)
            )
        }
    }
}

运行效果:

118.gif

最后

在本文中,我们深入探讨了 Android Compose Transition 动画的使用,介绍了 updateTransition、rememberInfiniteTransition 等动画 API 的使用方法。并使用这些 API 实现了一个上传按钮的动画实战,展示了如何使用 Transition 实现动画效果。希望通过本文让大家更好地了解 Android Compose Transition 动画的使用,从而在自己的应用程序中实现更加生动、有趣的动画效果。更多关于 Compose 动画的使用,请持续关注本专栏。

本篇文章设置的源码地址:ComposeAnimationDemo

本文正在参加「金石计划」