那条曲线:贝塞尔与弹簧
上一章决定了「加不加」。这一章决定「怎么动」——而这两个字背后是两套完全不同的模型,选错了会让动效说出你不想说的话。
线性动画为什么看着假
先说一个最基础的事实:现实世界里没有匀速运动。
任何有质量的东西启动时要加速,停下时要减速。当一个元素在屏幕上匀速滑过去然后戛然而止,你的视觉系统会立刻标记为「不对劲」——虽然你说不出为什么。
所以第一条规矩很短:永远不要用 LinearEasing,除非你在做进度条或者旋转加载圈(那些确实该匀速)。
缓动曲线:你给时长,它给节奏
缓动曲线就是一条从 (0,0) 到 (1,1) 的三次贝塞尔。你给两个控制点,它决定「这段时间里,进度怎么分配」。
切到几条 Material 的曲线,看「走完一半用了多少」这一格:
| 曲线 | 控制点 | t=0.5 时走了 | 最快的一刻 |
|---|---|---|---|
Linear | 0, 0, 1, 1 | 0.500 | 全程匀速 |
M2 FastOutSlowIn | 0.4, 0, 0.2, 1 | 0.776 | 30% 处 |
M3 Standard | 0.2, 0, 0, 1 | 0.878 | 12% 处 |
M3 EmphasizedDecelerate | 0.05, 0.7, 0.1, 1 | 0.950 | 起步瞬间 |
M3 StandardAccelerate | 0.3, 0, 1, 1 | 0.372 | 结束瞬间 |
t=0.5 时走了多少,决定了这条曲线的性格:
- > 0.7 —— 减速型。前面猛、后面轻轻落下。用于东西进来:弹窗出现、面板展开、页面推入。它让元素显得有重量但不迟钝。
- < 0.4 —— 加速型。起步慢、后面加速冲出去。用于东西离开:弹窗关闭、页面退出。因为离开的东西不需要你继续看它。
- ≈ 0.5 —— 标准型。进出都行,最保险。
一句话记法:进来要慢慢停,离开要快快走。
这个 Demo 里的曲线不是画的。贝塞尔曲线是参数式的——给定 x(时间进度),你没法直接求 y(动画进度),必须先反解出参数 t。做法是牛顿迭代,退化时用二分兜底。浏览器求 cubic-bezier() 就是这么干的,这里实现的是同一套。
弹簧:你给物理,它给时长
弹簧和缓动曲线最大的不同,是你不给它时长。
你给的是「多硬」(stiffness)和「多黏」(dampingRatio),时长是算出来的。切到 Demo 的弹簧模式:
| 配置 | 类型 | 稳定时间 | 冲过头 |
|---|---|---|---|
| NoBouncy 1.0 / Medium 1500 | 临界阻尼 | 172ms | 不冲 |
| MediumBouncy 0.5 / Medium 1500 | 欠阻尼 | 243ms | 16.3% @ 94ms |
| LowBouncy 0.75 / Low 200 | 欠阻尼 | 468ms | 2.8% @ 336ms |
| HighBouncy 0.2 / MediumLow 400 | 欠阻尼 | 1080ms | 52.7% @ 160ms |
临界阻尼:数学上「在不回弹的前提下,最快到达终点」的那个值。
比 1 小 → 欠阻尼,会冲过头再荡回来。
比 1 大 → 过阻尼,不会冲过头,但会拖泥带水地慢慢靠近。
Compose 的默认值就是 DampingRatioNoBouncy = 1f,因为它最不容易出错。
看最后一行:HighBouncy 冲过头 52.7%、要 1080ms 才稳下来。这个幅度在真实 App 里几乎总是太大了——它会让人以为界面「弹了一下没接住」。
这条曲线是阻尼谐振子的闭式解,不是拿几段贝塞尔拼的:
// 欠阻尼(ζ < 1):指数衰减 × 正弦振荡 ω₀ = √(k/m) // 自然频率,Compose 里 m = 1 ωd = ω₀·√(1 − ζ²) // 阻尼频率 x(t) = e^(−ζω₀t) · (c₁·cos(ωd·t) + c₂·sin(ωd·t)) // 临界阻尼(ζ = 1) x(t) = (c₁ + c₂·t) · e^(−ω₀t) // 稳定时间:位移和速度同时低于阈值。 // 必须数值扫描 —— 因为欠阻尼时位移会周期性穿过零点, // 只看位移会误判成「已经到了」。
什么时候用哪个
用弹簧:手势松手后的收尾、拖拽回弹、下拉刷新、可以被中途打断的东西。
因为弹簧有速度状态——你甩得快,它就飞得远;动画进行中你又碰了一下,它能从当前速度接着走,不会跳变。缓动曲线做不到这个,中途打断会有明显的顿挫。
用缓动曲线:确定性的转场、显隐、颜色变化、进度。这些没有「手指的速度」这回事,给个时长更好控制,也更容易和别的动画对齐。
时长该配多少
Material 3 给了一套时长 token,你的活就是从里面挑,不是现编:
| token | 毫秒 | 用在哪 |
|---|---|---|
short2 | 100 | 状态层、涟漪——「立刻」 |
short4 | 200 | 小元素显隐、图标切换 |
medium2 | 300 | 卡片展开、面板滑入 |
long2 | 500 | 整页转场 |
extraLong2 | 800 | 大面积、强调性的入场(慎用) |
但不是线性的。一个滑 400dp 的面板不该比滑 100dp 的慢四倍——那会显得像在水里。大致按平方根走。
另外两条边界:
- 下限 100ms:比这更短,人眼看到的是「闪了一下」而不是「动了一下」,因果感反而没了。
- 上限 500ms:超过这个数,用户开始等你。除非是首次入场这种一辈子只看一次的。
// 缓动:时长和曲线都从 token 取,不写字面量 val enterSpec = tween( durationMillis = MotionTokens.medium2, // 300 easing = MotionTokens.emphasizedDecelerate // 进来 → 减速型 ) val exitSpec = tween ( durationMillis = MotionTokens.short4, // 200,离开要更快 easing = MotionTokens.emphasizedAccelerate // 离开 → 加速型 ) AnimatedVisibility( visible = expanded, enter = expandVertically(enterSpec) + fadeIn(enterSpec), exit = shrinkVertically(exitSpec) + fadeOut(exitSpec) ) // 弹簧:手势收尾。注意 visibilityThreshold —— 它决定什么时候算「到了」 val offset = remember { Animatable(0f) } offset.animateTo( targetValue = 0f, animationSpec = spring( dampingRatio = Spring.DampingRatioNoBouncy, // 1.0,不回弹 stiffness = Spring.StiffnessMediumLow // 400 ), initialVelocity = flingVelocity // ← 手指甩出去的速度接进来 )
注意最后那个 initialVelocity:这是弹簧存在的意义。用户手指离开屏幕时是有速度的,把这个速度接进动画,动效才会有「跟手」的感觉。用 tween 就没有这一环,无论你调什么曲线都会觉得「有一点点断」。
- 「用哪条曲线」——进来用减速型,离开用加速型,其余用 Standard。永远不用 Linear。
- 「时长写多少」——从 M3 时长 token 里挑。下限 100ms,上限 500ms,距离远的按平方根加。
- 「用 tween 还是 spring」——手势驱动、可被打断的用 spring(并接上
initialVelocity);确定性转场用 tween。