本篇是动画的最后一篇,我们先介绍 Transition API,然后再介绍与 Transition 相关的三个上层 API:AnimatedVisibility()、Crossfade()、AnimatedContent()。
1、Transition
Transition API 是用于管理多属性复杂状态过渡动画的核心工具,它允许开发者将多个动画属性绑定到一个状态变化事件中,实现同步、协调的动画效果。
一提到 Transition,熟悉原生开发的同学很容易想到负责 Activity 与 Fragment 转场动画的 Transition。原生的 Transition 只能用于 Activity 与 Fragment 转场动画,不能用在 View 上。而 Compose 的 Transition API 是用于各个 UI 组件的转场动画,二者的适用对象是完全不同的。
实际上前面我们已经讲过一种 Compose 的转场动画:animate*AsState()
,在实现一些简单的动画时,使用 animate*AsState()
甚至更简单一些:
@Composable
fun TransitionSample() {
var big by remember { mutableStateOf(false) }
val size by animateDpAsState(if (big) 96.dp else 48.dp)
Box(modifier = Modifier
.size(size)
.background(Color.Green)
.clickable { big = !big }
)
}
让 Transition 实现点击 Box 变换其尺寸的效果:
@Composable
fun TransitionSample() {
var big by remember { mutableStateOf(false) }
// val size by animateDpAsState(if (big) 96.dp else 48.dp)
val bigTransition = updateTransition(targetState = big)
val size by bigTransition.animateDp { // it 是 state 对象,也就是 Boolean
if (it) 96.dp else 48.dp
}
Box(modifier = Modifier
.size(size)
.background(Color.Green)
.clickable { big = !big }
)
}
容易看出,Transition 实现同样的动画效果要比使用 animateDpAsState() 多出一步,需要先创建一个 Transition 对象,然后调用相应的函数创建动画。
既然如此,为什么还要提供 Transition API 呢?我们带着这个问题来学习 Transition。
1.1 Transition 的创建与使用
上面的示例使用 updateTransition() 创建 Transition 对象:
@Composable
fun <T> updateTransition(
targetState: T,
label: String? = null
): Transition<T> {
// 创建 Transition 对象并通过 remember 缓存,无参 remember 只会在第一次调用时执行,
// 从而保证了 Transition 只被创建一次
val transition = remember { Transition(targetState, label = label) }
// 动画转移到目标状态
transition.animateTo(targetState)
DisposableEffect(transition) {
onDispose {
// Clean up on the way out, to ensure the observers are not stuck in an in-between
// state.
transition.onTransitionEnd()
}
}
return transition
}
源码表明,updateTransition() 实际上做了两件事:创建 Transition 与更新状态。注意它只更新了状态,让 Transition 处于旧状态与新状态之间的中间状态,但不负责根据状态转移而生成动画。你需要调用它的返回值 Transition 提供的对应的函数以展示状态转移动画,比如 animateDp()、animateInt()、animateFloat() 等等。
以示例代码使用的 animateDp() 为例:
@Composable
inline fun <S> Transition<S>.animateDp(
noinline transitionSpec: @Composable Transition.Segment<S>.() -> FiniteAnimationSpec<Dp> = {
spring(visibilityThreshold = Dp.VisibilityThreshold)
},
label: String = "DpAnimation",
targetValueByState: @Composable (state: S) -> Dp // 泛型 S 表示状态类型
): State<Dp> =
animateValue(Dp.VectorConverter, transitionSpec, label, targetValueByState)
第三个参数 targetValueByState 是一个 Composable 函数,作用是根据传入的目标状态参数 state 计算出动画的目标值。
第二个参数 label 是在 Android Studio 的预览模式下,区分同一个 Transition 下不同动画的标识。具体说来,就是给可组合函数加上 @Preview 注解进入预览模式后,可以点击如下图所示的按钮进入动画预览模式:
在动画预览模式下,可以看到 Transition 下的状态以及所有动画:
当然,由于我们目前使用的是一个非常简单的例子,因此你可以很容易的判断出,左侧方框内的 Boolean 表示的是 big 状态,而右侧方框内的 DpAnimation : 48.0dp 表示的是 size(DpAnimation 是参数默认值)。但倘若一个非常复杂的动画,它的 Transition 内部可能会包含很多的状态和动画值,如果没有标识就很难区分它们。所以,第二个参数的 label 此时就派上用场了,当你调用 updateTransition() 和 animateDp() 时传入一个特定的 label,在预览时就能区分它们了:
@Preview
@Composable
fun TransitionSample() {
var big by remember { mutableStateOf(false) }
val bigTransition = updateTransition(big, "big")
val size by bigTransition.animateDp(label = "size") {
if (it) 96.dp else 48.dp
}
Box(modifier = Modifier
.size(size)
.background(Color.Green)
.clickable { big = !big }
)
}
效果如下:
最后说第一个参数 transitionSpec,它是一个返回有限动画 FiniteAnimationSpec 的 Composable 函数,animateDp() 的动画曲线就是用这个函数生成的。它要求你传一个函数而不是直接提供一个 FiniteAnimationSpec 对象,是为了方便在不同的状态下提供不同的 FiniteAnimationSpec,比如:
val size by bigTransition.animateDp({if (!initState && targetState) spring() else tween() }, label = "size") { if (it) 96.dp else 48.dp }
initState 与 targetState 是 Segment 提供的。Segment 是指状态转移中的某一段状态,可以提供这一段的状态信息。Segment 提供了简便方法 isTransitioningTo():
infix fun S.isTransitioningTo(targetState: S): Boolean {
return this == initialState && targetState == this@Segment.targetState
}
因此上面的等价写法是:
val size by bigTransition.animateDp({if (false isTransitioningTo true) spring() else tween() }, label = "size") { if (it) 96.dp else 48.dp }
这样更加直观的表达出状态从 false 转移到 true。
如果想创建一个无限循环类型的 Transition,可以使用 rememberInfiniteTransition(),它会返回一个 InfiniteTransition。
1.2 Transition 与 animate*AsState() 的区别
既然二者能实现相同的效果,为啥还要多提供一个 Transition?它用起来还比 animateXxxAsState() 多一些步骤。因为二者的关注点不同:animateDpAsState() 面向属性本身,而 Transition 面向属性背后的状态,这样就容易建立多属性的状态模型。
比如,给上例增加一个功能,在点击时改变图形的圆角大小:
@Composable
fun TransitionSample() {
var big by remember { mutableStateOf(false) }
val bigTransition = updateTransition(targetState = big)
val size by bigTransition.animateDp { if (it) 96.dp else 48.dp }
val corner by bigTransition.animateDp { if (it) 0.dp else 18.dp }
Box(modifier = Modifier
.size(size)
.clip(RoundedCornerShape(corner))
.background(Color.Green)
.clickable { big = !big }
)
}
假如使用 animateDpAsState() 来计算 size 和 corner,那么 Compose 会为每个属性启动一个协程进行计算动画,而使用 Transition 则只开一个协程,在 Transition 内部对所有的属性动画做统一管理。因此属性越多,Transition 的性能优势越明显。
此外还有一个好处,就是前面介绍 animateDp() 的第二个参数 label 时说到可以进行动画的预览,这个是 animate*AsState()
所不具备的功能。
还有,经过前面对 Transition 的介绍,你会发现它与 animate*AsState()
并没有本质上的区别,都是针对状态转移的。只不过一个是瞄准的是具体的属性,一个瞄准的是所有状态。因此,animateDpAsState() 具有的弱点 —— 不能设置动画的初始值,Transition 也是有的。虽然不能直接设置初始值,但是 Transition 可以通过一种比较迂回的方式去设置动画的初始值。先观察 updateTransition() 的源码:
@Composable
fun <T> updateTransition(
targetState: T,
label: String? = null
): Transition<T> {
val transition = remember { Transition(targetState, label = label) }
transition.animateTo(targetState)
DisposableEffect(transition) {
onDispose {
// Clean up on the way out, to ensure the observers are not stuck in an in-between
// state.
transition.onTransitionEnd()
}
}
return transition
}
通过 Transition 的次构造函数传入 targetState:
@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)
}
次构造调用主构造时会将 targetState 包装进 MutableTransitionState 对象中,实际上真正进行状态管理的就是 MutableTransitionState。
既然如此,我们可以在创建 Transition 时,使用另一个 updateTransition() 直接传入 MutableTransitionState:
@Composable
fun <T> updateTransition(
transitionState: MutableTransitionState<T>,
label: String? = null
): Transition<T> {
val transition = remember(transitionState) {
Transition(transitionState = transitionState, label)
}
transition.animateTo(transitionState.targetState)
DisposableEffect(transition) {
onDispose {
// Clean up on the way out, to ensure the observers are not stuck in an in-between
// state.
transition.onTransitionEnd()
}
}
return transition
}
这样做的好处是,可以在界面刚开始展示时,就直接开始动画:
@Preview
@Composable
fun TransitionSample() {
var big by remember { mutableStateOf(true) }
val bigState = remember { MutableTransitionState(!big) }
bigState.targetState = big
val bigTransition = updateTransition(bigState, "big")
val size by bigTransition.animateDp(label = "size") { if (it) 96.dp else 48.dp }
val corner by bigTransition.animateDp(label = "corner") { if (it) 0.dp else 18.dp }
Box(modifier = Modifier
.size(size)
.clip(RoundedCornerShape(corner))
.background(Color.Green)
.clickable { big = !big }
)
}
这一点是 animate*AsState()
做不到的。
2、AnimatedVisibility()
AnimatedVisibility() 会让其内容的显示与隐藏以动画的方式呈现,而不是突然的出现或消失。
2.1 基本用法
举一个简单的代码示例,点击按钮切换方块的显示或隐藏:
@Composable
fun AnimatedVisibilitySample() {
Column {
var shown by remember { mutableStateOf(true) }
if (shown) {
Box(
modifier = Modifier
.size(100.dp)
.background(Color.Green)
)
}
Button(onClick = { shown = !shown }) {
Text("切换")
}
}
}
上述代码完成的是没有动画效果的显示与隐藏,将 if 修改为 AnimatedVisibility() 即可实现动画效果:
可以看到,消失的动画是从下至上的,而显示的动画是由上至下的。这是因为,我们在 Column 中调用的 AnimatedVisibility() 实际上是 ColumnScope 的扩展函数:
@Composable
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)
}
该扩展函数的参数 enter 指定了入场动画,默认值是 fadeIn() + expandVertically(),即淡入 + 垂直展开动画,exit 指定出场动画,默认值是 fadeOut() + shrinkVertically(),即淡出 + 垂直收缩。演示效果正是因为使用了这两个参数的默认值。
既然 Column 有扩展函数,那么 Row 也会有相应的扩展函数:
@Composable
fun RowScope.AnimatedVisibility(
visibleState: MutableTransitionState<Boolean>,
modifier: Modifier = Modifier,
enter: EnterTransition = expandHorizontally() + fadeIn(),
exit: ExitTransition = shrinkHorizontally() + fadeOut(),
label: String = "AnimatedVisibility",
content: @Composable() AnimatedVisibilityScope.() -> Unit
) {
val transition = updateTransition(visibleState, label)
AnimatedEnterExitImpl(transition, { it }, modifier, enter, exit, content)
}
如果不是以上两种布局,就会使用通用的 AnimatedVisibility():
@Composable
fun AnimatedVisibility(
visibleState: MutableTransitionState<Boolean>,
modifier: Modifier = Modifier,
enter: EnterTransition = fadeIn() + expandIn(),
exit: ExitTransition = fadeOut() + shrinkOut(),
label: String = "AnimatedVisibility",
content: @Composable() AnimatedVisibilityScope.() -> Unit
) {
val transition = updateTransition(visibleState, label)
AnimatedEnterExitImpl(transition, { it }, modifier, enter, exit, content)
}
AnimatedVisibility() 在内部先用 updateTransition() 创建出一个 Transition 对象并使用该对象进行后续的动画操作,因此 AnimatedVisibility() 可以视为对 updateTransition() 的扩展。
2.2 参数详解
下面来详细看一下 enter 与 exit 的类型 EnterTransition 与 ExitTransition,由于二者相似,因此我们只详解 EnterTransition:
@Immutable
sealed class EnterTransition {
internal abstract val data: TransitionData
}
@Immutable
private class EnterTransitionImpl(override val data: TransitionData) : EnterTransition()
EnterTransition 是一个密封类,它只有一个子类 EnterTransitionImpl,实现了父类的抽象属性 data:
@Immutable
internal data class TransitionData(
val fade: Fade? = null, // 淡入淡出
val slide: Slide? = null, // 滑动
val changeSize: ChangeSize? = null, // 尺寸改变,裁切方式
val scale: Scale? = null // 缩放
)
TransitionData 定义了四个属性,实际上就是四种类型的动画。在使用时,不用自己创建这四种类型的对象再传进来,可以直接使用 Compose 提供的函数,下面来分别介绍。
Fade
淡入效果 fadeIn() 会完成整个创建流程并返回一个 EnterTransitionImpl:
@Stable
fun fadeIn(
animationSpec: FiniteAnimationSpec<Float> = spring(stiffness = Spring.StiffnessMediumLow),
initialAlpha: Float = 0f
): EnterTransition {
// 为 EnterTransitionImpl 传入 TransitionData,该 TransitionData 指定了 fade 属性
return EnterTransitionImpl(TransitionData(fade = Fade(initialAlpha, animationSpec)))
}
我们可以传入定制的 FiniteAnimationSpec 以及初始透明度:
@Composable
fun AnimatedVisibilitySample() {
Column {
var shown by remember { mutableStateOf(true) }
AnimatedVisibility(shown, enter = fadeIn(tween(4000), 0.3f)) {
Box(
modifier = Modifier
.size(100.dp)
.background(Color.Green)
)
}
Button(onClick = { shown = !shown }) {
Text("切换")
}
}
}
为了让动画效果显得明显一些,特意将动画时长设置为 4 秒,效果如下:
Slide
同理,slide 对应的入场效果动画是 slideIn():
@Stable
fun slideIn(
animationSpec: FiniteAnimationSpec<IntOffset> =
spring(
stiffness = Spring.StiffnessMediumLow,
visibilityThreshold = IntOffset.VisibilityThreshold
),
initialOffset: (fullSize: IntSize) -> IntOffset,
): EnterTransition {
return EnterTransitionImpl(TransitionData(slide = Slide(initialOffset, animationSpec)))
}
必须要给 initialOffset 这个函数参数赋值,因为需要通过它来确定从哪个位置滑入:
@Composable
fun AnimatedVisibilitySample() {
Column {
var shown by remember { mutableStateOf(true) }
AnimatedVisibility(shown, enter = slideIn { IntOffset(-it.width, -it.height) }) {
Box(
modifier = Modifier
.size(100.dp)
.background(Color.Green)
)
}
Button(onClick = { shown = !shown }) {
Text("切换")
}
}
}
initialOffset() 在参数中提供了组件的尺寸 fullSize,因此可以很容易地获取到左上顶点的偏移量,据此创建 IntOffset 并返回即可。效果如下:
此外,slideIn() 还有只进行垂直方向滑入或水平方向滑入的衍生版本 slideInVertically() 与 slideInHorizontally(),只需要传入对应方向的一维偏移量即可,不多赘述。
ChangeSize
changeSize 的入场动画由 expandIn() 提供:
@Stable
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 传一个时长为 5 秒的 TweenSpec 来观察 expandIn() 的默认效果,并且为了证明默认保留右下角区域,我们在绿色方块的右下角放了一个小的红色方块作为参照,代码如下:
@Composable
fun AnimatedVisibilitySample() {
Column {
var shown by remember { mutableStateOf(true) }
AnimatedVisibility(shown, enter = expandIn(tween(5000))) {
Box(
modifier = Modifier
.size(100.dp)
.background(Color.Green)
) {
Box(
modifier = Modifier
.size(30.dp)
.background(Color.Red)
.align(Alignment.BottomEnd)
)
}
}
Button(onClick = { shown = !shown }) {
Text("切换")
}
}
}
效果图如下:
可以看到,是右下角保留的红色方块现在左上角的位置显现,然后慢慢扩大直到整个组件完全展示。
接下来再看 expandIn() 参数的含义:
- expandFrom:扩展边界的起点,默认为 Alignment.BottomEnd,这解释了为何默认情况下组件是从右下角展开的
- clip:是否应剪切动画边界之外的内容,默认为 true,如果设置为 false,则不进行裁切,只进行简单的位移
- initialSize:扩展边界的起始大小,默认返回 IntSize(0, 0)
我们对以上参数逐个进行测试。首先修改 expandFrom 的位置,比如让它从左上角开始展开:
AnimatedVisibility(shown, enter = expandIn(tween(5000), Alignment.TopStart))
效果如下,右下角的红色方块最后才展示出来:
然后设置 initialSize,让宽高都是一半的位置作为动画开始的初始位置:
AnimatedVisibility(
shown,
enter = expandIn(tween(5000), Alignment.TopStart) {
IntSize(it.width / 2, it.height / 2)
},
)
动画初始就会展示左上角的 1/4,然后慢慢展开,效果如下:
再看 clip,将其修改为 false 查看效果:
AnimatedVisibility(
shown,
enter = expandIn(tween(5000), Alignment.TopStart, false) {
IntSize(it.width / 2, it.height / 2)
},
)
效果如下:
expandIn() 也有两个在单一方向上的衍生函数 expandHorizontally() 与 expandVertically()。
Scale
最后,通过 scaleIn() 提供缩放入场:
@Stable
@ExperimentalAnimationApi
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))
)
}
默认以组件中心 TransformOrigin.Center 为缩放中心,默认效果如下:
最后我们来说一下这四种函数之间的加号,是 EnterTransition 重写了 plus 操作符:
@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
)
)
}
plus 是创建一个新的 EnterTransitionImpl,里面的 TransitionData 的四个参数,优先选择等号左侧的,如果等号左侧没有配置某种动画,才会选择等号右侧的。
出场动画 ExitTransition 的内容与 EnterTransition 大同小异,只不过入场动画的 expandIn() 对应出场动画的 shrinkOut():
AnimatedVisibility(
shown,
enter = fadeIn() + expandIn(),
exit = fadeOut() + shrinkOut()
)
此外,Transition 还有一个 AnimatedVisibility() 的扩展函数:
@ExperimentalAnimationApi
@Composable
fun <T> Transition<T>.AnimatedVisibility(
visible: (T) -> Boolean,
modifier: Modifier = Modifier,
enter: EnterTransition = fadeIn() + expandIn(),
exit: ExitTransition = shrinkOut() + fadeOut(),
content: @Composable() AnimatedVisibilityScope.() -> Unit
) = AnimatedEnterExitImpl(this, visible, modifier, enter, exit, content)
第一个参数 visible 是返回 Boolean 类型的函数,其参数 T 是 Transition 的状态,你需要根据这个状态决定 AnimatedVisibility() 的内容是否可见并作为 visible 的返回值。
无论使用哪一种 AnimatedVisibility,它里面都只能存放一个 Composable 组件,放多了行为不正常。如果有多个 Composable 组件想使用 AnimatedVisibility,那么就为每一个组件配一个 AnimatedVisibility。
3、Crossfade()
Crossfade() 会以动画方式切换显示内容,动画是透明度渐变动画,也就是淡入淡出动画,与上一节介绍的 fadeIn()、fadeOut() 完全相同。我们直接来看 Crossfade() 的参数,因为这些参数在前面介绍其他 API 时已经见过很多次了:
@OptIn(ExperimentalAnimationApi::class)
@Composable
fun <T> Crossfade(
targetState: T,
modifier: Modifier = Modifier,
animationSpec: FiniteAnimationSpec<Float> = tween(),
label: String = "Crossfade",
content: @Composable (T) -> Unit
) {
val transition = updateTransition(targetState, label)
transition.Crossfade(modifier, animationSpec, content = content)
}
targetState 是代表目标布局状态的键,每当更改一个键时,动画将被触发,使用旧键调用的 content 将淡出,而使用新键调用的 content 将淡入。
假设在未使用动画的情况下,根据不同状态显示相应组件的代码如下:
@Composable
fun CrossfadeSample() {
Column {
var shown by remember { mutableStateOf(false) }
if (shown) {
Box(
Modifier
.size(48.dp)
.background(Color.Red)
)
} else {
Box(
Modifier
.size(24.dp)
.background(Color.Green)
)
}
Button(onClick = { shown = !shown }) {
Text("切换")
}
}
}
如果想实现动画切换效果,可以用 Crossfade() 包住负责组件切换的 if - else 代码块,并且将 if 的判断条件修改为参数传入的 state 对象:
@Composable
fun CrossfadeSample() {
Column {
var shown by remember { mutableStateOf(false) }
Crossfade(targetState = shown, animationSpec = tween(5000)) { state ->
if (state) {
Box(
Modifier
.size(48.dp)
.background(Color.Red)
)
} else {
Box(
Modifier
.size(24.dp)
.background(Color.Green)
)
}
}
Button(onClick = { shown = !shown }) {
Text("切换")
}
}
}
这里照例是为了让动画效果更明显,将动画时间设置为 5 秒了,效果如下:
可以看到,在渐变过程中,就是两个组件同时存在的时间段内,它会让两个组件都完整显示,但是在渐变完成之后,就只保留新组件的尺寸范围。
由于 Crossfade() 就是一个功能很简单的函数,因此它不能修改动画类型,只能是淡入淡出动画。此外,Crossfade() 的状态值可以是任意类型,不止是上面示例的 Boolean 类型。比如 Int 类型,它可以有多个状态值,需要结合 when 使用。
4、AnimatedContent()
前两节我们讲了 AnimatedVisibility() 与 Crossfade(),它们有各自的适用场景:
- AnimatedVisibility():对单个组件的出现与消失的动画,可以配置多种动画规格(透明度、偏移位置、尺寸裁剪、缩放)
- Crossfade():对不同状态下的多个组件间切换施以透明度淡入淡出的动画效果,无法配置其他动画效果
AnimatedContent() 覆盖了上述两个函数的功能,可以对两个组件根据状态进行切换,并可以配置切换时的动画效果。它的使用方式也与上面的函数类似:
@OptIn(ExperimentalAnimationApi::class)
@Composable
fun AnimatedContentSample() {
Column {
var shown by remember { mutableStateOf(false) }
AnimatedContent(shown) { state ->
if (state) {
Box(
Modifier
.size(48.dp)
.background(Color.Red)
)
} else {
Box(
Modifier
.size(24.dp)
.background(Color.Green)
)
}
}
Button(onClick = { shown = !shown }) {
Text("切换")
}
}
}
效果如下:

这种效果是 AnimatedContent() 的 transitionSpec 参数的默认值配置出的动画效果:
@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
)
}
入场的两个动画 fadeIn() 与 scaleIn() 都是延迟 90ms 开始执行,持续 220ms。出场动画 fadeOut() 则是立即执行,时长 90ms。实际上就是 fadeOut() 执行完,让原始组件消失之后,再开始执行入场的两个动画。
4.1 ContentTransform
再观察 transitionSpec 的返回值 ContentTransform:
@ExperimentalAnimationApi
class ContentTransform(
val targetContentEnter: EnterTransition,
val initialContentExit: ExitTransition,
targetContentZIndex: Float = 0f,
sizeTransform: SizeTransform? = SizeTransform()
) {
var targetContentZIndex by mutableStateOf(targetContentZIndex)
var sizeTransform: SizeTransform? = sizeTransform
internal set
}
ContentTransform 有四个属性:
- targetContentEnter:目标内容的入场动画
- initialContentExit:初始内容的出场动画
- targetContentZIndex:Z 轴的绘制顺序,用于配置各个组件间的覆盖关系
- sizeTransform:尺寸渐变动画
targetContentEnter 与 initialContentExit
假如不想使用 AnimatedContent() 的参数 transitionSpec 的默认值,想要自己选取出入场动画,可以自己写一个返回 ContentTransform 的函数:
AnimatedContent(
shown,
transitionSpec = { ContentTransform(fadeIn(), fadeOut()) }
)
它还有一种等价的方式 —— 使用 EnterTransition 的扩展函数 with():
AnimatedContent(shown, transitionSpec = { fadeIn() with fadeOut() })
with() 实际上就是帮助我们实现了第一种方式:
@ExperimentalAnimationApi
infix fun EnterTransition.with(exit: ExitTransition) = ContentTransform(this, exit)
targetContentZIndex
假如还想修改出入场组件之间的覆盖关系,可以通过修改 targetContentZIndex 实现。默认情况下,targetContentZIndex 是无需配置的,因为入场的组件会在出场组件的上面,遮住出场组件,这个行为是符合预期与需求的。比如使用以下测试代码:
@OptIn(ExperimentalAnimationApi::class)
@Composable
fun AnimatedContentSample1() {
Column {
var shown by remember { mutableStateOf(false) }
AnimatedContent(
shown,
transitionSpec = {
fadeIn(tween(3000)) with fadeOut(tween(3000, 3000))
}
) {
if (it) {
Box(
Modifier
.size(48.dp)
.clip(RoundedCornerShape(10.dp))
.background(Color.Red)
)
} else {
Box(
Modifier
.size(24.dp)
.background(Color.Green)
)
}
}
Button(onClick = { shown = !shown }) {
Text("切换")
}
}
}
效果如下:

我们设置出场动画延迟 3s 再开始执行,目的是等待入场动画执行完毕后再开始出场动画,这样看的更清楚一些。上图可以明显看出,红色方块入场时先立即覆盖了即将出场的绿色方块,在入场动画结束后,绿色方块开始出场,左上角的绿色才缓缓消失。
但是有些情况下,默认行为不能满足开发需求,比如想让一个组件做背景,那么这个组件在动画过程中,不论是入场还是出场,它一定是永远在最下层的。再比如一个悬浮的按钮,不管是入场还是出场,在动画过程中永远都是在最上层的。因此我们需要酌情修改 targetContentZIndex。比如对于上面的例子,改为不论入场还是出场,红色方块永远在绿色方块下方:
@OptIn(ExperimentalAnimationApi::class)
@Composable
fun AnimatedContentSample2() {
Column {
var shown by remember { mutableStateOf(false) }
AnimatedContent(
shown,
transitionSpec = {
// targetState 就是 shown,当其为 true 时就是要显示红色方块了,
// 此时将 targetContentZIndex 调小
if (targetState) {
(fadeIn(tween(3000)) with
fadeOut(tween(3000, 3000))).apply {
targetContentZIndex = -1f
}
} else {
fadeIn(tween(3000)) with fadeOut(tween(3000, 3000))
}
}
) {
if (it) {
Box(
Modifier
.size(48.dp)
.clip(RoundedCornerShape(10.dp))
.background(Color.Red)
)
} else {
Box(
Modifier
.size(24.dp)
.background(Color.Green)
)
}
}
Button(onClick = { shown = !shown }) {
Text("切换")
}
}
}
由于 targetContentZIndex 的默认值是 0f,如果想让红色方块在下面,给它赋一个小于 0 的值即可:

sizeTransform
最后看 sizeTransform 的设置,直接使用 SizeTransform():
@ExperimentalAnimationApi
fun SizeTransform(
clip: Boolean = true,
sizeAnimationSpec: (initialSize: IntSize, targetSize: IntSize) -> FiniteAnimationSpec<IntSize> =
{ _, _ -> spring(visibilityThreshold = IntSize.VisibilityThreshold) }
): SizeTransform = SizeTransformImpl(clip, sizeAnimationSpec)
clip 表示是否裁切,一般为 true;sizeAnimationSpec 是计算动画曲线的函数,返回 FiniteAnimationSpec,前面也说过多次了。
如果想把配置好的 SizeTransform 合并到现有的 ContentTransform 对象中,可以其扩展函数 using():
AnimatedContent(
shown,
transitionSpec = {
(fadeIn() + scaleIn(tween(500)) with fadeOut()) using SizeTransform()
}
)
两个入场动画合并通过重写 EnterTransition 的 plus 操作符实现:
sealed class EnterTransition {
@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
)
)
}
}
入场动画与出场动画合并使用 with():
@ExperimentalAnimationApi
infix fun EnterTransition.with(exit: ExitTransition) = ContentTransform(this, exit)
最后将 SizeTransform 合并到已有的 ContentTransform 中,用 using():
@ExperimentalAnimationApi
class AnimatedContentScope<S> internal constructor(
@ExperimentalAnimationApi
infix fun ContentTransform.using(sizeTransform: SizeTransform?) = this.apply {
this.sizeTransform = sizeTransform
}
}
可以回头看一下 AnimatedContent() 的 transitionSpec 参数,就是通过以上方式将入场、出场效果配置在一起的。
4.2 Transition 版本
AnimatedContent() 也有一个 Transition 版本,内容与使用方式与本节讲述的通用版本类似:
@ExperimentalAnimationApi
@Composable
fun <S> Transition<S>.AnimatedContent(
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,
contentKey: (targetState: S) -> Any? = { it },
content: @Composable() AnimatedVisibilityScope.(targetState: S) -> Unit
) {...}