Compose动画:AnimatedContent

937 阅读2分钟

我正在参加「掘金·启航计划」。

今天的 Compose 动画主角是 AnimatedContent。虽然它目前是实验性的(@ExperimentalAnimationApi),不过也不妨碍我们研究和使用。

AnimatedContent

AnimatedContent,又是一个「顾名思义」的货 —— 这足见命名的规范性、贴切性是何等的重要啊 —— 这货是一个高阶方法,提供一个容器,让其内容在 state 改变时可以「动」起来。所谓「动」起来,就是播放设置好的内容进出动画了。动画可以是预制的,也可以是自定义的。

它的源码如下:

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

要点分析

首先, AnimatedContent 是一个泛型方法,参数 targetState 可以是任意类型,只要它改变,content 的动画就会执行

方法内部调用的是 Transition 的扩展:fun <S> Transition<S>.AnimatedContent()。是不是很眼熟?对了,在 AnimatedVisibility 的实现里面,也有类似的实现。

最为重要的,是 transitionSpec 参数,它的类型是 AnimatedContentScope<S>.() -> ContentTransform,这就的两层含义:

  1. 它是一个带接受者的方法,接受者是 AnimatedContentScope<S> 类型
  2. 方法需要构造一个 ContentTransform 类型对象并返回

其中,第2步中的 ContentTransform 就是动画核心所在:用于设置初始内容消失与目标内容加入时的动画

参数 content 是内容组件,为 @Composable 的方法,也是带接受者的,是我们已经熟悉的 AnimatedVisibilityScope,并且目标 state 会在这里回调返回。

案例

理论还是太抽象,我们还是来一个例子看看 AnimatedContent 的效果。一切都采用默认的设置。

private const val DES = "Jetpack is a suite of libraries to help developers follow best practices, reduce boilerplate code, and write code that works consistently across Android versions and devices so that developers can focus on the code they care about."

@Composable
fun AnimatedContentTest() {
    Box {
        val pageOn = remember { mutableStateOf(false) }
        AnimatedContent(pageOn.value) {
            if (!pageOn.value) {
                Text(
                    text = "Empty", modifier = Modifier
                        .fillMaxSize()
                        .background(Color.LightGray), textAlign = TextAlign.Center
                )
            } else {
                Text(text = "$DES\n$DES\n$DES\n$DES\n$DES\n$DES\n$DES\n$DES\n$DES\n", modifier = Modifier
                    .fillMaxSize()
                    .background(Color.Gray))
            }
        }

        Button(onClick = { pageOn.value = !pageOn.value }, modifier = Modifier.padding(16.dp)) {
            Text(text = if (pageOn.value) "隐藏内容" else "显示内容")
        }
    }
}

这里,泛型 S 为 Boolean 型,content 由开关控制,各显示一个 Text,效果为:

device-2022-10-24-170438.gif

AnimatedContent 作为一个容器,在 Button 来回点击时,其内容「动画改变」。

我们回头再来看看,默认的动画是由 transitionSpec 参数提供,其值是:

fadeIn(animationSpec = tween(220, delayMillis = 90)) +
            scaleIn(initialScale = 0.92f, animationSpec = tween(220, delayMillis = 90)) with
            fadeOut(animationSpec = tween(90))

fadeIn() + scaleIn,那就是「渐入+放大」,后面接个 with 是什么?

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

哈,原来是一个中缀函数,连接 EnterTransitionExitTransition,得到我们要的 ContentTransform,enter 是进入动画,exit 是退出动画。前面 with 后接的是 fadeOut,那就是说,退出是「渐隐」。

所以说,整个动画效果为:内容显示「渐入+放大」,内容消失「渐出」。看起来像,但太快了,也不确定?没事,延长下时间来看看:

// ...
AnimatedContent(pageOn.value, transitionSpec = {
            // 覆盖并调整时间参数
            fadeIn(animationSpec = tween(2200, delayMillis = 90)) +
                    scaleIn(initialScale = 0.5f, animationSpec = tween(2200, delayMillis = 90)) with
                    fadeOut(animationSpec = tween(1500))
        }) {
        // ...

沿用默认动画,只延长了的相应的时长,同时将初始尺寸比例改为一半。效果如下:

device-2022-10-24-173034.gif

这下动画应该很明显了。

等等!不仅更明显了,我们还可以观察到一个现象:state 变化时,内容是先直接显示出来,然后再播放动画的。

这就奇了,和想要的效果不一致啊,难道案例的实现,是错的?再看看方法的参数,好像也并没有可控制的。pageOn 是一个 MutableState,它的值改变,就触发 compose 流程 —— 那看来是这玩意儿的锅?一方面,它的值改变,导致 content 改变;另一方面,它作为 state,值变化也将引起动画的出现。这么分析的话,content 就不应该有组件本身的变化。修改下:

// ...
AnimatedContent(/*...*/) {
            Text( // 纯内容变化
                text = if (!pageOn.value) "Empty" else "$DES\n$DES\n$DES\n$DES\n$DES\n$DES\n$DES\n$DES\n$DES\n", modifier = Modifier
                    .fillMaxSize()
                    .background(Color.Gray), textAlign = TextAlign.Center
            )
        }
        // ...

device-2022-10-24-175311.gif

依然有问题。

—— 能没有问题呢,这种写法下,pageOn.value 的引用不照样存在吗?

其实,content 由 @Composable() AnimatedVisibilityScope.(targetState: S) -> Unit 得到,我们大可利用这里的 targetState 来避免 pageOn.value 的引用。再次修改下:

//...
AnimatedContent(/*...*/) { state ->
            Text(
            // 使用回调参数来控制内容
                text = if (!state) "Empty" else "$DES\n$DES\n$DES\n$DES\n$DES\n$DES\n$DES\n$DES\n$DES\n", modifier = Modifier
                    .fillMaxSize()
                    .background(Color.Gray), textAlign = TextAlign.Center
            )
        }

device-2022-10-24-180523.gif

果然,一切如预期了,正常的「前任组件消失+现任组件进入」动画。

思考下,还有没有其它实现方法?

小结

今天完成了 AnimatedContent 的初步认识,其功能和基本使用算是了解了。下一篇咱继续!