Jetpack Compose 动画6——Crossfade & AnimatedContent

1,237 阅读7分钟

Crossfade & AnimatedContent

Crossfade

AnimatedVisibility 可以为同一内容的出现和消失添加动画效果,那内容切换(一个内容消失,另一个内容出现)该怎么做动画呢?总不能写两遍 AnimatedVisibility 吧,这时候就需要用到 Crossfade 了。Crossfade 能在两个布局内容切换时添加淡出淡入动画效果。使用也非常简单:

var currentShape by remember { mutableStateOf(Shape.Circle) }
Crossfade(targetState = currentShape) { currentShape ->
    when (currentShape) {
        Shape.Circle -> SmallCircle()
        Shape.Square -> BigBox()
    }
}
Crossfade.gif

第一个必填参数是目标状态,第二个必填参数是内容,可以根据不同状态来加载不同的内容,状态改变导致内容切换时,Crossfade 会为消失的内容添加淡出动画,为出现的内容添加淡入动画。

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

而且从源码也能看出原来 Crossfade 也是基于 Transition 实现的,调用的是 transition.Crossfade() 方法。

淡入淡出说白了就是透明度动画,我们可以利用参数 animationSpec: FiniteAnimationSpec<Float> 来自定义透明度动画的规格,例如动画时长、动画曲线等等。

呃...产品那边说不想要淡入淡出的切换效果,能不能整个别的切换效果,右滑出左滑入的那种也行啊...... 不好意思,不行,Crossfade 只能做淡入淡出的切换效果,毕竟它的名字就叫 Cross Fade 嘛,不过我们可以用 AnimatedContent 来实现其他的内容切换效果。

AnimatedContent

AnimatedContent 会在内容根据目标状态发生变化时,为内容添加动画效果。

可以把 AnimatedContent 看作是一个高级版本的 Crossfade,Crossfade 只能做淡入淡出的切换效果,而 AnimatedContent 可以做更多的切换效果。用法和 Crossfade 差不多,直接把上面代码的 Crossfade 换成 AnimatedContent

var currentShape by remember { mutableStateOf(Shape.Circle) }
AnimatedContent(targetState = currentShape) { currentShape ->
    when (currentShape) {
        Shape.Circle -> SmallCircle()
        Shape.Square -> BigBox()
    }
}
AnimatedContent.gif
@Composable
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
) {
    // 一样是基于 Transition 实现的
    val transition = updateTransition(targetState = targetState, label = label)
    transition.AnimatedContent(modifier, transitionSpec, contentAlignment, contentKey, content = content)
}

重点看一下 AnimatedContent() 的参数 transitionSpec,毕竟我们就是奔着“更多的内容切换效果”来的,而这个参数很明显就是用来配置内容的切换效果。它的类型是 AnimatedContentTransitionScope<S>.() -> ContentTransform,感觉有点眼熟,和 Transition.animateXxx() 的参数 transitionSpec 差不多,都是函数参数类型,不过这个函数参数要求返回类型是 ContentTransform

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

ContentTransform 的构造函数可以看出,这是一个内容切换动画的配置类,能够配置 4 个方面的参数:

  • targetContentEnter: EnterTransition 入场动画;

  • initialContentExit: ExitTransition 出场动画;

  • targetContentZIndex: Float 用于指定入场内容的 z-index 值。默认情况下,出场的内容和入场的内容的 z-index 都是 0f,不过 Compose 规定:两个 z-index 值一样的内容,后被添加到界面上的内容会更靠前,所以默认的一次内容切换动画中,入场内容会显示在出场内容之上。

  • sizeTransform: SizeTransform 当入场内容和出场内容的大小不一致时就会涉及到 Size 的转换动画。这个 Size 动画,到底是属于出场动画还是入场动画的一部分呢?好像都属于,又好像都不属于。算了,干脆用一个独立参数来对这个 Size 动画过程进行配置吧。

    SizeTransform.gif

    Crossfade 的淡出淡入是没有大小转换动画的,可以对比一下:

    SizeTransform=null.gif

可以通过构造函数创建一个 ContentTransform 实例,不过更常见的做法是使用 infix 函数 togetherWith()

infix fun EnterTransition.togetherWith(exit: ExitTransition) = ContentTransform(this, exit)
// ContentTransform = EnterTransition togetherWith ExitTransition

另外顺带提一下,ContentTransform 有一个 infix 函数 using(),用于配置 SizeTransform

override infix fun ContentTransform.using(sizeTransform: SizeTransform?) = this.apply {
    this.sizeTransform = sizeTransform
}

现在可以回头看一下 AnimatedContent() 可选参数 transitionSpec 的默认值了:

transitionSpec: AnimatedContentTransitionScope<S>.() -> ContentTransform = {
    (fadeIn(animationSpec = tween(220, delayMillis = 90)) +
     scaleIn(initialScale = 0.92f, animationSpec = tween(220, delayMillis = 90))
    ).togetherWith(fadeOut(animationSpec = tween(90)))
}

入场动画是 [淡入 fadeIn] + [放大 scaleIn],出场动画是 [淡出 fadeOut],出入场延迟都是 90 ms。



Sample

现在我们来动手试一试,下面这段代码,使用 AnimatedContent 根据 ascill 码来切换字母方块。

默认字母切换效果.gif
var currentAsciiCode by remember { mutableStateOf(65) }
...
AnimatedContent(targetState = currentAsciiCode) { asciiCode ->
    val letterChar = Char(code = asciiCode)
    LetterBox(letter = letterChar)
}

我们的目标是将默认效果改为“左滑入,右滑出”,像纸张一样层层堆叠递进:

var currentAsciiCode by remember { mutableStateOf(65) }
...
AnimatedContent(
    targetState = currentAsciiCode,
    transitionSpec = {
        (fadeIn() + slideInHorizontally { fullWidth -> -fullWidth }) togetherWith
        (fadeOut() + slideOutHorizontally { fullWidth -> fullWidth })
    }) { asciiCode ->
    val letterChar = Char(code = asciiCode)
    LetterBox(letter = letterChar)
}
不完美的字母切换效果.gif

入场动画设为 fadeIn() + slideInHorizontally { fullWidth -> -fullWidth }fade() 是淡入,slideInHorizontally { } 是横向滑入,而且滑入前的横向偏移大小,恰好是容器的宽 fullWidth,因为是在左边滑入,那么 OffsetX 就是 -fullWidth。出场动画和入场动画是相反的,不再赘述。

A-Z偏移量.jpg

不过!我们这个场景是字母切换,A -> Z,这个切换场景是有顺序的,当我们反过来的时候就会发现问题了:

字母切换效果问题所在.gif

A -> Z 是“左滑入,右滑出”,反过来 A <- Z 时,应该是“左滑出,右滑入”才对。现在我们点击左边按钮,A <- Z 的切换效果还是按照之前的设置,是不符合用户预期的,感觉很怪异。解决办法也很简单,我们根据动画的初始状态和目标状态来判断是正向切换还是反向切换,然后根据切换方向来设置不同的入场出场动画就 OK 了:

var currentAsciiCode by remember { mutableStateOf(65) }
...
AnimatedContent(
    targetState = currentAsciiCode,
    transitionSpec = { // 拥有 AnimatedContentTransitionScope 上下文,可以获取 initialState 和 targetState
        if (targetState > initialState) { // A -> Z
            (fadeIn() + slideInHorizontally { fullWidth -> -fullWidth }) togetherWith
            (fadeOut() + slideOutHorizontally { fullWidth -> fullWidth })
        } else { // Z -> A
            (fadeIn() + slideInHorizontally { fullWidth -> fullWidth }) togetherWith
            (fadeOut() + slideOutHorizontally { fullWidth -> -fullWidth })
        }
    }) { asciiCode ->
    val letterChar = Char(code = asciiCode)
    LetterBox(letter = letterChar)
}
完美字母切换效果.gif

哎,Okey,现在完美了。原来参数 transitionSpec 类型设计成函数参数,就是为了让我们可以根据不同的场景来动态配置不同的动画效果啊。

另外,有两个很有用的辅助函数 slideIntoContainer(towards = ...)slideOutOfContainer(towards = ...),这两个是 AnimatedContentTransitionScope 的拓展函数,可以快速创建滑入滑出容器的动画效果,只需我们提供方向,而不用我们手动计算初始和目标偏移量。

使用它俩可以替代上面我们写的 slideInHorizontally { }slideOutHorizontally { }

var currentAsciiCode by remember { mutableStateOf(65) }
...
AnimatedContent(
    targetState = currentAsciiCode,
    transitionSpec = { // 拥有 AnimatedContentTransitionScope 上下文,可以获取 initialState 和 targetState
        if (targetState > initialState) { // A -> Z
            (fadeIn() + slideIntoContainer(towards = AnimatedContentTransitionScope.SlideDirection.Right)) togetherWith
            (fadeOut() + slideOutOfContainer(towards = AnimatedContentTransitionScope.SlideDirection.Right))
        } else { // Z -> A
            (fadeIn() + slideIntoContainer(towards = AnimatedContentTransitionScope.SlideDirection.Left)) togetherWith
            (fadeOut() + slideOutOfContainer(towards = AnimatedContentTransitionScope.SlideDirection.Left))
        }
    }) { asciiCode ->
    ...
}

上面的例子中,出场内容和入场内容都是等大小的,如果入场内容和出场内容的大小不一致,使用传统方式 slideInHorizontally { fullWidth -> -fullWidth } 计算偏移量就会出现问题:

错误计算偏移量-前.jpg 错误计算偏移量-后.jpg

入场内容是大方块,出场内容是小方块,入场内容的初始位置就在容器左侧,出场内容的初始位置就在容器右侧,而 slideInHorizontally 传入的 fullWidth 就是当前容器的宽,也就是小方块的宽,这样的话,偏移量就会偏小。

如果使用 slideIntoContainer()slideOutOfContainer(),就不会出现这个问题,因为这两个函数会自动计算出场内容和入场内容的大小,取其中较大的那个作为偏移量的基准值,这样就不会出现偏移量偏小的问题了。

正确计算偏移量-前.jpg 正确计算偏移量-后.jpg