Compose动画:AnimatedVisibility (3)

165 阅读3分钟

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

到目前为止,AnimatedVisibility 的使用及其特性,相信已经很清楚了吧?不清楚的,再找找之前的几篇看看,也可以留言,咱一起讨论讨论。

今天来谈谈 AnimatedVisibilityTransition。你可能会问,怎么还是 Transition?之前不是讲了吗?确实,上篇我们学习 Transition.AnimatedVisibility 扩展时,已经搞清楚了 Transition,不过呢,今天讲的是另一个东西: EnterTransitionExitTransition。在讲 AnimatedVisibility 时,一直没有深入地分析这二位,但是它们却是最重要的,因为这就是动画本尊啊!

EnterTransition

EnterTransition 是一个密封类,源码如下,很简单。

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

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

    override fun equals(other: Any?): Boolean {
        return other is EnterTransition && other.data == data
    }

    override fun hashCode(): Int = data.hashCode()

    companion object {
        val None: EnterTransition = EnterTransitionImpl(TransitionData())
    }
}

可以看到,EnterTransition 重载了加法运算符,这就是各个动画的结合可以使用「+」的原因(比如默认的 enter 动画是 fadeIn() + expandIn())。

从功能上来讲,EnterTransition 用于定义 AnimatedVisibility 类型的组件在变为 visible 时使用的动画。动画共 4 类,包括:

  • 渐变:fadeIn(渐入)
  • 缩放:scaleIn(放大)
  • 划动:slideIn(划入), slideInHorizontally(横向划入), slideInVertically(纵向划入)
  • 扩展:expandIn(右下扩展入), expandHorizontally(右扩展入), expandVertically(下扩展入)

TransitionData

从前面源码看到,EnterTransition 内部有一个抽象域 data 待实现, 为TransitionData 类型,该类型是一个纯数据类。

@Immutable
internal data class TransitionData(
    val fade: Fade? = null,
    val slide: Slide? = null,
    val changeSize: ChangeSize? = null,
    val scale: Scale? = null
)

数据类包括四个数据域,是和四类动画对应的,用于存储动画参数。比如说,默认动画里用到的 fadeIn(),是这样实现的:

@Stable
fun fadeIn(
    animationSpec: FiniteAnimationSpec<Float> = spring(stiffness = Spring.StiffnessMediumLow),
    initialAlpha: Float = 0f
): EnterTransition {
    return EnterTransitionImpl(TransitionData(fade = Fade(initialAlpha, animationSpec)))
}

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

方法返回 EnterTransition 类型,使用 EnterTransitionImpl 构造实例,传入的参数是 Fade 对象,也就是渐变,因为初始 alpha 是 0,所以就是「渐入」动画了。

其它诸如 fadeOut()slideIn() 等,都是类似的,加入需要关注的参数来构造 TransitionData 对象,从而得到动画数据

我们再来看看动画结合的实现:

    // ...
    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
            )
        )

实际上,就是数据的选择:针对特定参数域,如果当前的值为 null,那就使用新加 data 里面的值,否则保持原样。所以说,已存在的特定类型的动画,无法通过叠加进行覆盖,动画叠加是作用于不同类型的动画的。比如,我们定义一个 enter 动画 anim1,「扩展+渐入」

val anim1 = remember { expandIn(tween(3_000)) + fadeIn(tween(3_000)) }
AnimatedVisibility(visible.value, enter = anim1, exit = shrinkOut(tween(3_000), Alignment.Center)) {
    Text(text = "Hello${name}Hello${name}Hello${name}", modifier = Modifier
        .width(width = 100.dp)
        .background(Color.Gray), textAlign = TextAlign.Center)
}

device-2022-10-21-105943.gif

我们再用另一个动画来叠加更改:

val anim1 = remember { expandIn(tween(3_000)) + fadeIn(tween(3_000)) }
// anim1 基础上,添加动画
val anim2 = remember { anim1 + expandIn() + scaleIn(tween(3_000)) }
AnimatedVisibility(visible.value, enter = anim2, exit = shrinkOut(tween(3_000), Alignment.Center)) {
// ...

anim2 在 anim1 基础上,又添加了一个「扩展」,只不过,是默认的效果,另外又加了一个「放大」。按照源码的逻辑,新的默认「扩展」是无法生效的,因为 anim1 本来就有了一个,而最终的动画将是「anim1 + scaleIn」。

device-2022-10-21-111424-override1.gif

效果确实如此。不过呢,调整一下顺序,也能实现「扩展」覆盖。如下:

val anim1 = remember { expandIn(tween(3_000)) + fadeIn(tween(3_000)) }
val anim2 = remember { expandIn() /*expandIn提前*/ + anim1 + scaleIn(tween(3_000)) }
AnimatedVisibility(visible.value, enter = anim2, exit = shrinkOut(tween(3_000), Alignment.Center)) {
// ...

device-2022-10-21-113122.gif

想想为什么?

动画类型及其控制

四类动画包括了渐变缩放划动扩展,分别对应的类型为:Fade, Scale, SlideChangeSize。来看看 Fade

@Immutable
internal data class Fade(val alpha: Float, val animationSpec: FiniteAnimationSpec<Float>)

参数 alpha 存储渐变的初始值,而参数 animationSpec 是「有限动画控制参数」,为 FiniteAnimationSpec 类型。默认的 fadeIn(),使用了 spring() ,得到了一个「跳跃类型」。

其它几个均是类似的实现方式,很简单,这里不再赘述。

But!有一点需要提一下,就是关于动画时长的问题。在之前的文章里,为了方便观察动画效果,我们曾经重新设置了 animationSpec 参数,使用的是 tween() 方法(补间)。之所以改用「补间动画」,是因为 spring 是不包含时间控制的。tween() 获取一个 TweenSpec 对象,即「补间参数」。类似的,还有 KeyframesSpecSnapSpec,它们均是 DurationBasedAnimationSpec 的实现类,可以控制动画时间或动画启动延时。

ExitTransition

ExitTransitionEnterTransition 的实现,几乎是一样的。所以,略掉。

// 和 EnterTransition 的实现几乎一样
@Immutable
sealed class ExitTransition {
    internal abstract val data: TransitionData

    @Stable
    operator fun plus(exit: ExitTransition): ExitTransition {
        return ExitTransitionImpl(
            TransitionData(
                fade = data.fade ?: exit.data.fade,
                slide = data.slide ?: exit.data.slide,
                changeSize = data.changeSize ?: exit.data.changeSize,
                scale = data.scale ?: exit.data.scale
            )
        )
    }

    override fun equals(other: Any?): Boolean {
        return other is ExitTransition && other.data == data
    }

    override fun hashCode(): Int = data.hashCode()

    companion object {
        val None: ExitTransition = ExitTransitionImpl(TransitionData())
    }
}

「扩展」动画

单独再来讨论一下「扩展」动画,即 expandIn()expandOut() 得到的类型。因为本人在理解它的效果的时候,就出现了很懵比不理解的状态,相信我不是唯一的吧?

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

首先,动画数据实际是由 ChangeSize 给出:

@Immutable
internal data class ChangeSize(
    val alignment: Alignment,
    val size: (fullSize: IntSize) -> IntSize = { IntSize(0, 0) },
    val animationSpec: FiniteAnimationSpec<IntSize> =
        spring(visibilityThreshold = IntSize.VisibilityThreshold),
    val clip: Boolean = true
)

而构造一个 ChangeSize 所需要的参数,直接来自于 expandIn 方法:

  • expandFrom: 扩展边界的启动点,默认为 Alignment.BottomEnd(右下)
  • initialSize: 初始尺寸
  • animationSpec: 动画参数,默认为 spring
  • clip: 动画边界外的内容是否剪切,默认 true

第一个参数是最难理解的。再来看看之前的例子:

@Composable
fun Greeting(name: String) {
    val visible = remember { mutableStateOf(false) }
    Column(Modifier.padding(start = 100.dp, top = 100.dp)) /*设置一个距离,方便观察*/ {
        Button(onClick = { visible.value = !visible.value }) {
            Text(text = if (visible.value) "隐藏" else "显示")
        }
        // 仅使用扩展动画,并调长动画时间
        AnimatedVisibility(visible.value, enter = expandIn(tween(3_000)), exit = shrinkOut(tween(3_000))) {
            Text(text = "Hello${name}Hello${name}Hello${name}", modifier = Modifier
                .width(width = 100.dp)
                .background(Color.Gray), textAlign = TextAlign.Center)
        }
    }
}

device-2022-10-20-175921.gif

组件从左上角开始,朝着右下角方向「移入」。退出时,反之。那么,这和「从右下角开始扩展」是什么关联?

我们把 expandFrom 参数改成 Alignment.TopStart 试试:

device-2022-10-20-180654.gif

效果变了:组件在放大,左上角最先显示 —— 这下就知道为什么前面的移入要加引号吧,因为其实它根本不是移入,它只是在扩展放大而已,因为它是「从内容右下角开始扩展」,所以只是看起来像是在移入!

现在,再给退出动画也修改一下启动点,设成 Alignment.Center,对于退出来讲,这个参数的意思是指:缩小边界的终点 —— 这里就是「往中间缩小」。

device-2022-10-21-095125.gif

观察下最后的还在显示的区域,就明白意思了。

另外值得注意的是,ChangeSize 的放大缩小,是组件的大小,内容未受影响;而 Scale 的放大缩小,是实际的整个组件的等比例缩放。

小结

至此,AnimatedVisibility 涉及到的相关参数以及部分实现细节(例如 scope、transition 这些),我们已经掌握,相信也已经具备在适当场景下正确使用的能力。

有什么问题和疑问,欢迎留言讨论。下篇再见!