Jetpack Compose 的内容切换:从 Crossfade 到 AnimatedContent

471 阅读5分钟

前言

使用 AnimatedVisibility 可以实现单个组件出现和消失的动画效果,那多个组件之间进行切换的动画效果,该怎么实现呢?

就要用到CrossfadeAnimatedContent

它们可以让某块区域的内容,根据用户的操作或状态,从内容 A 换成内容 B,并且这种切换是以动画形式完成的。

淡入淡出切换:Crossfade

如果我们只需要一个淡入淡出的动画效果来切换内容,就可以使用 Crossfade,它是通过改变内容的透明度实现的。

让旧元素逐渐消失(透明度从 1 到 0),让新元素逐渐显示(透明度从 0 到 1),从而实现平滑的过渡效果。

@Composable
fun CrossfadeDemo() {
    var shown by remember { mutableStateOf(true) }

    Column(horizontalAlignment = Alignment.CenterHorizontally) {

        Button(onClick = { shown = !shown }) {
            Text(if (shown) "切换到内容B" else "切换到内容A")
        }

        Crossfade(targetState = shown, animationSpec = tween(durationMillis = 2000)) { targetShow ->
            if (targetShow) {
                Box(
                    modifier = Modifier
                        .size(100.dp)
                        .background(Color.Green),
                    contentAlignment = Alignment.Center
                ) {
                    Image(painter = painterResource(id = R.drawable.girl),contentDescription = null)
                }
            } else {
                Box(
                    modifier = Modifier
                        .size(100.dp)
                        .background(Color.Green),
                    contentAlignment = Alignment.Center
                ) {
                    Image(painter = painterResource(id = R.drawable.dog),contentDescription = null)
                }
            }
        }
    }
}

运行效果:

image.gif

Crossfade 并不是只能针对 truefalse 做出反应,它的目标状态 targetState 可以是任何类型,比如枚举、Int类型。

对于 Crossfade 的动画效果只有淡入淡出,你无法做更多的效果配置(除了控制动画速度曲线和时长)。如果你不需要淡入淡出效果,需要别的动画效果,并且要对效果进行详细的配置,就只能使用更为强大的 AnimatedContent

详细定制内容切换:AnimatedContent

AnimatedContent 不仅可以实现 Crossfade 的淡入淡出效果,还可以对入场和出场动画效果做详细的定制。

先来看看它的基本用法,只需将前面的示例中使用的 Crossfade 改为 AnimatedContent(当然 AnimatedContent 函数没有 animationSpec 参数,要去掉):

@Composable
fun AnimatedContentDemo() {
    var shown by remember { mutableStateOf(true) }

    Column(horizontalAlignment = Alignment.CenterHorizontally) {

        Button(onClick = { shown = !shown }) {
            Text(if (shown) "切换到内容B" else "切换到内容A")
        }

        AnimatedContent(targetState = shown) { targetShow ->
            if (targetShow) {
                Box(
                    modifier = Modifier
                        .size(100.dp)
                        .background(Color.Green),
                    contentAlignment = Alignment.Center
                ) {
                    Image(painter = painterResource(id = R.drawable.girl),contentDescription = null)
                }
            } else {
                Box(
                    modifier = Modifier
                        .size(100.dp)
                        .background(Color.Green),
                    contentAlignment = Alignment.Center
                ) {
                    Image(painter = painterResource(id = R.drawable.dog),contentDescription = null)
                }
            }
        }
    }
}

运行效果:

image.gif

看起来和使用 Crossfade 还是有一些区别的:

  1. 新旧内容交替时,Crossfade 是新、旧元素同时地逐渐出现和逐渐消失,而 AnimatedContent 会等待旧的内容消失后,新的内容再开始出现。

  2. 并且尺寸变化也不同。内容切换时,AnimatedContent 的尺寸变化是以动画的形式渐变地变化。而不是像 Crossfade 那样,在某个时间点,尺寸发生突变。

如果你想更清楚地看到这些区别,可以进入动画预览(Animation Preview),仔细观察:

image.gif

详细配置动画效果

动画效果的配置是主要是通过 transitionSpec 参数来完成的。这个参数是一个 lambda 表达式,需要返回一个 ContentTransform 对象。

并且它具有 Transition.Segment 上下文,我们可以访问到动画切换前的初始状态和切换后的目标状态: initialStatetargetState

@Composable
public fun <S> AnimatedContent(
    targetState: S,
    modifier: Modifier = Modifier,
    transitionSpec: AnimatedContentTransitionScope<S>.() -> ContentTransform = {
        (fadeIn(animationSpec = tween(220, delayMillis = 90)) +
                scaleIn(initialScale = 0.92f, animationSpec = tween(220, delayMillis = 90)))
            .togetherWith(fadeOut(animationSpec = tween(90)))
    },
    contentAlignment: Alignment = Alignment.TopStart,
    label: String = "AnimatedContent",
    contentKey: (targetState: S) -> Any? = { it },
    content: @Composable() AnimatedContentScope.(targetState: S) -> Unit
) 

我们先来看看它的默认值,出场动画是一个 fadeOut 效果,时长90ms;入场动画是 fadeIn 效果加上 scaleIn 效果,并且有一个90ms的延迟,动画时长是220ms。

这就是我们刚刚看到的默认效果。如果不想要这种默认效果,就需要自定义 transitionSpec 参数的 lambda 表达式。

我们来看看 lambda 表达式的返回值的类型 ContentTransform

public class ContentTransform(
    public val targetContentEnter: EnterTransition,
    public val initialContentExit: ExitTransition,
    targetContentZIndex: Float = 0f,
    sizeTransform: SizeTransform? = SizeTransform()
) {
    public var targetContentZIndex: Float by mutableFloatStateOf(targetContentZIndex)

    public var sizeTransform: SizeTransform? = sizeTransform
        internal set
}

它有四个属性:

  1. targetContentEnter: 配置新内容的入场动画

  2. initialContentExit: 配置旧内容的出场动画

    这两个动画效果是如何配置的我们已经知道了,和 AnimatedVisibility 函数中的 enterexit 参数的配置方式完全一样。

    Compose 给我们提供了一个中缀函数 togetherWith,让我们可以轻松地将一个 EnterTransition 和一个 ExitTransition 组合成一个 ContentTransform 对象,像这样:

    transitionSpec = {
        fadeIn(animationSpec = tween(durationMillis = 500)) togetherWith fadeOut(
            animationSpec = tween(
                durationMillis = 500
            )
        )
    }
    

    内部其实就是调用了 ContentTransform 的构造函数

    public infix fun EnterTransition.togetherWith(exit: ExitTransition): ContentTransform =
        ContentTransform(this, exit)
    
  3. targetContentZIndex 参数是用于控制新、旧内容的 Z 轴绘制顺序的。一般来说,新内容都会绘制在旧内容的上面。但如果你不想这样,想要旧内容盖在新内容之上,或者是某个内容永远在最底层,就可以通过调整这个参数来完成,参数的值代表层级,值越大,层级越高。

    你可以通过 ContentTransform 的构造函数传入,也可以在创建好的 ContentTransform 对象上,修改 targetContentZIndex 属性:

    transitionSpec = {
        (fadeIn()).togetherWith(fadeOut()).apply { 
            targetContentZIndex = if (!targetState ) 1f else 0f
        }
    }
    
  4. sizeTransform 参数可以配置当切换内容前后尺寸不相同时,容器尺寸变化的动画效果。我们前面看到的尺寸的渐变效果,就是它完成的。

    public fun SizeTransform(
        clip: Boolean = true,
        sizeAnimationSpec: (initialSize: IntSize, targetSize: IntSize) -> FiniteAnimationSpec<IntSize> =
            { _, _ ->
                spring(
                    stiffness = Spring.StiffnessMediumLow,
                    visibilityThreshold = IntSize.VisibilityThreshold
                )
            }
    )
    

    创建 SizeTransform 对象时,我们可以配置两个参数 clipsizeAnimationSpec

    clip 表示进行尺寸变化的动画时,显示的内容是否裁剪。如果不裁剪,那么在进行尺寸变化的过程中,显示的内容可能会超出或小于组件的布局尺寸。

    sizeAnimationSpec 则是定义尺寸变化的动画速度曲线。

    你可以使用 Compose 提供的中缀函数 using,往 ContentTransform 对象上应用 SizeTransform 对象:

    transitionSpec = {
        (fadeIn()).togetherWith(fadeOut()) using SizeTransform(
            clip = false,
            sizeAnimationSpec = { _, _ -> tween() })
    }
    

    当然也可以通过 ContentTransform 的构造函数直接传入。

示例

最后看一个完整的示例:

@Composable
fun AnimatedContentDemo() {
    var count by remember { mutableIntStateOf(1) }

    Column(horizontalAlignment = Alignment.CenterHorizontally) {
        AnimatedContent(
            targetState = count,
            transitionSpec = {
                // 旧内容向下滑出,新内容向上滑入
                if (targetState > initialState) {
                    (slideInVertically { height -> height } + fadeIn()).togetherWith(
                        exit = slideOutVertically { height -> -height } + fadeOut())
                } else {
                    (slideInVertically { height -> -height } + fadeIn()).togetherWith(
                        exit = slideOutVertically { height -> height } + fadeOut())
                }
            },
        ) { targetCount ->
            Text(
                text = "第 $targetCount 页",
                modifier = Modifier.padding(16.dp),
                color = if (targetCount % 2 == 0) Color.Blue else Color.Magenta
            )
        }

        Row {
            Button(onClick = { count-- }, enabled = (count > 1)) { Text("上一页") }
            Spacer(Modifier.width(16.dp))
            Button(onClick = { count++ }) { Text("下一页") }
        }
    }
}

运行效果:

image.gif