Compose 实践与探索六 —— Transition 及其关联 API

本篇是动画的最后一篇,我们先介绍 Transition API,然后再介绍与 Transition 相关的三个上层 API:AnimatedVisibility()、Crossfade()、AnimatedContent()。

1、Transition

Transition API 是用于管理多属性复杂状态过渡动画的核心工具,它允许开发者将多个动画属性绑定到一个状态变化事件中,实现同步、协调的动画效果。

一提到 Transition,熟悉原生开发的同学很容易想到负责 Activity 与 Fragment 转场动画的 Transition。原生的 Transition 只能用于 Activity 与 Fragment 转场动画,不能用在 View 上。而 Compose 的 Transition API 是用于各个 UI 组件的转场动画,二者的适用对象是完全不同的。

实际上前面我们已经讲过一种 Compose 的转场动画:animate*AsState(),在实现一些简单的动画时,使用 animate*AsState() 甚至更简单一些:

@Composable
fun TransitionSample() {
    var big by remember { mutableStateOf(false) }
    val size by animateDpAsState(if (big) 96.dp else 48.dp)

    Box(modifier = Modifier
        .size(size)
        .background(Color.Green)
        .clickable { big = !big }
    )
}

让 Transition 实现点击 Box 变换其尺寸的效果:

@Composable
fun TransitionSample() {
    var big by remember { mutableStateOf(false) }
//    val size by animateDpAsState(if (big) 96.dp else 48.dp)
    val bigTransition = updateTransition(targetState = big)
    val size by bigTransition.animateDp { // it 是 state 对象,也就是 Boolean
        if (it) 96.dp else 48.dp
    }

    Box(modifier = Modifier
        .size(size)
        .background(Color.Green)
        .clickable { big = !big }
    )
}

容易看出,Transition 实现同样的动画效果要比使用 animateDpAsState() 多出一步,需要先创建一个 Transition 对象,然后调用相应的函数创建动画。

既然如此,为什么还要提供 Transition API 呢?我们带着这个问题来学习 Transition。

1.1 Transition 的创建与使用

上面的示例使用 updateTransition() 创建 Transition 对象:

@Composable
fun <T> updateTransition(
    targetState: T,
    label: String? = null
): Transition<T> {
    // 创建 Transition 对象并通过 remember 缓存,无参 remember 只会在第一次调用时执行,
    // 从而保证了 Transition 只被创建一次
    val transition = remember { Transition(targetState, label = label) }
    // 动画转移到目标状态
    transition.animateTo(targetState)
    DisposableEffect(transition) {
        onDispose {
            // Clean up on the way out, to ensure the observers are not stuck in an in-between
            // state.
            transition.onTransitionEnd()
        }
    }
    return transition
}

源码表明,updateTransition() 实际上做了两件事:创建 Transition 与更新状态。注意它只更新了状态,让 Transition 处于旧状态与新状态之间的中间状态,但不负责根据状态转移而生成动画。你需要调用它的返回值 Transition 提供的对应的函数以展示状态转移动画,比如 animateDp()、animateInt()、animateFloat() 等等。

以示例代码使用的 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 // 泛型 S 表示状态类型
): State<Dp> =
    animateValue(Dp.VectorConverter, transitionSpec, label, targetValueByState)

第三个参数 targetValueByState 是一个 Composable 函数,作用是根据传入的目标状态参数 state 计算出动画的目标值。

第二个参数 label 是在 Android Studio 的预览模式下,区分同一个 Transition 下不同动画的标识。具体说来,就是给可组合函数加上 @Preview 注解进入预览模式后,可以点击如下图所示的按钮进入动画预览模式:

请添加图片描述

在动画预览模式下,可以看到 Transition 下的状态以及所有动画:

请添加图片描述

当然,由于我们目前使用的是一个非常简单的例子,因此你可以很容易的判断出,左侧方框内的 Boolean 表示的是 big 状态,而右侧方框内的 DpAnimation : 48.0dp 表示的是 size(DpAnimation 是参数默认值)。但倘若一个非常复杂的动画,它的 Transition 内部可能会包含很多的状态和动画值,如果没有标识就很难区分它们。所以,第二个参数的 label 此时就派上用场了,当你调用 updateTransition() 和 animateDp() 时传入一个特定的 label,在预览时就能区分它们了:

@Preview
@Composable
fun TransitionSample() {
    var big by remember { mutableStateOf(false) }
    val bigTransition = updateTransition(big, "big")
    val size by bigTransition.animateDp(label = "size") {
        if (it) 96.dp else 48.dp
    }

    Box(modifier = Modifier
        .size(size)
        .background(Color.Green)
        .clickable { big = !big }
    )
}

效果如下:

在这里插入图片描述

最后说第一个参数 transitionSpec,它是一个返回有限动画 FiniteAnimationSpec 的 Composable 函数,animateDp() 的动画曲线就是用这个函数生成的。它要求你传一个函数而不是直接提供一个 FiniteAnimationSpec 对象,是为了方便在不同的状态下提供不同的 FiniteAnimationSpec,比如:

val size by bigTransition.animateDp({if (!initState && targetState) spring() else tween() }, label = "size") { if (it) 96.dp else 48.dp }

initState 与 targetState 是 Segment 提供的。Segment 是指状态转移中的某一段状态,可以提供这一段的状态信息。Segment 提供了简便方法 isTransitioningTo():

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

因此上面的等价写法是:

val size by bigTransition.animateDp({if (false isTransitioningTo true) spring() else tween() }, label = "size") { if (it) 96.dp else 48.dp }

这样更加直观的表达出状态从 false 转移到 true。

如果想创建一个无限循环类型的 Transition,可以使用 rememberInfiniteTransition(),它会返回一个 InfiniteTransition。

1.2 Transition 与 animate*AsState() 的区别

既然二者能实现相同的效果,为啥还要多提供一个 Transition?它用起来还比 animateXxxAsState() 多一些步骤。因为二者的关注点不同:animateDpAsState() 面向属性本身,而 Transition 面向属性背后的状态,这样就容易建立多属性的状态模型。

比如,给上例增加一个功能,在点击时改变图形的圆角大小:

@Composable
fun TransitionSample() {
    var big by remember { mutableStateOf(false) }
    val bigTransition = updateTransition(targetState = big)
    val size by bigTransition.animateDp { if (it) 96.dp else 48.dp }
    val corner by bigTransition.animateDp { if (it) 0.dp else 18.dp }

    Box(modifier = Modifier
        .size(size)
        .clip(RoundedCornerShape(corner))
        .background(Color.Green)
        .clickable { big = !big }
    )
}

假如使用 animateDpAsState() 来计算 size 和 corner,那么 Compose 会为每个属性启动一个协程进行计算动画,而使用 Transition 则只开一个协程,在 Transition 内部对所有的属性动画做统一管理。因此属性越多,Transition 的性能优势越明显。

此外还有一个好处,就是前面介绍 animateDp() 的第二个参数 label 时说到可以进行动画的预览,这个是 animate*AsState() 所不具备的功能。

还有,经过前面对 Transition 的介绍,你会发现它与 animate*AsState() 并没有本质上的区别,都是针对状态转移的。只不过一个是瞄准的是具体的属性,一个瞄准的是所有状态。因此,animateDpAsState() 具有的弱点 —— 不能设置动画的初始值,Transition 也是有的。虽然不能直接设置初始值,但是 Transition 可以通过一种比较迂回的方式去设置动画的初始值。先观察 updateTransition() 的源码:

@Composable
fun <T> updateTransition(
    targetState: T,
    label: String? = null
): Transition<T> {
    val transition = remember { Transition(targetState, label = label) }
    transition.animateTo(targetState)
    DisposableEffect(transition) {
        onDispose {
            // Clean up on the way out, to ensure the observers are not stuck in an in-between
            // state.
            transition.onTransitionEnd()
        }
    }
    return transition
}

通过 Transition 的次构造函数传入 targetState:

@Stable
class Transition<S> @PublishedApi internal constructor(
    private val transitionState: MutableTransitionState<S>,
    val label: String? = null
) {
    internal constructor(
        initialState: S,
        label: String?
    ) : this(MutableTransitionState(initialState), label)
}

次构造调用主构造时会将 targetState 包装进 MutableTransitionState 对象中,实际上真正进行状态管理的就是 MutableTransitionState。

既然如此,我们可以在创建 Transition 时,使用另一个 updateTransition() 直接传入 MutableTransitionState:

@Composable
fun <T> updateTransition(
    transitionState: MutableTransitionState<T>,
    label: String? = null
): Transition<T> {
    val transition = remember(transitionState) {
        Transition(transitionState = transitionState, label)
    }
    transition.animateTo(transitionState.targetState)
    DisposableEffect(transition) {
        onDispose {
            // Clean up on the way out, to ensure the observers are not stuck in an in-between
            // state.
            transition.onTransitionEnd()
        }
    }
    return transition
}

这样做的好处是,可以在界面刚开始展示时,就直接开始动画:

@Preview
@Composable
fun TransitionSample() {
    var big by remember { mutableStateOf(true) }
    val bigState = remember { MutableTransitionState(!big) }
    bigState.targetState = big
    val bigTransition = updateTransition(bigState, "big")
    val size by bigTransition.animateDp(label = "size") { if (it) 96.dp else 48.dp }
    val corner by bigTransition.animateDp(label = "corner") { if (it) 0.dp else 18.dp }

    Box(modifier = Modifier
        .size(size)
        .clip(RoundedCornerShape(corner))
        .background(Color.Green)
        .clickable { big = !big }
    )
}

这一点是 animate*AsState() 做不到的。

2、AnimatedVisibility()

AnimatedVisibility() 会让其内容的显示与隐藏以动画的方式呈现,而不是突然的出现或消失。

2.1 基本用法

举一个简单的代码示例,点击按钮切换方块的显示或隐藏:

@Composable
fun AnimatedVisibilitySample() {
    Column {
        var shown by remember { mutableStateOf(true) }
        if (shown) {
            Box(
                modifier = Modifier
                    .size(100.dp)
                    .background(Color.Green)
            )
        }
        Button(onClick = { shown = !shown }) {
            Text("切换")
        }
    }
}

上述代码完成的是没有动画效果的显示与隐藏,将 if 修改为 AnimatedVisibility() 即可实现动画效果:

请添加图片描述

可以看到,消失的动画是从下至上的,而显示的动画是由上至下的。这是因为,我们在 Column 中调用的 AnimatedVisibility() 实际上是 ColumnScope 的扩展函数:

@Composable
fun ColumnScope.AnimatedVisibility(
    visible: Boolean,
    modifier: Modifier = Modifier,
    enter: EnterTransition = fadeIn() + expandVertically(),
    exit: ExitTransition = fadeOut() + shrinkVertically(),
    label: String = "AnimatedVisibility",
    content: @Composable AnimatedVisibilityScope.() -> Unit
) {
    val transition = updateTransition(visible, label)
    AnimatedEnterExitImpl(transition, { it }, modifier, enter, exit, content)
}

该扩展函数的参数 enter 指定了入场动画,默认值是 fadeIn() + expandVertically(),即淡入 + 垂直展开动画,exit 指定出场动画,默认值是 fadeOut() + shrinkVertically(),即淡出 + 垂直收缩。演示效果正是因为使用了这两个参数的默认值。

既然 Column 有扩展函数,那么 Row 也会有相应的扩展函数:

@Composable
fun RowScope.AnimatedVisibility(
    visibleState: MutableTransitionState<Boolean>,
    modifier: Modifier = Modifier,
    enter: EnterTransition = expandHorizontally() + fadeIn(),
    exit: ExitTransition = shrinkHorizontally() + fadeOut(),
    label: String = "AnimatedVisibility",
    content: @Composable() AnimatedVisibilityScope.() -> Unit
) {
    val transition = updateTransition(visibleState, label)
    AnimatedEnterExitImpl(transition, { it }, modifier, enter, exit, content)
}

如果不是以上两种布局,就会使用通用的 AnimatedVisibility():

@Composable
fun AnimatedVisibility(
    visibleState: MutableTransitionState<Boolean>,
    modifier: Modifier = Modifier,
    enter: EnterTransition = fadeIn() + expandIn(),
    exit: ExitTransition = fadeOut() + shrinkOut(),
    label: String = "AnimatedVisibility",
    content: @Composable() AnimatedVisibilityScope.() -> Unit
) {
    val transition = updateTransition(visibleState, label)
    AnimatedEnterExitImpl(transition, { it }, modifier, enter, exit, content)
}

AnimatedVisibility() 在内部先用 updateTransition() 创建出一个 Transition 对象并使用该对象进行后续的动画操作,因此 AnimatedVisibility() 可以视为对 updateTransition() 的扩展。

2.2 参数详解

下面来详细看一下 enter 与 exit 的类型 EnterTransition 与 ExitTransition,由于二者相似,因此我们只详解 EnterTransition:

@Immutable
sealed class EnterTransition {
    internal abstract val data: TransitionData
}

@Immutable
private class EnterTransitionImpl(override val data: TransitionData) : EnterTransition()

EnterTransition 是一个密封类,它只有一个子类 EnterTransitionImpl,实现了父类的抽象属性 data:

@Immutable
internal data class TransitionData(
    val fade: Fade? = null, // 淡入淡出
    val slide: Slide? = null, // 滑动
    val changeSize: ChangeSize? = null, // 尺寸改变,裁切方式
    val scale: Scale? = null // 缩放
)

TransitionData 定义了四个属性,实际上就是四种类型的动画。在使用时,不用自己创建这四种类型的对象再传进来,可以直接使用 Compose 提供的函数,下面来分别介绍。

Fade

淡入效果 fadeIn() 会完成整个创建流程并返回一个 EnterTransitionImpl:

@Stable
fun fadeIn(
    animationSpec: FiniteAnimationSpec<Float> = spring(stiffness = Spring.StiffnessMediumLow),
    initialAlpha: Float = 0f
): EnterTransition {
    // 为 EnterTransitionImpl 传入 TransitionData,该 TransitionData 指定了 fade 属性
    return EnterTransitionImpl(TransitionData(fade = Fade(initialAlpha, animationSpec)))
}

我们可以传入定制的 FiniteAnimationSpec 以及初始透明度:

@Composable
fun AnimatedVisibilitySample() {
    Column {
        var shown by remember { mutableStateOf(true) }
        AnimatedVisibility(shown, enter = fadeIn(tween(4000), 0.3f)) {
            Box(
                modifier = Modifier
                    .size(100.dp)
                    .background(Color.Green)
            )
        }
        Button(onClick = { shown = !shown }) {
            Text("切换")
        }
    }
}

为了让动画效果显得明显一些,特意将动画时长设置为 4 秒,效果如下:

请添加图片描述

Slide

同理,slide 对应的入场效果动画是 slideIn():

@Stable
fun slideIn(
    animationSpec: FiniteAnimationSpec<IntOffset> =
        spring(
            stiffness = Spring.StiffnessMediumLow,
            visibilityThreshold = IntOffset.VisibilityThreshold
        ),
    initialOffset: (fullSize: IntSize) -> IntOffset, 
): EnterTransition {
    return EnterTransitionImpl(TransitionData(slide = Slide(initialOffset, animationSpec)))
}

必须要给 initialOffset 这个函数参数赋值,因为需要通过它来确定从哪个位置滑入:

@Composable
fun AnimatedVisibilitySample() {
    Column {
        var shown by remember { mutableStateOf(true) }
        AnimatedVisibility(shown, enter = slideIn { IntOffset(-it.width, -it.height) }) {
            Box(
                modifier = Modifier
                    .size(100.dp)
                    .background(Color.Green)
            )
        }
        Button(onClick = { shown = !shown }) {
            Text("切换")
        }
    }
}

initialOffset() 在参数中提供了组件的尺寸 fullSize,因此可以很容易地获取到左上顶点的偏移量,据此创建 IntOffset 并返回即可。效果如下:

请添加图片描述

此外,slideIn() 还有只进行垂直方向滑入或水平方向滑入的衍生版本 slideInVertically() 与 slideInHorizontally(),只需要传入对应方向的一维偏移量即可,不多赘述。

ChangeSize

changeSize 的入场动画由 expandIn() 提供:

@Stable
fun expandIn(
    animationSpec: FiniteAnimationSpec<IntSize> =
        spring(
            stiffness = Spring.StiffnessMediumLow,
            visibilityThreshold = IntSize.VisibilityThreshold
        ),
    expandFrom: Alignment = Alignment.BottomEnd,
    clip: Boolean = true,
    initialSize: (fullSize: IntSize) -> IntSize = { IntSize(0, 0) },
): EnterTransition {
    return EnterTransitionImpl(
        TransitionData(
            changeSize = ChangeSize(expandFrom, initialSize, animationSpec, clip)
        )
    )
}

这种是指裁切方式入场,默认是保留组件右下角的区域,然后在组件左上角的位置,慢慢展开保留区域直到展示出整个组件。我们先只给 animationSpec 传一个时长为 5 秒的 TweenSpec 来观察 expandIn() 的默认效果,并且为了证明默认保留右下角区域,我们在绿色方块的右下角放了一个小的红色方块作为参照,代码如下:

@Composable
fun AnimatedVisibilitySample() {
    Column {
        var shown by remember { mutableStateOf(true) }
        AnimatedVisibility(shown, enter = expandIn(tween(5000))) {
            Box(
                modifier = Modifier
                    .size(100.dp)
                    .background(Color.Green)
            ) {
                Box(
                    modifier = Modifier
                        .size(30.dp)
                        .background(Color.Red)
                        .align(Alignment.BottomEnd)
                )
            }
        }

        Button(onClick = { shown = !shown }) {
            Text("切换")
        }
    }
}

效果图如下:

请添加图片描述

可以看到,是右下角保留的红色方块现在左上角的位置显现,然后慢慢扩大直到整个组件完全展示。

接下来再看 expandIn() 参数的含义:

  • expandFrom:扩展边界的起点,默认为 Alignment.BottomEnd,这解释了为何默认情况下组件是从右下角展开的
  • clip:是否应剪切动画边界之外的内容,默认为 true,如果设置为 false,则不进行裁切,只进行简单的位移
  • initialSize:扩展边界的起始大小,默认返回 IntSize(0, 0)

我们对以上参数逐个进行测试。首先修改 expandFrom 的位置,比如让它从左上角开始展开:

AnimatedVisibility(shown, enter = expandIn(tween(5000), Alignment.TopStart))

效果如下,右下角的红色方块最后才展示出来:

请添加图片描述

然后设置 initialSize,让宽高都是一半的位置作为动画开始的初始位置:

AnimatedVisibility(
    shown,
    enter = expandIn(tween(5000), Alignment.TopStart) {
        IntSize(it.width / 2, it.height / 2)
    },
)

动画初始就会展示左上角的 1/4,然后慢慢展开,效果如下:

请添加图片描述

再看 clip,将其修改为 false 查看效果:

AnimatedVisibility(
    shown,
    enter = expandIn(tween(5000), Alignment.TopStart, false) {
        IntSize(it.width / 2, it.height / 2)
    },
)

效果如下:

请添加图片描述

expandIn() 也有两个在单一方向上的衍生函数 expandHorizontally() 与 expandVertically()。

Scale

最后,通过 scaleIn() 提供缩放入场:

@Stable
@ExperimentalAnimationApi
fun scaleIn(
    animationSpec: FiniteAnimationSpec<Float> = spring(stiffness = Spring.StiffnessMediumLow),
    initialScale: Float = 0f,
    transformOrigin: TransformOrigin = TransformOrigin.Center,
): EnterTransition {
    return EnterTransitionImpl(
        TransitionData(scale = Scale(initialScale, transformOrigin, animationSpec))
    )
}

默认以组件中心 TransformOrigin.Center 为缩放中心,默认效果如下:

请添加图片描述

最后我们来说一下这四种函数之间的加号,是 EnterTransition 重写了 plus 操作符:

	@Stable
    operator fun plus(enter: EnterTransition): EnterTransition {
        return EnterTransitionImpl(
            TransitionData(
                fade = data.fade ?: enter.data.fade,
                slide = data.slide ?: enter.data.slide,
                changeSize = data.changeSize ?: enter.data.changeSize,
                scale = data.scale ?: enter.data.scale
            )
        )
    }

plus 是创建一个新的 EnterTransitionImpl,里面的 TransitionData 的四个参数,优先选择等号左侧的,如果等号左侧没有配置某种动画,才会选择等号右侧的。

出场动画 ExitTransition 的内容与 EnterTransition 大同小异,只不过入场动画的 expandIn() 对应出场动画的 shrinkOut():

AnimatedVisibility(
    shown,
    enter = fadeIn() + expandIn(),
    exit = fadeOut() + shrinkOut()
)

此外,Transition 还有一个 AnimatedVisibility() 的扩展函数:

@ExperimentalAnimationApi
@Composable
fun <T> Transition<T>.AnimatedVisibility(
    visible: (T) -> Boolean,
    modifier: Modifier = Modifier,
    enter: EnterTransition = fadeIn() + expandIn(),
    exit: ExitTransition = shrinkOut() + fadeOut(),
    content: @Composable() AnimatedVisibilityScope.() -> Unit
) = AnimatedEnterExitImpl(this, visible, modifier, enter, exit, content)

第一个参数 visible 是返回 Boolean 类型的函数,其参数 T 是 Transition 的状态,你需要根据这个状态决定 AnimatedVisibility() 的内容是否可见并作为 visible 的返回值。

无论使用哪一种 AnimatedVisibility,它里面都只能存放一个 Composable 组件,放多了行为不正常。如果有多个 Composable 组件想使用 AnimatedVisibility,那么就为每一个组件配一个 AnimatedVisibility。

3、Crossfade()

Crossfade() 会以动画方式切换显示内容,动画是透明度渐变动画,也就是淡入淡出动画,与上一节介绍的 fadeIn()、fadeOut() 完全相同。我们直接来看 Crossfade() 的参数,因为这些参数在前面介绍其他 API 时已经见过很多次了:

@OptIn(ExperimentalAnimationApi::class)
@Composable
fun <T> Crossfade(
    targetState: T,
    modifier: Modifier = Modifier,
    animationSpec: FiniteAnimationSpec<Float> = tween(),
    label: String = "Crossfade",
    content: @Composable (T) -> Unit
) {
    val transition = updateTransition(targetState, label)
    transition.Crossfade(modifier, animationSpec, content = content)
}

targetState 是代表目标布局状态的键,每当更改一个键时,动画将被触发,使用旧键调用的 content 将淡出,而使用新键调用的 content 将淡入。

假设在未使用动画的情况下,根据不同状态显示相应组件的代码如下:

@Composable
fun CrossfadeSample() {
    Column {
        var shown by remember { mutableStateOf(false) }
        if (shown) {
            Box(
                Modifier
                    .size(48.dp)
                    .background(Color.Red)
            )
        } else {
            Box(
                Modifier
                    .size(24.dp)
                    .background(Color.Green)
            )
        }
        Button(onClick = { shown = !shown }) {
            Text("切换")
        }
    }
}

如果想实现动画切换效果,可以用 Crossfade() 包住负责组件切换的 if - else 代码块,并且将 if 的判断条件修改为参数传入的 state 对象:

@Composable
fun CrossfadeSample() {
    Column {
        var shown by remember { mutableStateOf(false) }
        Crossfade(targetState = shown, animationSpec = tween(5000)) { state ->
            if (state) {
                Box(
                    Modifier
                        .size(48.dp)
                        .background(Color.Red)
                )
            } else {
                Box(
                    Modifier
                        .size(24.dp)
                        .background(Color.Green)
                )
            }
        }

        Button(onClick = { shown = !shown }) {
            Text("切换")
        }
    }
}

这里照例是为了让动画效果更明显,将动画时间设置为 5 秒了,效果如下:

请添加图片描述

可以看到,在渐变过程中,就是两个组件同时存在的时间段内,它会让两个组件都完整显示,但是在渐变完成之后,就只保留新组件的尺寸范围。

由于 Crossfade() 就是一个功能很简单的函数,因此它不能修改动画类型,只能是淡入淡出动画。此外,Crossfade() 的状态值可以是任意类型,不止是上面示例的 Boolean 类型。比如 Int 类型,它可以有多个状态值,需要结合 when 使用。

4、AnimatedContent()

前两节我们讲了 AnimatedVisibility() 与 Crossfade(),它们有各自的适用场景:

  • AnimatedVisibility():对单个组件的出现与消失的动画,可以配置多种动画规格(透明度、偏移位置、尺寸裁剪、缩放)
  • Crossfade():对不同状态下的多个组件间切换施以透明度淡入淡出的动画效果,无法配置其他动画效果

AnimatedContent() 覆盖了上述两个函数的功能,可以对两个组件根据状态进行切换,并可以配置切换时的动画效果。它的使用方式也与上面的函数类似:

@OptIn(ExperimentalAnimationApi::class)
@Composable
fun AnimatedContentSample() {
    Column {
        var shown by remember { mutableStateOf(false) }
        AnimatedContent(shown) { state ->
            if (state) {
                Box(
                    Modifier
                        .size(48.dp)
                        .background(Color.Red)
                )
            } else {
                Box(
                    Modifier
                        .size(24.dp)
                        .background(Color.Green)
                )
            }
        }

        Button(onClick = { shown = !shown }) {
            Text("切换")
        }
    }
}

效果如下:

请添加图片描述

这种效果是 AnimatedContent() 的 transitionSpec 参数的默认值配置出的动画效果:

@ExperimentalAnimationApi
@Composable
fun <S> AnimatedContent(
    targetState: S,
    modifier: Modifier = Modifier,
    transitionSpec: AnimatedContentScope<S>.() -> ContentTransform = {
        fadeIn(animationSpec = tween(220, delayMillis = 90)) +
            scaleIn(initialScale = 0.92f, animationSpec = tween(220, delayMillis = 90)) with
            fadeOut(animationSpec = tween(90))
    },
    contentAlignment: Alignment = Alignment.TopStart,
    content: @Composable() AnimatedVisibilityScope.(targetState: S) -> Unit
) {
    val transition = updateTransition(targetState = targetState, label = "AnimatedContent")
    transition.AnimatedContent(
        modifier,
        transitionSpec,
        contentAlignment,
        content = content
    )
}

入场的两个动画 fadeIn() 与 scaleIn() 都是延迟 90ms 开始执行,持续 220ms。出场动画 fadeOut() 则是立即执行,时长 90ms。实际上就是 fadeOut() 执行完,让原始组件消失之后,再开始执行入场的两个动画。

4.1 ContentTransform

再观察 transitionSpec 的返回值 ContentTransform:

@ExperimentalAnimationApi
class ContentTransform(
    val targetContentEnter: EnterTransition,
    val initialContentExit: ExitTransition,
    targetContentZIndex: Float = 0f,
    sizeTransform: SizeTransform? = SizeTransform()
) {

    var targetContentZIndex by mutableStateOf(targetContentZIndex)

    var sizeTransform: SizeTransform? = sizeTransform
        internal set
}

ContentTransform 有四个属性:

  • targetContentEnter:目标内容的入场动画
  • initialContentExit:初始内容的出场动画
  • targetContentZIndex:Z 轴的绘制顺序,用于配置各个组件间的覆盖关系
  • sizeTransform:尺寸渐变动画

targetContentEnter 与 initialContentExit

假如不想使用 AnimatedContent() 的参数 transitionSpec 的默认值,想要自己选取出入场动画,可以自己写一个返回 ContentTransform 的函数:

AnimatedContent(
    shown,
    transitionSpec = { ContentTransform(fadeIn(), fadeOut()) }
)

它还有一种等价的方式 —— 使用 EnterTransition 的扩展函数 with():

AnimatedContent(shown, transitionSpec = { fadeIn() with fadeOut() })

with() 实际上就是帮助我们实现了第一种方式:

@ExperimentalAnimationApi
infix fun EnterTransition.with(exit: ExitTransition) = ContentTransform(this, exit)

targetContentZIndex

假如还想修改出入场组件之间的覆盖关系,可以通过修改 targetContentZIndex 实现。默认情况下,targetContentZIndex 是无需配置的,因为入场的组件会在出场组件的上面,遮住出场组件,这个行为是符合预期与需求的。比如使用以下测试代码:

@OptIn(ExperimentalAnimationApi::class)
@Composable
fun AnimatedContentSample1() {
    Column {
        var shown by remember { mutableStateOf(false) }
        AnimatedContent(
            shown,
            transitionSpec = {
                fadeIn(tween(3000)) with fadeOut(tween(3000, 3000))
            }
        ) {
            if (it) {
                Box(
                    Modifier
                        .size(48.dp)
                        .clip(RoundedCornerShape(10.dp))
                        .background(Color.Red)
                )
            } else {
                Box(
                    Modifier
                        .size(24.dp)
                        .background(Color.Green)
                )
            }
        }

        Button(onClick = { shown = !shown }) {
            Text("切换")
        }
    }
}

效果如下:

请添加图片描述

我们设置出场动画延迟 3s 再开始执行,目的是等待入场动画执行完毕后再开始出场动画,这样看的更清楚一些。上图可以明显看出,红色方块入场时先立即覆盖了即将出场的绿色方块,在入场动画结束后,绿色方块开始出场,左上角的绿色才缓缓消失。

但是有些情况下,默认行为不能满足开发需求,比如想让一个组件做背景,那么这个组件在动画过程中,不论是入场还是出场,它一定是永远在最下层的。再比如一个悬浮的按钮,不管是入场还是出场,在动画过程中永远都是在最上层的。因此我们需要酌情修改 targetContentZIndex。比如对于上面的例子,改为不论入场还是出场,红色方块永远在绿色方块下方:

@OptIn(ExperimentalAnimationApi::class)
@Composable
fun AnimatedContentSample2() {
    Column {
        var shown by remember { mutableStateOf(false) }
        AnimatedContent(
            shown,
            transitionSpec = {
                // targetState 就是 shown,当其为 true 时就是要显示红色方块了,
                // 此时将 targetContentZIndex 调小
                if (targetState) {
                    (fadeIn(tween(3000)) with
                            fadeOut(tween(3000, 3000))).apply {
                        targetContentZIndex = -1f
                    }
                } else {
                    fadeIn(tween(3000)) with fadeOut(tween(3000, 3000))
                }
            }
        ) {
            if (it) {
                Box(
                    Modifier
                        .size(48.dp)
                        .clip(RoundedCornerShape(10.dp))
                        .background(Color.Red)
                )
            } else {
                Box(
                    Modifier
                        .size(24.dp)
                        .background(Color.Green)
                )
            }
        }

        Button(onClick = { shown = !shown }) {
            Text("切换")
        }
    }
}

由于 targetContentZIndex 的默认值是 0f,如果想让红色方块在下面,给它赋一个小于 0 的值即可:

请添加图片描述

sizeTransform

最后看 sizeTransform 的设置,直接使用 SizeTransform():

@ExperimentalAnimationApi
fun SizeTransform(
    clip: Boolean = true,
    sizeAnimationSpec: (initialSize: IntSize, targetSize: IntSize) -> FiniteAnimationSpec<IntSize> =
        { _, _ -> spring(visibilityThreshold = IntSize.VisibilityThreshold) }
): SizeTransform = SizeTransformImpl(clip, sizeAnimationSpec)

clip 表示是否裁切,一般为 true;sizeAnimationSpec 是计算动画曲线的函数,返回 FiniteAnimationSpec,前面也说过多次了。

如果想把配置好的 SizeTransform 合并到现有的 ContentTransform 对象中,可以其扩展函数 using():

AnimatedContent(
    shown,
    transitionSpec = {
        (fadeIn() + scaleIn(tween(500)) with fadeOut()) using SizeTransform()
    }
)

两个入场动画合并通过重写 EnterTransition 的 plus 操作符实现:

sealed class EnterTransition {

    @Stable
    operator fun plus(enter: EnterTransition): EnterTransition {
        return EnterTransitionImpl(
            TransitionData(
                fade = data.fade ?: enter.data.fade,
                slide = data.slide ?: enter.data.slide,
                changeSize = data.changeSize ?: enter.data.changeSize,
                scale = data.scale ?: enter.data.scale
            )
        )
    }
}

入场动画与出场动画合并使用 with():

@ExperimentalAnimationApi
infix fun EnterTransition.with(exit: ExitTransition) = ContentTransform(this, exit)

最后将 SizeTransform 合并到已有的 ContentTransform 中,用 using():

@ExperimentalAnimationApi
class AnimatedContentScope<S> internal constructor(
    @ExperimentalAnimationApi
    infix fun ContentTransform.using(sizeTransform: SizeTransform?) = this.apply {
        this.sizeTransform = sizeTransform
    }
}

可以回头看一下 AnimatedContent() 的 transitionSpec 参数,就是通过以上方式将入场、出场效果配置在一起的。

4.2 Transition 版本

AnimatedContent() 也有一个 Transition 版本,内容与使用方式与本节讲述的通用版本类似:

@ExperimentalAnimationApi
@Composable
fun <S> Transition<S>.AnimatedContent(
    modifier: Modifier = Modifier,
    transitionSpec: AnimatedContentScope<S>.() -> ContentTransform = {
        fadeIn(animationSpec = tween(220, delayMillis = 90)) +
            scaleIn(initialScale = 0.92f, animationSpec = tween(220, delayMillis = 90)) with
            fadeOut(animationSpec = tween(90))
    },
    contentAlignment: Alignment = Alignment.TopStart,
    contentKey: (targetState: S) -> Any? = { it },
    content: @Composable() AnimatedVisibilityScope.(targetState: S) -> Unit
) {...}
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值