控制内容的显隐:AnimatedVisibility

410 阅读9分钟

前言

AnimatedVisibility Composable 函数可以让界面中的元素以动画的形式出现和消失。而不是瞬间就消失,然后猛地又出现。

从 if 到 AnimatedVisibility

我们先从一个简单的需求开始:如果我要让一个组件从界面消失,然后又出现在界面,该怎么办?

很简单,我们都写过,把组件放到 if 的代码块中就行了,像这样:

var visible by remember { mutableStateOf(true) }

Box(Modifier.fillMaxSize()){
    Column(Modifier.align(Alignment.Center)) {
        if (visible) {
            Box(
                modifier = Modifier
                    .size(100.dp)
                    .background(Color.Green)
            )
        }

        Button(onClick = { visible = !visible }) {
            Text(if (visible)"点击消失" else "点击出现")
        }
    }
}

运行效果:

image.gif

完成了,但元素出现和消失是瞬间完成的,没有任何的过渡,用户看起来非常累(不信?你再多看几遍)。

那怎么能让这个过程变为动画的呢?

也很简单,把 if 换成 AnimatedVisibility,其它都不用改。

var visible by remember { mutableStateOf(true) }

Box(Modifier.fillMaxSize()){
    Column(Modifier.align(Alignment.Center)) {
        AnimatedVisibility (visible) {
            Box(
                modifier = Modifier
                    .size(100.dp)
                    .background(Color.Green)
            )
        }

        Button(onClick = { visible = !visible }) {
            Text(if (visible)"点击消失" else "点击出现")
        }
    }
}

运行效果:

image.gif

这样 Box 组件的出现和消失就有动画效果了,并且我们还发现动画还是垂直方向的展开、收起效果。正好对应了我们的 Column 布局,这难道是一个巧合?

我们点进去 AnimatedVisibility 的源码看看:

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

发现这个函数是 ColumnScope 作用域的扩展函数,简单来说,这个函数只有在 Column 布局里才能使用。并且我们看入场和出场动画的配置(enterexit参数),默认值包含 Vertically (纵向)的字样。

这么一结合,我们就知道了:这是 Compose 专门针对 Column 布局所做的纵向的入场和出场动画效果。

ColumnScope.AnimatedVisibility 类似的扩展函数还有 RowScope.AnimatedVisibility,它则是针对 Row 布局所做的横向的入场和出场动画效果。

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

如果不在以上两个布局中使用 AnimatedVisibility,就会调用通用的 AnimatedVisibility,不带任何前缀:

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

它的入场和出场效果就是通用的。

定制动画:enter 与 exit

如果要定制我们想要的入场和出场动画效果,就需要通过 AnimatedVisibility 函数的 enterexit 参数来控制。这两个参数的类型分别是 EnterTransitionExitTransition

我们先来看看控制入场动画的参数 enter 的类型 EnterTransition

@Immutable
public sealed class EnterTransition

发现是一个密封类,再去看它的唯一的子类 EnterTransitionImpl

@Immutable private class EnterTransitionImpl(override val data: 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, // 缩放
    val hold: Boolean = false,
    val effectsMap: Map<TransitionEffectKey<*>, TransitionEffect> = emptyMap()
)

它就是使用这几个属性来配置的动画的入场动画效果的。我们在填写 enter 参数时,其实就在创建并配置 TransitionData 对象的属性值,来决定动画效果。

出场动画效果(exit参数)也是类似的配置

但我们通常不会也不能创建 TransitionData 对象来配置动画效果,因为 TransitionDatainternal 的。而是调用 Compose 提供的一系列函数来定义动画效果,动画效果主要有以下四个维度。

淡入淡出 (Fade)

淡入淡出的效果通过改变透明度来实现。

// 定义淡入效果
@Stable
public fun fadeIn(
    animationSpec: FiniteAnimationSpec<Float> = spring(stiffness = Spring.StiffnessMediumLow),
    initialAlpha: Float = 0f
)

我们可以对这种动画效果进行一个定制:

  1. 指定初始的透明度(initialAlpha),默认值是 0f,也就是淡入时以完全透明开始。

  2. 指定动画曲线(animationSpec),默认是 spring(stiffness = Spring.StiffnessMediumLow) 弹跳效果。

// 定义淡出效果
@Stable
public fun fadeOut(
    animationSpec: FiniteAnimationSpec<Float> = spring(stiffness = Spring.StiffnessMediumLow),
    targetAlpha: Float = 0f,
)

淡出效果的定制是类似的,只是 targetAlpha 参数指定的是目标的透明度,默认值是 0f,表示淡出时以完全透明结束。

AnimatedVisibility(
    visible = visible,
    enter = fadeIn(
        animationSpec = tween(durationMillis = 2000),
        initialAlpha = 0.3f
    ),
    exit = fadeOut(
        animationSpec = spring(stiffness = Spring.StiffnessVeryLow),
        targetAlpha = 0.1f
    )
) {
    Box(
        modifier = Modifier
            .size(100.dp)
            .background(Color.Green)
    )
}

运行效果:

image.gif

前面我们说调用的这些函数其实是在配置 TransitionData 对象内部的属性值,现在来验证一下,看一下 fadeIn 函数的实现:

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

它内部确实有创建 EnterTransitionImpl 对象,其参数 TransitionData 中指定了 fade 属性值。

滑动 (Slide)

滑动的效果是通过改变位置实现的。

// 定义滑入效果
@Stable
public fun slideIn(
    animationSpec: FiniteAnimationSpec<IntOffset> =
        spring(
            stiffness = Spring.StiffnessMediumLow,
            visibilityThreshold = IntOffset.VisibilityThreshold
        ),
    initialOffset: (fullSize: IntSize) -> IntOffset,
)

initialOffset 参数是一个 lambda 表达式,没有默认值,它要我们返回初始的偏移量,滑入时会从提供的这个初始的位置慢慢移到组件本身应该到的位置;lambda 表达式的参数 fullSize 提供了组件的尺寸,方便我们基于组件的尺寸进行偏移计算。

// 定义滑出效果
@Stable
public fun slideOut(
    animationSpec: FiniteAnimationSpec<IntOffset> =
        spring(
            stiffness = Spring.StiffnessMediumLow,
            visibilityThreshold = IntOffset.VisibilityThreshold
        ),
    targetOffset: (fullSize: IntSize) -> IntOffset,
)

类似地,targetOffset 定义的是滑出动画的目标偏移量,从组件本身应该在的位置慢慢移到目标位置,然后消失。

并且 Compose 提供了便捷的函数,来提供水平、垂直的滑入滑出:slideInHorizontallyslideOutHorizontallyslideInVerticallyslideOutVertically

其中这些函数的 lambda 表达式提供的参数值不是组件的尺寸,而是组件的宽或高。

AnimatedVisibility(
    visible = visible,
    enter = slideInHorizontally(
        animationSpec = tween(durationMillis = 2000),
        initialOffsetX = { width ->
            -width / 2
        }
    ), // 从左侧一半宽度处滑入
    exit = slideOutVertically(
        animationSpec = tween(durationMillis = 2000),
        targetOffsetY = { fullHeight ->
            fullHeight
        }
    )  // 向下滑出整个高度
) {
    Box(
        modifier = Modifier
            .size(100.dp)
            .background(Color.Green)
    )
}

运行效果:

image.gif

改变尺寸 (Change Size)

它是通过改变组件的可见尺寸实现的,本质上是对组件做裁切。

// 定义展开进入的效果
@Stable
public 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) },
)

expandFrom 参数是定义展开的起始位置,如 Alignment.TopStart, Alignment.Center 等。

initialSize 参数指定的是初始尺寸,它的 fullSize 参数提供了组件的尺寸,让我们可以基于组件的尺寸来设置初始尺寸。

// 定义收缩退出的效果
@Stable
public fun shrinkOut(
    animationSpec: FiniteAnimationSpec<IntSize> =
        spring(
            stiffness = Spring.StiffnessMediumLow,
            visibilityThreshold = IntSize.VisibilityThreshold
        ),
    shrinkTowards: Alignment = Alignment.BottomEnd,
    clip: Boolean = true,
    targetSize: (fullSize: IntSize) -> IntSize = { IntSize(0, 0) },
)

收缩效果也差不多,只是 targetSize 参数指定的是目标尺寸。

AnimatedVisibility(
    visible = visible,
    enter = expandIn(
        expandFrom = Alignment.BottomEnd, // 右下角开始展开
        animationSpec = tween(durationMillis = 2000),
        initialSize = { IntSize(10, 10) }
    ),
    exit = shrinkOut(
        shrinkTowards = Alignment.TopStart, // 左上角开始收缩
        animationSpec = tween(durationMillis = 2000),
        targetSize = { IntSize(0, 0) }
    )
) {
    Box(
        modifier = Modifier
            .size(100.dp)
            .background(Color.Green)
    )
}

运行效果:

image.gif

你会发现你怎么调整展开/收缩的位置,好像动画效果都差不多,其实这是因为我们在对一个纯色方块做动画效果,你分不清它的东南西北,所以你只需用一张图片,对它来做动画效果就行了。

还是刚刚的代码,我换了一张图片:

image.gif

最后一个参数 clip,它非常重要,它表示是否对内容进行裁切。

默认为 true。如果为 false,则组件的显示内容不会被裁切,会完整显示。仅仅是其占用的布局空间会按照动画的效果进行变化,所以其他元素可能会在早期覆盖组件。

AnimatedVisibility(
    visible = visible,
    enter = expandIn(
        clip = false,
        expandFrom = Alignment.TopStart, // 左上角开始展开
        animationSpec = tween(durationMillis = 2000),
        initialSize = { IntSize(10, 10) }
    ),
    exit = shrinkOut(
        shrinkTowards = Alignment.BottomEnd, // 右下角开始收缩
        animationSpec = tween(durationMillis = 2000),
        targetSize = { IntSize(0, 0) }
    )
) {
    Image(
        painter = painterResource(id = R.drawable.girl),
        contentDescription = null,
        modifier = Modifier.size(100.dp)
    )
}
image.gif

Compose 也提供了便捷的函数:expandHorizontally()shrinkHorizontally()expandVertically()shrinkVertically()

我们可以方便地完成纵向和横向的展开/收缩动画。

缩放 (Scale)

通过改变组件的尺寸比例实现的。

// 定义放缩进入的效果
@Stable
public fun scaleIn(
    animationSpec: FiniteAnimationSpec<Float> = spring(stiffness = Spring.StiffnessMediumLow),
    initialScale: Float = 0f,
    transformOrigin: TransformOrigin = TransformOrigin.Center,
)

initialScale 参数表示初始的缩放比例,默认值为 0f。

transformOrigin 是缩放的原点/中心,从哪里开始缩放,默认值是中心点 TransformOrigin.Center

// 定义缩放离开的效果
@Stable
public fun scaleOut(
    animationSpec: FiniteAnimationSpec<Float> = spring(stiffness = Spring.StiffnessMediumLow),
    targetScale: Float = 0f,
    transformOrigin: TransformOrigin = TransformOrigin.Center
)

定义缩放退出的效果也是类似的配置。

特别注意:缩放改变的只是组件的显示效果,并不会改变所占用的布局空间,所以在放大时,很有可能会与其他组件重叠。如果是用改变尺寸的效果,就会将其他组件“推开”。

AnimatedVisibility(
    visible = visible,
    enter = scaleIn(
        initialScale = 0.5f,
        transformOrigin = TransformOrigin(0f, 0f)
    ) , // 从左上角开始,50%大小缩放进入
    exit = scaleOut(
        targetScale = 0f
    )
) {
    Image(
        painter = painterResource(id = R.drawable.girl),
        contentDescription = null,
        modifier = Modifier.size(100.dp)
    )
}
image.gif

以上就是这四个维度的动画效果的配置。

组合动画效果

我们来看看为什么 + 操作符可以组合多个动画效果。

进入看看源码:

@Stable
public operator fun plus(exit: ExitTransition): ExitTransition {
    return ExitTransitionImpl(
        TransitionData(
            fade = exit.data.fade ?: data.fade,
            slide = exit.data.slide ?: data.slide,
            changeSize = exit.data.changeSize ?: data.changeSize,
            scale = exit.data.scale ?: data.scale,
            hold = exit.data.hold || data.hold,
            // `exit` after plus operator to prioritize its values on the map
            effectsMap = data.effectsMap + exit.data.effectsMap
        )
    )
}

可以发现,当组合多个动画效果时,会进行属性的合并。动画的属性值会优先选取 + 操作符右侧的 EnterTransition 对象的属性:

如果右侧的 EnterTransition 对象的某个属性(比如 fade)不为空,就会使用右侧的该属性值;否则,才使用左侧 EnterTransition 对象的属性值。

比如我们这么填 enter 参数:

enter = fadeIn(animationSpec = spring(), initialAlpha = 0.3f)
        + fadeIn(animationSpec = tween(durationMillis = 1000), initialAlpha = 0.2f)

最终的效果,应该是第二个 fadeIn 函数产生的效果:initialAlpha0.2fanimationSpectween(durationMillis = 1000)

Transition.AnimatedVisibility

除了以上三种 AnimatedVisibility,还有一个 Transition 的扩展函数版本。

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

与上述的唯一区别就是,visible 参数是一个 lambda 表达式,表达式会传入 Transition 对象当前的目标状态值,我们根据该状态返回一个布尔值,来决定是否显示内容。

使用原则

最后,使用 AnimatedVisibility 有一个重要原则:

你只能往它内部填入一个 Composable 函数,这是因为它就没有设计控制多个 Composable 的显隐。如果要填多个 Composable 函数,可能会导致布局效果不可预料。

如果需要让多个组件同时具有出现和消失的动画,并且要有一定的布局(例如,在 Column 中垂直排列),你应该为每一个需要的组件包裹一层 AnimatedVisibility

Column {
    AnimatedVisibility(visible = showFirst) {
        Text("First Item")
    }
    AnimatedVisibility(visible = showSecond) {
        Text("Second Item")
    }
}

而不是都挤在一块:

Column {
    AnimatedVisibility(visible = show) {
        Text("First Item")
        Text("Second Item")
    }
}

当然你可以把多个组件在 AnimatedVisibility 的内部,套上布局:

AnimatedVisibility(visible = show) {
    Column {
        Text("First Item")
        Text("Second Item")
    }
}