Android Compose 动画使用详解(十二)AnimatedVisibility动画的使用

1,149 阅读12分钟

前言

本篇是 Android Compose 动画的第十二篇,本篇介绍一个新的动画组件 AnimatedVisibilityAnimatedVisibility是一个容器类的 Composable,作用是在组件的可见性状态之间添加动画效果。当组件从可见状态到不可见状态或从不可见状态到可见状态转换时,AnimatedVisibility 会自动为组件添加动画效果,使组件在状态之间平滑地过渡。

基础使用

前面说了 AnimatedVisibility是一个容器类的 Composable 组件,其内容就是我们要控制显示隐藏的 UI 组件,需接收一个 Boolean 类型的 visible参数用于控制 content 的显示隐藏。 使用代码如下:

var shown by remember { mutableStateOf(true) }

Box{
    AnimatedVisibility(visible = shown) {
        Box(
            Modifier
                .size(100.dp, 100.dp)
                .background(Color.Blue)
        )
    }
    Button(onClick = {
        // 改变显示隐藏状态
        shown = !shown
    }, Modifier.padding(top = 100.dp)) {
        Text(text = "Switch", style = TextStyle(fontSize = 10.sp))
    }
}

运行看一下效果:

121.gif

当我们通过点击按钮控制 shown 的 true/false 时,AnimatedVisibility 中包裹的控件就会出现显示/隐藏的过渡动画效果。

上面的 AnimatedVisibility 是包裹在 Box 中的,默认效果是从左上角渐变展开或渐变缩回去,如果是在 Column 或 Row 中使用则默认效果也会不一样,将上面的代码最外层的 Box 换成 Column 和 Row 分别看一下效果,修改后的代码如下:

var shown by remember { mutableStateOf(true) }

// AnimatedVisibility 放在 Column 中
Column{
    AnimatedVisibility(visible = shown) {
        Box(...)
    }
    Button(..)
}

// AnimatedVisibility 放在 Row 中
Row {
    AnimatedVisibility(visible = shown) {
        Box(...)
    }
    Button(...)
}

跟之前的代码唯一的改动就是将外面的 Box 分别换成了 Column 和 Row ,下面分别看一下运行效果:

122.gif

可以发现,在 Column 中由隐藏到现实的效果是从上方向下展开,在 Row 中的效果是从左边向右边展开。为什么使用同样的控件放在不同的容器中展现的效果不一样呢?我们继续向下探索,答案一会儿揭晓。

自定义动画

上面讲了 AnimatedVisibility 的基础使用,使用很简单,但是动画效果很单调,那么我们能不能自定义显示隐藏的动画效果呢?答案当然是可以的。

首先我们来看一下 AnimatedVisibility 的源码定义:

@Composable
fun AnimatedVisibility(
    visible: Boolean,
    modifier: Modifier = Modifier,
    enter: EnterTransition = fadeIn() + expandIn(),
    exit: ExitTransition = shrinkOut() + fadeOut(),
    label: String = "AnimatedVisibility",
    content: @Composable() AnimatedVisibilityScope.() -> Unit
)

总共有六个参数:

  • visible:用于控制内容是否可见
  • modifier:通用 Modifier ,用于修饰控件
  • enter:进入动画,即控件从隐藏到现实的动画
  • exit:退出动画,即控件从显示到隐藏的动画
  • label:动画标签,跟上一篇介绍的 Transition 的 label 作用一直,用于动画预览时起标识作用
  • content:内容,是一个函数类型参数,函数内需返回一个 Composable 的控件

重点就在 enterexit参数上,如果需要自定义动画效果就需要传入这两个参数,这两个参数都有默认值,所以我们不传时会有默认的显示隐藏动画效果,至于这里默认值的含义等我们详细了解了自定义动画后再回过头来看。

enterexit参数的类型分别是 EnterTransitionExitTransition,我们先来看 EnterTransition类型的源码定义:

sealed class EnterTransition {
    internal abstract val data: TransitionData
    
    @Stable
    operator fun plus(enter: EnterTransition): EnterTransition {
        return EnterTransitionImpl(...)
    }

    override fun toString(): String = ...

    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 是一个密封内,有一个 TransitionData类型的 data 属性和一个plus 的操作方法。既然是一个密封类,那肯定存在其子类,跟踪发现其子类只有一个 EnterTransitionImpl,其源码如下:

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

其实现就是传入 data ,所以关键就在这个 data 上,所以再往下看一下其类型 TransitionData的源码:

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

是一个数据类,有四个属性:

  • fade: 淡入淡出动画
  • slide:滑动入场、出场动画
  • changeSize:改变尺寸大小的入场、出场动画
  • scale:缩放方式的入场、出场动画

所以我们只需要分别设置对应的动画是不是就能实现自定义的动画效果了,那怎么使用呢,创建一个 EnterTransitionImpl 对象然后传入 TransitionData 么?下面这样:

AnimatedVisibility(visible = shown, enter = EnterTransitionImpl(TransitionData(...))){
    ...
}

实际上面这样写的话编辑器就报错了,通过上面的源码可以发现 EnterTransitionImpl是 private 的,TransitionData又是 internal 的,都不能直接在项目中使用,那应该怎么用呢?

Compose 对上面四种不同类型的动画分别提供了快捷的调用方法,如下所示:

类型入场动画出场动画
FadefadeIn()fadeOut()
SlideslideIn()
slideInHorizontally()
slideInVertically()
slideOut()
slideOutHorizontally()
slideOutVertically()
ChangeSizeexpandIn()
expandHorizontally()
expandVertically()
shrinkOut()
shrinkHorizontally()
shrinkVertically()
ScalescaleIn()scaleOut()

除了 ChangeSize 外,其他都是以名称 + In/Out 命令的方法,很好区分入场与出场动画的使用,同时 Slide 和 ChangeSize 还提供了对应横向(Horizontally)、竖向(Vertically)的快捷方法。 下面就详细介绍一下对应方法的使用。

fadeIn

看一下 fadeIn方法的源码:

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

有两个参数:

  • animationSpec:动画规格,FiniteAnimationSpec 类型,参考之前动画规格的介绍,即这里能使用除了 InfiniteRepeatableSpec无限循环动画规格之外的其他几个,如果对动画规格的配置不了解可以去看看本专栏关于动画规格的介绍
  • initialAlpha:初始透明度

返回的是 EnterTransition 类型,其实就是创建了一个 EnterTransitionImpl 传入 TransitionData ,跟我们上面的写法完全一样,只是我们不能直接调用。

使用示例:

var shown by remember { mutableStateOf(false) }

Box{
    AnimatedVisibility(
        visible = shown, 
        // 进入动画设置 fadeIn ,动画规格设置 tween 时长 1000ms,初始透明度 0.3f
        enter = fadeIn(animationSpec = tween(1000), initialAlpha = 0.3f)) {
        Box(...)
    }
    Button(onClick = {
        shown = !shown
    }, Modifier.padding(top = 100.dp)) {
        Text(text = "Switch", style = TextStyle(fontSize = 10.sp))
    }
}

运行一下看看效果:

123.gif

这样动画就只有一个初始透明度为 0.3 的淡入效果

slideIn

slideIn源码如下:

fun slideIn(
    animationSpec: FiniteAnimationSpec<IntOffset> =
        spring(
            stiffness = Spring.StiffnessMediumLow,
            visibilityThreshold = IntOffset.VisibilityThreshold
        ),
    initialOffset: (fullSize: IntSize) -> IntOffset,
): EnterTransition {
    return EnterTransitionImpl(TransitionData(slide = Slide(initialOffset, animationSpec)))
}

同样是两个参数,animationSpec 动画规格就不介绍了,跟上面作用一样,主要看一下 initialOffset即初始偏移,即动画从什么位置滑入,其类型 (fullSize: IntSize) -> IntOffset是一个函数类型参数,传入 fullSize 是 IntSize参数,返回 IntOffset,其中 fullSize即为控件的大小,所以可以根据控件大小来设置对应动画的偏移量,使用如下:

var shown by remember { mutableStateOf(false) }

Box(Modifier .padding(top = 100.dp, start = 100.dp)) {
    // 创建 slide 入场动画
    val enter = slideIn {
        // 偏移为 x 往左偏移控件的宽度,y 往上偏移控件的高度
        IntOffset(-it.width, -it.height)
    }
    AnimatedVisibility(visible = shown, enter = enter) {
        Box(
            Modifier
                // 使用动画值
                .size(100.dp, 100.dp)
                .background(Color.Blue)
        )
    }
    Button(onClick = {
        shown = !shown
    }, Modifier.padding(top = 100.dp)) {
        Text(text = "Switch", style = TextStyle(fontSize = 10.sp))
    }
}

运行效果:

124.gif

这样就实现了控件从左上角滑入的效果。

Slide 除了 slideIn 外还有 slideInHorizontally、slideInVertically,即横向滑入和竖向滑入,先看一下 slideInHorizontally 的源码:

fun slideInHorizontally(
    animationSpec: FiniteAnimationSpec<IntOffset> =
        spring(
            stiffness = Spring.StiffnessMediumLow,
            visibilityThreshold = IntOffset.VisibilityThreshold
        ),
    initialOffsetX: (fullWidth: Int) -> Int = { -it / 2 },
): EnterTransition =
    slideIn(
        initialOffset = { IntOffset(initialOffsetX(it.width), 0) },
        animationSpec = animationSpec
    )

与 slideIn 方法的区别是将 initialOffset 参数换成了 initialOffsetX,即 x 轴方向的偏移,默认值是向左偏移控件宽度的一半,其实现也是调用的 slideIn 方法,只是 y 方向的偏移传入了 0,这样就实现了横向的滑动动画。

举一反三,slideInVertically 竖向滑动动画其实就是把 x 方向的偏移设置了 0,如下:

fun slideInVertically(
    animationSpec: FiniteAnimationSpec<IntOffset> =
        spring(
            stiffness = Spring.StiffnessMediumLow,
            visibilityThreshold = IntOffset.VisibilityThreshold
        ),
    initialOffsetY: (fullHeight: Int) -> Int = { -it / 2 },
): EnterTransition =
    slideIn(
        initialOffset = { IntOffset(0, initialOffsetY(it.height)) },
        animationSpec = animationSpec
    )

实际上 slideInHorizontally、slideInVertically 就是 slideIn 方法的二次封装。

expandIn

expandIn 的源码:

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

方法有四个参数,参数说明如下:

  • animationSpec:动画规格
  • expandFrom:从哪里开始展开,Alignment 类型,默认为 Alignment.BottomEnd 即右下角
  • clip:是否裁剪,默认为 true 即会裁剪
  • initialSize:初始大小,函数类型,传入参数 fullSize 为控件大小

使用示例:

var shown by remember { mutableStateOf(false) }
Box{
    val enter = expandIn(
        // 动画规格
        animationSpec = tween(1000),
        // 设置从哪里开始展开
        expandFrom = Alignment.BottomEnd,
        // 是否裁剪
        clip = true){
        // 动画初始大小
        IntSize(it.width/3, it.height/3)
    }
    AnimatedVisibility(visible = shown, enter = enter) {
        Box(
            Modifier
                .size(100.dp, 100.dp)
                .background(Color.Blue)
        )
    }
    Button(onClick = {
        shown = !shown
    }, Modifier.padding(top = 100.dp)) {
        Text(text = "Switch", style = TextStyle(fontSize = 10.sp))
    }
}

运行效果:

125.gif

咦,不对啊,expandFrom 不是设置的 Alignment.BottomEnd 么?不是应该从右下角展开么,怎么动画效果是从左上角展开的呢?

这是因为 expandIn 本质是 changeSize,即改变控件的大小,且是以裁剪的方式,实际上是先展示右下角,然后慢慢变大,但上面的动画的方块颜色只有一种,所以给我们的视觉效果是从左上角展开的。我们将其内部添加不同的颜色块再看看效果:

126.gif

这样就能很明显的看到动画是从右下角展开的。

上面的代码 clip 我们设置的 true,即裁剪,如果设置为 false 不裁剪又是什么效果呢,将其修改为 false 运行看看效果:

127.gif

可以发现,动画开始时控件就完全显示了,然后慢慢移动到了目标位置,如果以控件右下角为参考点跟上面裁剪的动画进行比较发现动画的路径是一样的,只是整体一个是从小变大而另一个是不变的,这就是不裁剪的效果。

除了 expandIn 外,还有 expandHorizontally、expandVertically 方法,他们实际上也是对 expandIn 方法的二次封装,前者将动画初始的高度设置为控件大小即实现横向的扩展,后者将动画初始的宽度设置为控件大小即实现竖向的扩展,以 expandHorizontally 为例看一下源码的实现:

fun expandHorizontally(
    animationSpec: FiniteAnimationSpec<IntSize> =
        spring(
            stiffness = Spring.StiffnessMediumLow,
            visibilityThreshold = IntSize.VisibilityThreshold
        ),
    expandFrom: Alignment.Horizontal = Alignment.End,
    clip: Boolean = true,
    // 只需要设置初始宽度
    initialWidth: (fullWidth: Int) -> Int = { 0 },
): EnterTransition {
    // 调用 expandIn 方法
    return expandIn(animationSpec, expandFrom.toAlignment(), clip = clip) {
        // 初始宽度使用传入的 initialWidth ,初始高度为控件高度
        IntSize(initialWidth(it.width), it.height)
    }
}

scaleIn

同样先来看一下 scaleIn 的源码:

fun scaleIn(
    animationSpec: FiniteAnimationSpec<Float> = spring(stiffness = Spring.StiffnessMediumLow),
    initialScale: Float = 0f,
    transformOrigin: TransformOrigin = TransformOrigin.Center,
): EnterTransition {
    return EnterTransitionImpl(
        TransitionData(scale = Scale(initialScale, transformOrigin, animationSpec))
    )
}

有三个参数:

  • animationSpec:动画规格
  • initialScale:初始缩放
  • transformOrigin:缩放的原点,默认为 TransformOrigin.Center 即控件的中心点

使用示例:

var shown by remember { mutableStateOf(false) }

Box{
    val enter = scaleIn(
        animationSpec = tween(1000), 
        initialScale = 0.3f, 
        transformOrigin = TransformOrigin.Center)
    AnimatedVisibility(visible = shown, enter = enter) {
        Box(
            Modifier
                // 使用动画值
                .size(100.dp, 100.dp)
                .background(Color.Blue)
        )
    }
    Button(onClick = {
        shown = !shown
    }, Modifier.padding(top = 100.dp)) {
        Text(text = "Switch", style = TextStyle(fontSize = 10.sp))
    }
}

运行效果:

128.gif

因为我们 transformOrigin 设置的 TransformOrigin.Center所以缩放是从中心开始变大的,如果要设置从右下角放大该怎么设置呢?是不是应该设置 TransformOrigin.BottomEnd 啊?但实际并没有这个值,我们来看一下 TransformOrigin.Center的定义:

val Center = TransformOrigin(0.5f, 0.5f)

其实 TransformOrigin.Center就是创建了一个 TransformOrigin对象,分别传入了 0.5f,实际就是宽高的一半,如果要设置到右下角,只需传入 TransformOrigin(1.0f, 1.0f)即可,如下:

val enter = scaleIn(
    animationSpec = tween(1000),
    initialScale = 0.3f,
    // 缩放原点设置到右下角
    transformOrigin = TransformOrigin(1.0f, 1.0f))

运行效果:

129.gif

出场动画的使用和参数与入场动画一致,学会了入场动画的使用相信你对出场动画的使用一定没有问题😏

组合

前面介绍的都是单个动画的效果,如果我即想有淡入动画又想有展开动画呢,应该怎么设置呢?我们再回到最开始的 AnimatedVisibility的定义:

@Composable
fun AnimatedVisibility(
    visible: Boolean,
    modifier: Modifier = Modifier,
    enter: EnterTransition = fadeIn() + expandIn(),
    exit: ExitTransition = shrinkOut() + fadeOut(),
    label: String = "AnimatedVisibility",
    content: @Composable() AnimatedVisibilityScope.() -> Unit
)

发现不管是入场动画还是出场动画默认设置有两个动画且是以 + 连接,按住ctrl + '+' 点进去看看源码 :

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

实际上就是我们之前看到的 EnterTransition/ExitTransition中的 plus 操作方法,通过分析源码,其实现是判断当前对象('+' 左边的)的对应动画是否存在,存在就用当前对象的,不存在就使用传入对象('+' 右边的)的。 所以默认的入场动画效果为淡入和展开的组合动画。

通过这种方式就将多个动画组合起来了,如果我们四个动画都设置则可以如下这么写:

AnimatedVisibility(
    visible = shown, 
    enter = fadeIn()+ slideIn(initialOffset = { IntOffset(it.width, it.height) }) + expandIn() + scaleIn()
)

再回到最开始的疑问,为什么在 Box 、Column、Row 中使用 AnimatedVisibility但是效果却是不一样的呢?

我们分别从 Box、Column、Row 中的AnimatedVisibility点进去看看源码,Box 里的我们上面已经看过了,看看其他两个的源码:

fun ColumnScope.AnimatedVisibility(
    visible: Boolean,
    modifier: Modifier = Modifier,
    enter: EnterTransition = fadeIn() + expandVertically(),
    exit: ExitTransition = fadeOut() + shrinkVertically(),
    label: String = "AnimatedVisibility",
    content: @Composable AnimatedVisibilityScope.() -> Unit
) {
    val transition = updateTransition(visible, label)
    AnimatedEnterExitImpl(transition, { it }, modifier, enter, exit, content)
}

fun RowScope.AnimatedVisibility(
    visible: Boolean,
    modifier: Modifier = Modifier,
    enter: EnterTransition = fadeIn() + expandHorizontally(),
    exit: ExitTransition = fadeOut() + shrinkHorizontally(),
    label: String = "AnimatedVisibility",
    content: @Composable() AnimatedVisibilityScope.() -> Unit
) {
    val transition = updateTransition(visible, label)
    AnimatedEnterExitImpl(transition, { it }, modifier, enter, exit, content)
}

发现虽然都叫 AnimatedVisibility但实际并不是同一个方法,Column 里的是 ColumnScope.AnimatedVisibility,Row 中的 RowScope.AnimatedVisibility,即分别是 ColumnScopeRowScope的扩展,而 Column、Row 的 content 参数是分别基于 ColumnScopeRowScope调用的,如下:

inline fun Column(
    modifier: Modifier = Modifier,
    verticalArrangement: Arrangement.Vertical = Arrangement.Top,
    horizontalAlignment: Alignment.Horizontal = Alignment.Start,
    content: @Composable ColumnScope.() -> Unit
)

inline fun Row(
    modifier: Modifier = Modifier,
    horizontalArrangement: Arrangement.Horizontal = Arrangement.Start,
    verticalAlignment: Alignment.Vertical = Alignment.Top,
    content: @Composable RowScope.() -> Unit
) 

所以在 Column、Row 中使用 AnimatedVisibility 时会分别调用到对应的ColumnScopeRowScope扩展的 AnimatedVisibility 方法,而 Compose 对其动画分别进行了单独设置,比如 Column 中的 enter 动画: fadeIn() + expandVertically()即淡入和竖向展开。这样就实现了不同的容器中使用默认的效果不一样。

最后

本篇详细介绍了 Android Compose 动画的 AnimatedVisibility 组件的使用,通过源码解析详细介绍了 AnimatedVisibility 各个 API 的详细使用及简单的实现原理。实际上 AnimatedVisibility 还有一些不常用的 API 使用方式,由于篇幅原因将在后续文章进行补充说明,如果你对更多的 Compose 动画使用感兴趣,请持续关注本专栏。

本篇文章的源码地址:ComposeAnimationDemo

本文正在参加「金石计划」