卷 V · 手· CH 18· 真求解器 深度 18/28

那条曲线:贝塞尔弹簧

上一章决定了「加不加」。这一章决定「怎么动」——而这两个字背后是两套完全不同的模型,选错了会让动效说出你不想说的话。

缓动曲线 = 给时长弹簧 = 给物理时长 token

线性动画为什么看着假

先说一个最基础的事实:现实世界里没有匀速运动。

任何有质量的东西启动时要加速,停下时要减速。当一个元素在屏幕上匀速滑过去然后戛然而止,你的视觉系统会立刻标记为「不对劲」——虽然你说不出为什么。

所以第一条规矩很短:永远不要用 LinearEasing,除非你在做进度条或者旋转加载圈(那些确实该匀速)。

缓动曲线:你给时长,它给节奏

缓动曲线就是一条从 (0,0) 到 (1,1) 的三次贝塞尔。你给两个控制点,它决定「这段时间里,进度怎么分配」。

切到几条 Material 的曲线,看「走完一半用了多少」这一格:

曲线控制点t=0.5 时走了最快的一刻
Linear0, 0, 1, 10.500全程匀速
M2 FastOutSlowIn0.4, 0, 0.2, 10.77630%
M3 Standard0.2, 0, 0, 10.87812%
M3 EmphasizedDecelerate0.05, 0.7, 0.1, 10.950起步瞬间
M3 StandardAccelerate0.3, 0, 1, 10.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欠阻尼243ms16.3% @ 94ms
LowBouncy 0.75 / Low 200欠阻尼468ms2.8% @ 336ms
HighBouncy 0.2 / MediumLow 400欠阻尼1080ms52.7% @ 160ms
阻尼比 = 1 是什么意思

临界阻尼:数学上「在不回弹的前提下,最快到达终点」的那个值。

比 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毫秒用在哪
short2100状态层、涟漪——「立刻」
short4200小元素显隐、图标切换
medium2300卡片展开、面板滑入
long2500整页转场
extraLong2800大面积、强调性的入场(慎用)
一条经验:动的距离越远、面积越大,时长越长

不是线性的。一个滑 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 就没有这一环,无论你调什么曲线都会觉得「有一点点断」。

本章关掉的自由度本章 3 条 · 累计 51 / 78
  • 「用哪条曲线」——进来用减速型,离开用加速型,其余用 Standard。永远不用 Linear。
  • 「时长写多少」——从 M3 时长 token 里挑。下限 100ms,上限 500ms,距离远的按平方根加。
  • 「用 tween 还是 spring」——手势驱动、可被打断的用 spring(并接上 initialVelocity);确定性转场用 tween。