一个丝滑的数字计数器,讲清楚 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 里可以直接比较 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 吧!

相关推荐
千里马学框架3 天前
一起学 Android 14:ShellTransition 屏幕旋转过程深度剖析
android·智能手机·性能优化·framework·性能·屏幕旋转·rotation
美狐美颜SDK开放平台3 天前
开发直播APP时如何接入视频美颜SDK?开发流程与注意事项
android·人工智能·计算机视觉·音视频·直播美颜sdk
AFinalStone3 天前
Android7 SystemUI源码解析(七)Keyguard锁屏模块深度解析
android·systemui
致远ccc3 天前
Google Play 上架前如何测试 App?多国家 Android 环境测试
android·app测试·googleplay·多国家应用测试
ttyyttemo3 天前
Kotlin 协程中的 Job 结构化并发与取消
android
sun0077003 天前
tbox 4g/5g切换,导致wan ip 改变,导致车机旧网络不可用。需要重启车机才行
android
ai2work3 天前
ch23 综合复刻:从零做一个最小可用版本(capstone)
kotlin
其实防守也摸鱼3 天前
内网穿透与反向代理:原理、工具与实战指南
android·大数据·运维·安全·网络安全·自动化·渗透
ai2work3 天前
ch21 签名、校验与发版
kotlin
AFinalStone3 天前
Android7 SystemUI 源码解析(四)NavigationBar 导航栏与 SystemBars
android·systemui