
在 Compose 里,AnimatedContent 主要用来处理一类很常见的变化:新旧内容的交替。
先看这个函数签名:
Kotlin
public fun <S> AnimatedContent(
targetState: S,
modifier: Modifier = Modifier,
transitionSpec: AnimatedContentTransitionScope<S>.() -> ContentTransform = {
(fadeIn(animationSpec = tween(220, delayMillis = 90)) +
scaleIn(initialScale = 0.92f, animationSpec = tween(220, delayMillis = 90)))
.togetherWith(fadeOut(animationSpec = tween(90)))
},
contentAlignment: Alignment = Alignment.TopStart,
label: String = "AnimatedContent",
contentKey: (targetState: S) -> Any? = { it },
content: @Composable() AnimatedContentScope.(targetState: S) -> Unit,
)
它和单纯修改某个属性不太一样。targetState 变化后,也就是新内容出现的时候,旧内容不会立刻从界面树里消失,而是会先留下来完成退出动画;与此同时,新内容会被组合出来,开始执行进入动画。
这两部分动画的配合关系,都写在 transitionSpec 里。
这个 lambda 返回一个 ContentTransform,用来描述旧内容怎么离开、新内容怎么进来。也正因为 transitionSpec 同时能够获取旧状态和新状态,动画可以根据这一次状态变化本身来决定,而不是只能套用一段固定的效果。
Kotlin
(fadeIn(animationSpec = tween(220, delayMillis = 90)) +
scaleIn(initialScale = 0.92f, animationSpec = tween(220, delayMillis = 90)))
.togetherWith(fadeOut(animationSpec = tween(90)))
上面是 AnimatedContent 的默认效果:新内容用"延迟后的淡入 + 缩放进入",旧内容用"快速淡出"。
这个默认动画设计的重点是:让旧内容快速退场,新内容稍微晚一点、柔和地出现。它不会做大幅位移,也不会有很强的方向感,只是一个通用、安全的默认过渡。
下面用一个数字计数器来具体说说这个动画。
数字变大时,新数字从下方滑入,旧数字向上滑出;数字变小时,方向反过来。除了滑动之外,再配合淡入淡出,并用 SizeTransform 处理数字宽度变化时的容器尺寸。
基础计数器
先看最小结构。示例里只有一个整数状态,以及两个按钮分别负责递减和递增。数字展示部分交给 AnimatedContent:
kotlin
var count by remember { mutableIntStateOf(0) }
Box(
modifier = Modifier.fillMaxWidth().height(160.dp),
contentAlignment = Alignment.Center,
) {
AnimatedContent(
targetState = count,
transitionSpec = { },
label = "counter",
) { value ->
Text(text = "$value", fontSize = 96.sp, fontWeight = FontWeight.Bold)
}
}
这里的 targetState 就是当前的 count。当 count 改变时,AnimatedContent 会把它当作新的内容目标,旧数字执行退出动画,新数字执行进入动画。
外层 Box 固定了 160.dp 的高度,这不是随手写的布局参数,垂直滑动需要上下空间,如果容器高度跟着文字本身收缩,动画会显得局促,甚至可能在滑动过程中被裁剪,所以外层的 Box 必须保留足够的空间来显示动画效果。
当然,这个数值并不是固定的,在确定好动画之前,可能需要调试这个空间的大小。
内容 lambda 里的 value 是这一次组合对应的目标值。需要注意的是,在过渡期间,Compose 可能会同时组合旧数字和新数字,因此这个 lambda 也可能在同一段过渡里被调用两次:一次用于离开的内容,一次用于进入的内容。
接下来定义几个参数,方便调整动画手感:
kotlin
val slideDurationMs = 650
val fadeDurationMs = 450
val slideOffsetDivisor = 1
slideDurationMs 控制滑动时长,fadeDurationMs 控制淡入淡出时长,slideOffsetDivisor 则控制数字相对自身高度移动多远。
描述进入和退出
transitionSpec 的接收者是 AnimatedContentTransitionScope<Int>。
在这个作用域里,可以用一个进入动画和一个退出动画组合出 ContentTransform。进入和退出之间用 togetherWith 连接;同一侧如果有多个效果,比如滑动加淡入,则用 + 叠加。
递增时的动画可以这样写:
kotlin
slideInVertically(
animationSpec = tween(slideDurationMs),
initialOffsetY = { it / slideOffsetDivisor },
) + fadeIn(animationSpec = tween(fadeDurationMs)) togetherWith
slideOutVertically(
animationSpec = tween(slideDurationMs),
targetOffsetY = { -it / slideOffsetDivisor },
) + fadeOut(animationSpec = tween(fadeDurationMs))
进入的新数字使用 in 类型的动画。
slideInVertically 里的 initialOffsetY 表示新内容从哪里开始。这里传入 { it / slideOffsetDivisor },其中 it 是内容测量出来的高度,单位是像素。也就是说,新数字一开始在最终位置的下方,然后向上滑到正中,同时执行淡入。
退出的旧数字则使用 out 类型的动画,例如 slideOutVertically。
它的 targetOffsetY 是 { -it / slideOffsetDivisor },所以旧数字会继续向上滑出,同时淡出。进入和退出使用同一组滑动、淡入淡出时长,整个替换看起来就会像一次完整的交接,而不是两个互不相关的动画。
如果新旧内容的尺寸不同,还可以在这个 ContentTransform 上挂一个 SizeTransform:
kotlin
slideIn + fadeIn togetherWith slideOut + fadeOut using
SizeTransform { initialSize, targetSize -> tween(300) }
SizeTransform 控制的是容器尺寸本身如何变化。比如数字从 9 变成 10 时,文本宽度会变大。如果没有尺寸动画,容器可能会在第一帧直接跳到新宽度,正在退出的旧内容就容易被切掉一部分。加上 SizeTransform 后,尺寸变化也会跟着过渡一起变得平滑。
动画跟着数字方向走
数字计数器的动画最好能和数字变化方向保持一致。递增时往上走,递减时往下走,用户会更容易理解这次变化。
在 transitionSpec 里可以直接比较 targetState 和 initialState:
kotlin
val goingUp = targetState > initialState
if (goingUp) {
} else {
}
当 targetState 大于 initialState,说明数字在增加。此时让新数字从下方进入,旧数字向上离开。反过来,当数字减少时,新数字从上方进入,旧数字向下离开。
这个写法的关键在于:AnimatedContent 不是只告诉你"目标状态是什么",它也把"上一个状态是什么"提供给了 transitionSpec。因此动画可以写成状态变化的函数。对于计数器这种有方向感的 UI,这一点很有用。
内容 key
在这个例子里,传给 targetState 的整数同时也是内容 key,不信你看那个函数的默认实现:
Kotlin
contentKey: (targetState: S) -> Any? = { it },
AnimatedContent 会根据相等性判断 key 是否变化,只有变化时才会触发过渡。
所以点击按钮让 count 加一,会播放数字替换动画;但如果父级因为别的原因发生重组,而 count 没变,这段过渡不会重新执行。
如果实际项目里传入的是一个复杂对象,但你只想根据其中某个字段决定是否触发动画,可以额外传入 contentKey。这样动画触发条件就可以和完整的业务状态解耦,不必让对象的所有变化都引发内容切换。
using 的作用也类似:它不会改变 ContentTransform 的基本结构,只是在已有的进入、退出动画之外,附加 SizeTransform 这类可选配置。需要时可以分别挂在不同分支上。
完整代码
下面就是实现部分的关键代码:
Kotlin
var count by remember { mutableIntStateOf(0) }
val slideDurationMs = 650
val fadeDurationMs = 450
val slideOffsetDivisor = 1
//...
Box(
modifier = Modifier
.fillMaxWidth()
.height(160.dp)
.clip(RoundedCornerShape(28.dp))
.background(Color(0xFF17211A)),
contentAlignment = Alignment.Center,
) {
AnimatedContent(
targetState = count,
transitionSpec = {
val goingUp = targetState > initialState
if (goingUp) {
slideInVertically(
animationSpec = tween(slideDurationMs),
initialOffsetY = { it / slideOffsetDivisor },
) + fadeIn(animationSpec = tween(fadeDurationMs)) togetherWith
slideOutVertically(
animationSpec = tween(slideDurationMs),
targetOffsetY = { -it / slideOffsetDivisor },
) + fadeOut(animationSpec = tween(fadeDurationMs)) using
SizeTransform { _, _ -> tween(300) }
} else {
slideInVertically(
animationSpec = tween(slideDurationMs),
initialOffsetY = { -it / slideOffsetDivisor },
) + fadeIn(animationSpec = tween(fadeDurationMs)) togetherWith
slideOutVertically(
animationSpec = tween(slideDurationMs),
targetOffsetY = { it / slideOffsetDivisor },
) + fadeOut(animationSpec = tween(fadeDurationMs)) using
SizeTransform { _, _ -> tween(300) }
}
},
label = "counter",
) { value ->
Text(
text = "$value",
fontSize = 96.sp,
fontWeight = FontWeight.Bold,
color = Color(0xFFF5C84B),
)
}
}
效果如下:

调整动画手感
这组动画的手感主要由三个参数决定,而且它们可以分开调。
slideOffsetDivisor 控制滑动距离。值为 1 时,新旧数字都会移动一个完整内容高度,替换感很强,接近老虎机滚轮的感觉。设为 2 时,移动距离减半,动作会轻一些。设为 4 时,滑动只剩下轻微位移,更像是由淡入淡出主导的变化。
slideDurationMs 和 fadeDurationMs 控制动画快慢。示例里的 650 和 450 偏慢,节奏比较从容。把滑动时长降到 120 左右,计数器会更敏捷,适合表单步进器这类高频操作。拉到 800 左右,数字替换会显得更郑重,适合分数揭晓、关键指标变化这样的场景。
如果把 fadeDurationMs 设为 0,动画就会变成纯滑动。这样更干脆,但当数字形状差异比较大时,也可能显得突兀。
SizeTransform 影响的是数字位数变化时的容器尺寸。比如从 9 到 10,宽度变化是明显的。默认的 tween 通常已经够用;如果想让尺寸变化更有弹性,或者更接近线性,也可以换成 keyframes 或指定不同的 Easing。
如果省略 SizeTransform,容器会在过渡开始时直接采用新尺寸。很多裁剪问题就是从这里来的:旧数字还在滑出,但容器已经按新内容重新测量了。加上尺寸动画成本不高,却能避免这类不太直观的视觉问题。
除了这些参数,还可以调整 animationSpec 本身。示例使用的是 tween,它适合稳定、可预期的过渡。如果把滑动部分换成 spring,数字到位时会有一点轻微回弹,看起来更有物理感。淡入淡出继续用 tween,滑动改成 spring,也是很常见的组合:透明度保持克制,位移负责提供动感。
一点想法
AnimatedContent 适合内容身份发生变化的场景。它的重点不是把某个数值从 A 补间到 B,而是让旧内容和新内容短暂共存,并安排它们如何完成交接。
如果只是连续改变某个属性,animate*AsState 应该更适合你。
如果只是两个内容之间做简单淡入淡出,Crossfade 就足够了。
如果你需要同时控制旧内容怎么退出、新内容怎么进入,并且还要根据状态变化方向调整动画,AnimatedContent 会更合适。
像计分板、步进器、Tab 指示器,或者任何"一个内容替换另一个内容"的 UI,都可以从这个模式开始。先选一个合适的时长,再用 slideOffsetDivisor 调整运动幅度;只要新旧内容的尺寸可能不同,就把 SizeTransform 一起加上。
来吧,下一次在两个布局做动画过渡的时候,尝试用一下 AnimatedContent 吧!