一个丝滑的数字计数器,讲清楚 AnimatedContent 怎么用

在 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 里可以直接比较 targetStateinitialState

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 时,滑动只剩下轻微位移,更像是由淡入淡出主导的变化。

slideDurationMsfadeDurationMs 控制动画快慢。示例里的 650450 偏慢,节奏比较从容。把滑动时长降到 120 左右,计数器会更敏捷,适合表单步进器这类高频操作。拉到 800 左右,数字替换会显得更郑重,适合分数揭晓、关键指标变化这样的场景。

如果把 fadeDurationMs 设为 0,动画就会变成纯滑动。这样更干脆,但当数字形状差异比较大时,也可能显得突兀。

SizeTransform 影响的是数字位数变化时的容器尺寸。比如从 910,宽度变化是明显的。默认的 tween 通常已经够用;如果想让尺寸变化更有弹性,或者更接近线性,也可以换成 keyframes 或指定不同的 Easing

如果省略 SizeTransform,容器会在过渡开始时直接采用新尺寸。很多裁剪问题就是从这里来的:旧数字还在滑出,但容器已经按新内容重新测量了。加上尺寸动画成本不高,却能避免这类不太直观的视觉问题。

除了这些参数,还可以调整 animationSpec 本身。示例使用的是 tween,它适合稳定、可预期的过渡。如果把滑动部分换成 spring,数字到位时会有一点轻微回弹,看起来更有物理感。淡入淡出继续用 tween,滑动改成 spring,也是很常见的组合:透明度保持克制,位移负责提供动感。

一点想法

AnimatedContent 适合内容身份发生变化的场景。它的重点不是把某个数值从 A 补间到 B,而是让旧内容和新内容短暂共存,并安排它们如何完成交接。

如果只是连续改变某个属性,animate*AsState 应该更适合你。

如果只是两个内容之间做简单淡入淡出,Crossfade 就足够了。

如果你需要同时控制旧内容怎么退出、新内容怎么进入,并且还要根据状态变化方向调整动画,AnimatedContent 会更合适。

像计分板、步进器、Tab 指示器,或者任何"一个内容替换另一个内容"的 UI,都可以从这个模式开始。先选一个合适的时长,再用 slideOffsetDivisor 调整运动幅度;只要新旧内容的尺寸可能不同,就把 SizeTransform 一起加上。

来吧,下一次在两个布局做动画过渡的时候,尝试用一下 AnimatedContent 吧!

相关推荐
私人珍藏库1 小时前
[Android] 红妆相机-拍照美颜图片美化工具
android·数码相机·app·软件·多功能
唯刻V1 小时前
你的IDE已经不认识你了
android·ide·android-studio·cli
zhangphil1 小时前
Android OS系统kswapd、kworker、HeapTaskDaemon/heapdamon对卡顿丢帧及应用流畅性的影响
android
开维游戏引擎11 小时前
AI自动生成游戏时,deepseek和mimo对比
android·游戏·语言模型·游戏引擎·ai编程
BreezeDove17 小时前
【Android】AS项目自动连接mumu模拟器配置
android
乐世东方客20 小时前
备份脚本记录(binlog文件+mysql+mongo)
android·数据库·mysql
私人珍藏库21 小时前
[Android] 视频下载鸟 v20.02 会员
android·人工智能·智能手机·app·工具·多功能
zh_xuan21 小时前
tv浏览网页工具
android·tv浏览网页
Carson带你学Android1 天前
Compose 终于上线 FlexBox:换行与弹性伸缩 都轻松搞定!
android·composer