Compose动画:AnimatedVisibility (2)

1,015 阅读6分钟

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

在《Compose动画:AnimatedVisibility》结尾中提到,AnimatedVisibility 还有一个版本未讲到,即 Transition 的扩展版本,今天就聊它吧。

Transition

Transition manages all the child animations on a state level. Child animations can be created in a declarative way using Transition.animateFloat, Transition.animateValue, animateColor etc. When the targetState changes, Transition will automatically start or adjust course for all its child animations to animate to the new target values defined for each animation. After arriving at targetState, Transition will be triggered to run if any child animation changes its target value (due to their dynamic target calculation logic, such as theme-dependent values).

上面是 Transition 类的描述,它的功能是:在同一状态层级下,管理所有子动画。其源码如下:

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


    var currentState: S
        get() = transitionState.currentState
        internal set(value) {
            transitionState.currentState = value
        }
    
    // 重要的状态参数
    var targetState: S by mutableStateOf(currentState)
        internal set

    // 略 ...
}

怎么管理呢?就是targetState 变化时,Transition 会自动启动或调整子动画的进程,让它们进入到新的目标状态,相当于说,它走到了一个统筹管理的使用。

在构造 Transition 时,需要一个 MutableTransitionState 对象,这个在上篇中已经讲过,即,一个状态控制参数。

AnimatedVisibilityScope

继续讲 Transition 的扩展动画前,还得来说说 AnimatedVisibilityScope。这货呢,在各个 AnimatedVisibility 版本中都是出现的,它是「动画 content 的接收 scope」。

fun AnimatedVisibility(
    // ...
    content: @Composable() AnimatedVisibilityScope.() -> Unit // content 的 scope 控制
) {
    // ...
}

来看看 AnimatedVisibilityScope 的源码:

interface AnimatedVisibilityScope {

    @Suppress("EXPERIMENTAL_ANNOTATION_ON_WRONG_TARGET")
    @get:ExperimentalAnimationApi
    @ExperimentalAnimationApi
    val transition: Transition<EnterExitState>

    @ExperimentalAnimationApi
    fun Modifier.animateEnterExit(
        enter: EnterTransition = fadeIn() + expandIn(),
        exit: ExitTransition = fadeOut() + shrinkOut(),
        label: String = "animateEnterExit"
    ): Modifier = composed(
        inspectorInfo = debugInspectorInfo {
            name = "animateEnterExit"
            properties["enter"] = enter
            properties["exit"] = exit
            properties["label"] = label
        }
    ) {
        this.then(transition.createModifier(enter, exit, label))
    }
}

很简单,它是一个接口,唯一的域 transition,用以设置自定义进入和退出动画;接口类中还扩展了 Modifier,增加方法 animateEnterExit(),用于在该 scope 下添加子组件的动画。

transition 的实例类型为 EnterExitState,这是一个枚举,它标志了 AnimatedVisibility 进入到退出的三个状态。

enum class EnterExitState {
    // enter 动画开始前的初始状态
    PreEnter,

    // enter 动画结束后的状态,也是 exit 动画开始前的状态
    Visible,

    // exit 动画结束后的状态
    PostExit
}

好了,既然 AnimatedVisibilityScope 自带一个 Transition,那就可以据此写个例子来学习 Transition 及其 AnimatedVisibility() 扩展的使用并验证它所能达到的效果了。

实验

案例 1

直接在之前篇章中例子的基本上,增加 transition 的子动画。

@Composable
fun Greeting3(name: String) {
    Box {
        val state = MutableTransitionState(false)
        val stateDesc = remember { mutableStateOf<EnterExitState?>(null) }
        Button(onClick = { state.targetState = !state.targetState}) {
            Text(text = (if (state.isIdle) (if (state.currentState) "隐藏" else "显示") else "动画中") + " - ${stateDesc.value}")
        }
        AnimatedVisibility(visibleState = state, enter = fadeIn(tween(3_000)), exit = fadeOut(tween(3_000))) {
            Text(
                text = "Hello $name!", modifier = Modifier
                    .padding(top = 60.dp)
                    .size(100.dp)
                    .background(Color.Gray), textAlign = TextAlign.Center
            )
            // 获取当前 scope 下的 transition,并添加 AnimatedVisibility 组件
            transition.AnimatedVisibility(visible = {
                stateDesc.value = it
                // transition的目标状态为Visible时,显示此子组件
                it == EnterExitState.Visible
            }, enter = scaleIn(tween(500)), exit = scaleOut(tween(500))) {
                Text(
                    text = "Child", modifier = Modifier
                        .padding(top = 60.dp)
                        .size(50.dp)
                        .background(Color.Blue), textAlign = TextAlign.Center
                )
            }
        }
    }
}

还有几点需要注意一下:

  1. 为方便观察,动画都作了相应的延长
  2. Button 上添加了当前 transition 状态的显示,也就是 EnterExitState 枚举
  3. 界面启动时,组件默认隐藏
  4. 灰色背景的动画为「渐入+渐出」,时长 3 秒;蓝色子组件的动画是「放大+缩小」,时长 500 毫秒

来看看上面代码的效果吧:

启动时

device-2022-10-17-171243.gif

默认隐藏,所以动画组件未显示,这时候也无动画。

单击显示

device-2022-10-17-171312.gif

Button 上有两个信息:「动画中」,「Visible」。动画开始,目标是 Visible 状态,即 enter 动画播放完毕的状态。这时候的动画为:父子同时渐入

咦?按代码来讲,不应该是「背景渐入+子组件放大」吗?怎么回事?—— 后面来说。

动画完成后,Button 变为了「隐藏」。

单击隐藏

device-2022-10-17-172003.gif

再次点击,隐藏组件,于是启动了退出动画。Button 变为 「动画中」+「PostExit」,又开始动画了,目标状态为 PostExit,即 exit 动画播放完毕的状态。

这时候的动画:背景渐隐退出,同时子组件播放缩小动画,并先于背景消失 —— 符合预期。

回头来说,为什么显示的时候播不了子组件的「放大」动画呢?是不是时间太短导致没看到?改改时间呢:

// ...
AnimatedVisibility(visibleState = state, enter = fadeIn(tween(500)), exit = fadeOut(tween(500))) {
    transition.AnimatedVisibility(visible = {
        stateDesc.value = it
        it == EnterExitState.Visible
    }, enter = scaleIn(tween(3_000)) /*时间长于背景动画进入时间*/, exit = scaleOut(tween(3_000))) {
    // ...

device-2022-10-17-175407.gif

确实,背景短时、子组件长时后,子组件的「放大」动画就可以看到了。同时,退出时,因为背景组件为父,它短时间隐藏了,这时候子组件的动画还没完成,但也看不见了 —— 这说明,父和子的 transition 是相结合的。再改下时间,进入和退出就很清楚了:

// ...
AnimatedVisibility(visibleState = state, enter = fadeIn(tween(500)), exit = fadeOut(tween(3_000))) {
    transition.AnimatedVisibility(visible = {
        stateDesc.value = it
        it == EnterExitState.Visible
    }, enter = scaleIn(tween(3_000)), exit = scaleOut(tween(500))) {
    // ...

device-2022-10-17-180418.gif

案例 2

在案例 1 中,子组件显示与否的 visible lambda,用的是 it == EnterExitState.Visible —— 这相当于就是完全和父同步的。那修改成 it == EnterExitState.PostExit 会是怎样的效果呢?

在点击过程中,背景的 transition 状态将经历如下转换:

`PreEnter -> Visible -> PostExit -> Visible -> PostExit ...`

所以预期修改后状态是:子组件只在父消失退出的时候显示出来,其他情况均隐藏

实际效果:

device-2022-10-17-181649.gif

在父背景消失时,子组件确实在放大进入,但是同时,它也随着父的渐隐动画而消失了,这也和前面的「父子结合」结论一致。我们还是来看看官方注释是怎么说的:

This extension function creates an AnimatedVisibility composable as a child Transition of the given Transition. This means: 1) the enter/exit transition is now triggered by the provided Transition's targetState change. When the targetState changes, the visibility will be derived using the visible lambda and Transition.targetState. 2) The enter/exit transitions, as well as any custom enter/exit animations defined in AnimatedVisibility are now hoisted to the parent Transition. The parent Transition will wait for all of them to finish before it considers itself finished

关键有两点:

  1. 此动画受 transition 的 targetState 影响,当 targetState 变化时,它的显示与否visible lambda 函数和 targetState 决定
  2. 子动画提升至父层面,只有所有子动画完成后,父动画才认为是真正地完成。

第 1 点就解释了原因:子组件的显示,不仅受自身的 visible 控制,还由父的 targetState 决定。如果父正在渐隐退出,那子是显示不了的。

同时,第 2 点正好说明了 Button 上动画状态的问题:transition 的完成,依赖于所有自定义子动画的完成

小结

Transition<T>.AnimatedVisibility() 扩展的主要功能是:在现有 transition 基础上,增加自定义子动画。这里只做了一些简单的案例展示,目的在于说明接口的使用方法和相关特性,可能还有更多的、更切合实际需求的使用方式,留给大家自行探索吧。