各位 Compose 彭于晏,早上好。
在之前讲解 Compose 的文章里,我们花了不少篇幅探讨 Compose 的绘制流程、布局原理和重组机制——这些确实是理解 Compose 如何工作的基础。
但一个界面真正"活"起来,往往靠的是动画。动画不是锦上添花,而是让用户感知到状态变化、建立空间认知的关键手段。
所以后续我会连续更新几篇文章,让我们把目光转向 Compose 动画。
作为开篇,我们先从最基础、最常用的 animateDpAsState 入手。
这个 API 用起来非常简单:你告诉它"目标是多大",它就返回一个会自己变化的尺寸值。当目标改变时,它会自动在旧值和新值之间平滑过渡。
但在这简单用法的背后,动画系统到底是怎么工作的?为什么有时候动画感觉流畅自然,有时候却显得生硬?持续时间和缓动曲线这些参数,究竟怎样影响动画的"手感"?
本文通过一个可展开卡片的实例,带你深入理解 Compose 尺寸动画的方方面面,重点讲解 tween 动画规格的使用技巧。
整体结构
界面上是一个标题行和一张 Card,卡片内容由正文文本和一个小箭头指示器组成。
点击卡片会切换大小状态,这个状态实际上是一个布尔值管理的,这个布尔值就是驱动动画的唯一状态。
UI 的字体、表面颜色、内边距等其余属性全部是静态的。
我们先来看看完整的示例代码(篇幅问题,只保留了主要代码)
// 两种形态的宽高
private val COLLAPSED_HEIGHT: Dp = 160.dp
private val EXPANDED_HEIGHT: Dp = 330.dp
// 动画时长
private const val EXPAND_DURATION_MS = 400
// 缓动曲线选项
private data class EasingOption(
val name: String,
val easing: androidx.compose.animation.core.Easing,
)
private val easingOptions = listOf(
EasingOption("FastOutSlowIn", FastOutSlowInEasing),
EasingOption("Linear", LinearEasing),
EasingOption("FastOutLinearIn", FastOutLinearInEasing),
)
// 示例文字
private val sampleText = buildString {
appendLine("XXX")
}
@Composable
fun AnimateDpPage(
onNavigate: (Any) -> Unit,
onBack: () -> Unit,
) {
var isExpanded by remember { mutableStateOf(false) }
var easingIndex by remember { mutableIntStateOf(0) }
val currentEasing by remember { derivedStateOf { easingOptions[easingIndex] } }
// 动画
val animatedHeight by animateDpAsState(
targetValue = if (isExpanded) EXPANDED_HEIGHT else COLLAPSED_HEIGHT,
animationSpec = tween(
durationMillis = EXPAND_DURATION_MS,
easing = currentEasing.easing,
),
label = "cardHeight",
)
Card(
modifier = Modifier
.fillMaxWidth()
.height(animatedHeight) // 使用动画变更高度
.clickable { isExpanded = !isExpanded },
//...
) {
//...
Text(
text = sampleText,
fontSize = 14.sp,
lineHeight = 20.sp,
modifier = Modifier.padding(top = 12.dp),
)
}
}
动画不追踪滚动位置或拖拽偏移量,它只关注 isExpanded 这一个布尔值:true 就去展开高度,false 就回折叠高度。animateDpAsState 会自动在当前值和目标值之间做平滑过渡。
Card 的高度直接绑定到 animatedHeight,每一帧都会用动画系统算出来的最新值来测量和布局。
代码里定义了三个关键常量:
COLLAPSED_HEIGHT和EXPANDED_HEIGHT:卡片折叠和展开时的高度,也就是动画的起点和终点。EXPAND_DURATION_MS:动画从头到尾需要多少毫秒。
而 EasingOption 中的 easing(缓动曲线)则控制动画在不同时间段的快慢节奏。
简单来说,就是决定动画是匀速运动,还是先快后慢、先慢后快。
动画调用解析
val animatedHeight by animateDpAsState(
targetValue = if (isExpanded) EXPANDED_HEIGHT else COLLAPSED_HEIGHT,
animationSpec = tween(
durationMillis = EXPAND_DURATION_MS,
easing = currentEasing.easing,
),
label = "cardHeight",
)
有三个要点值得注意:
targetValue在每次重组时根据isExpanded计算。当布尔值从false变成true(或反过来),animateDpAsState会检测到目标值变了,随即开始一段新的动画,从当前值平滑过渡到新目标。tween是基于持续时间的动画,不是物理模拟。它不会模拟惯性、弹跳或摩擦,而是严格按照给定的时间和曲线,算出每一帧应该显示什么值——结果是完全可预测的。label只是给调试工具(如 Animation Preview)用的标签,对动画效果没有任何影响。
因为 tween 完全由时间和曲线决定,所以 EXPAND_DURATION_MS 和 easing 的组合就决定了动画的节奏。
两个端点高度的变化只影响动画要走多远,不会改变动画要花多长时间——这是两个独立的维度。
三种 Easing 对比
FastOutSlowInEasing 是 Material Design 的默认缓动曲线。打个比方:它就像一辆车起步时猛踩油门快速提速,快到目的地时提前松油门平稳刹停。体现在卡片上,就是前半段展开很快,后半段逐渐放慢、轻柔地停下来,不会有任何生硬的"撞墙感"。
我们还提供了两种替代方案:
- LinearEasing:匀速运动,每一帧移动的距离完全相同。听起来很"完美",但实际效果却很机械——因为现实中任何物体的启动和停止都有加减速过程,匀速运动反而让人觉得不自然。
- FastOutLinearInEasing:起步和
FastOutSlowIn一样快速,但后半段不会减速,而是保持匀速冲到终点。就像一辆车猛踩油门起步,到了目的地却不刹车,直接撞上终点线——对于要飞出屏幕的元素来说很合适,但对于需要"停稳"的元素就显得粗暴了。
对于一个打开后需要停在原地的卡片,FastOutSlowInEasing 是最佳选择。它收尾时的减速恰好给用户一种"卡片已经到位、稳稳落定"的感觉。反过来,如果元素要离开屏幕(比如关闭动画),那就应该选收尾时加速的曲线,让它"加速离开"。
缓动曲线不只是让动画"好看",它其实是在传达一种物理直觉——让用户通过动画的节奏感,直觉地理解元素正在做什么。
各常量对感觉的影响
逐一分析每个常量对动画感受的具体影响:
- EXPAND_DURATION_MS:设为
400时,展开动画从容而不拖沓。降到150左右,运动变得干脆利落、几乎瞬间完成,适合功能性面板,但会丧失"正在打开"的感知。推到700以上,卡片开始显得迟钝,尤其在用户连续点击、需要等待上一次动画完成时。 - 缓动曲线:换成
LinearEasing,卡片以恒定速度打开,运动感觉僵硬。换成FastOutLinearInEasing,卡片以全速到达展开高度,显得突兀。保持FastOutSlowInEasing则产生柔和的落定效果,与 Material Motion 整体风格一致。 - COLLAPSED_HEIGHT:设为
160.dp时,折叠卡片显示标题行和几行正文。降到约80.dp,只有标题行可见,折叠状态变成了一个头部概览。提高到接近EXPANDED_HEIGHT,动画几乎没有距离可走,展开变成轻微推动而非明确手势。 - EXPANDED_HEIGHT:设为
330.dp,卡片舒适地显示全部文本内容,内边距充裕。减小它,底部内容会被卡片边缘裁剪,尽管布局系统仍认为它们存在。增大到远超内容尺寸,卡片底部变成空白区域。
最好的做法,高度应该由内容驱动,而非固定Dp值。
一点想法
本文通过一个可展开卡片的实例,讲解了 animateDpAsState 配合 tween 规格的用法,核心内容包括:
- 决定动画效果的四个量:持续时间、缓动曲线、折叠高度、展开高度
targetValue如何响应布尔值变化并触发动画FastOutSlowInEasing、LinearEasing和FastOutLinearInEasing在驱动相同高度变化时的差异
理解这些原理之后,你就不会把动画参数当成"随便填个默认值"了。
animateDpAsState 并不是什么魔法——它的本质很简单:接收一个目标值和一条缓动曲线,然后在每一帧算出当前应该显示多少。
掌握了这个核心,在 tween 和 spring 之间、在不同缓动曲线之间做选择,其实就是在回答一个问题:你希望这个元素"动起来像什么"?是轻柔落地,还是弹跳着到位,还是匀速滑过去?
无论你正在构建内联展开的卡片、滑入视图的底部弹出框,还是放大为主图的缩略图,模式都是一样的:选择起点和终点、选择持续时间、选择与元素行为匹配的缓动曲线。