核心结论 :苹果的滑动手感之所以「有肉」,不是因为动画做得花,而是因为它按真实物理在动 。拆开看是四层:① 1:1 跟手 (内容跟着手指走,零延迟,像抓住了东西);② 越界阻尼 (滑到边界还能再拉一点,但越拉越沉、渐近饱和,像拉橡皮筋);③ 弹簧回弹 (松手后按弹簧物理回到目标,有加速度、会轻微过冲,不是线性减速);④ 动量惯性 (甩得越快滑得越远)。而大多数网页用
linear/ease缓动,本质是「按时间表移动」,没有质量、没有加速度、没有边界弹性------所以「脆」,没有重量感。想做出肉感,只需把「时间驱动的缓动」换成「力驱动的弹簧 + 阻尼函数」。
目录
-
- 一句话结论
-
- 「肉感」到底是什么:不是视觉,是物理
-
- 第一层:1:1 跟手,先「抓住」再谈手感
-
- 第二层:越界阻尼(Rubber Banding)
-
- 第三层:松手回弹(Spring Animation)
-
- 第四层:动量惯性(Velocity Projection)
-
- 为什么 linear / ease「假」:时间表 vs 物理力
-
- 动画曲线对比(实测截图)
-
- 完整可运行代码:橡皮筋 + 弹簧求解器
-
- 对照表:症状 → 原因 → 解法
-
- 判断收尾
1. 一句话结论
先建立一个直觉 🎭:苹果的滑动像「推一个挂了弹簧的球」 ------你推它,它跟手;推到头,弹簧被拉伸,越拉越费劲;松手,弹簧把球拉回去,还会小小地晃一下。而普通网页的滑动像「拖动一张贴在墙上的纸」------到墙边就停,没有回弹,没有重量。
四层物理对应四种手感:
- 跟手:让你觉得「我抓住了它」。
- 越界阻尼:让你觉得「它有边界,但边界是软的」。
- 弹簧回弹:让你觉得「它有质量,会晃」。
- 动量惯性:让你觉得「它记得我甩了多快」。
一句话:肉感 = 把「按时间播放的动画」换成「按物理演化的运动」。
2. 「肉感」到底是什么:不是视觉,是物理
很多人以为「肉感」是视觉风格(颜色、阴影、圆角)。其实它完全是动觉(kinesthetic)的:
- 同样的界面,用
ease滑 → 「硬、脆、塑料感」。 - 换成弹簧物理 → 「软、沉、有肉感」。
- 差别不在长得像不像,而在运动是否遵循物理规律。
WWDC18 的《Designing Fluid Interfaces》讲得最清楚:把滚动内容想象成一个连着弹簧的球------弹簧一端是「当前显示位置」,另一端是「内容因弹性想去的位置」,弹簧的拉力决定内容怎么动。
| 维度 | 普通缓动 | 苹果肉感 |
|---|---|---|
| 驱动方式 | 时间表(0.3s 走完) | 物理力(弹簧拉力) |
| 有无质量 | 无 | 有(惯性) |
| 边界行为 | 硬停 | 软边界(越界阻尼) |
| 结束方式 | 到点即停 | 收敛 + 轻微过冲 |
| 手感 | 脆、塑料 | 沉、有肉 |
参考资料:
3. 第一层:1:1 跟手,先「抓住」再谈手感
肉感的第一前提是跟手(direct manipulation):
- 手指移动 1px,内容就移动 1px------边界之内完全 1:1。
- 不能有延迟 :任何"平滑跟随"(如
transition: 0.1s)都会让人觉得「滑腻、不跟手」。 - 也不能加缓动:跟手阶段加缓动 = 手和内容「脱节」。
用类比 🎭:这就像「抓娃娃机」------爪子跟着摇杆动,你才有控制感;如果爪子延迟半秒才动,你会立刻觉得「这机器坏了」。
⚠️ 常见错误:给拖动本身加
transition。正确做法是:拖动时位移直接等于手指位移(无过渡),只有松手后才交给弹簧动画。
4. 第二层:越界阻尼(Rubber Banding)
跟手做对了,接下来是最有辨识度的一层------越界阻尼(rubber banding)。
- 边界内:内容 1:1 跟手。
- 越界后 :真实位移 = 手指位移经过一个阻尼函数压缩 ,越拉越沉、渐近饱和(怎么拉都到不了无穷)。
- 松手:弹回边界。
苹果风格的阻尼公式(业界常用近似):
offset=limit+d+c×∣overshoot∣overshoot×d×c
其中 overshoot 是手指越界的距离,d 是容器尺寸,c 是阻尼常数(常用约 0.55)。
- 当
overshoot很小时,式子近似线性(跟手)。 - 当
overshoot很大时,分母被|overshoot|主导,结果趋近于常数------越拉越拉不动。
用类比 🎭:这就是「拉橡皮筋」 ------开始拉很轻松,拉到一定程度就越来越费劲,最后几乎拉不动。手感上的「肉」,就来自这条渐近饱和曲线。
5. 第三层:松手回弹(Spring Animation)
松手之后,内容不是「匀速回到原位」,而是按弹簧物理回弹:
- 加速度来自弹簧力 :越远离目标,拉力越大(
F = -k × 位移)。 - 阻尼消耗能量:阻尼越大,晃得越少、越快停。
- 临界阻尼 :刚好不过冲,直接收敛------苹果默认 UI 动画常用这个(
damping 1.0,response 0.3--0.4s)。 - 欠阻尼 :会轻微过冲再回落------甩动/翻页这类「有动量」的场景用(
damping ≈ 0.8)。
两个关键参数:
| 参数 | 含义 | 苹果常用值 |
|---|---|---|
| response | 到达目标的大致时间感 | 0.3--0.4s |
| damping | 阻尼比(1 = 临界,<1 会过冲) | 默认 1.0;甩动 0.8 |
用类比 🎭:这就像「推门」 ------轻轻推,门缓缓回到关闭位(临界阻尼);用力推,门会「咚」地撞一下再弹回来(欠阻尼过冲)。肉感来自「它会晃,但晃得住」。
6. 第四层:动量惯性(Velocity Projection)
第四层是最容易被忽略、但最影响「顺滑感」的------动量:
- 松手那一刻,系统读取手指速度,预测「按这个速度滑下去会停在哪」。
- 速度越快,目标位置越远(甩一下能滑很远)。
- 然后把这个「预测目标」交给弹簧,让它滑向那里。
为什么重要?
- 没有动量:松手即停,像「刹车失灵后被强行拽住」。
- 有动量:松手后继续滑、慢慢减速停下------像真实物体有惯性。
用类比 🎭:这就像「扔纸飞机」 ------你扔得越快,它飞得越远,然后受阻力慢慢落下。肉感里有一部分就是「我甩出去的力被记住了」。
7. 为什么 linear / ease「假」:时间表 vs 物理力
现在能回答核心问题了:为什么 linear / ease 没有肉感?
linear/ease是「时间表」 :规定「0.3 秒内从 A 到 B」,然后按固定曲线插值------没有质量、没有加速度、没有边界弹性。- 弹簧是「物理力」 :每帧计算「弹簧拉力 → 加速度 → 速度 → 新位置」------有质量、会过冲、受阻尼。
| 对比 | CSS 缓动 | 弹簧物理 |
|---|---|---|
| 结束 | 到点精确停止 | 收敛,可能轻微过冲 |
| 中途被打断 | 从头重新算 | 保留当前速度,平滑接续 |
| 边界 | 无概念 | 可做越界阻尼 |
| 手感 | 机械、精确 | 有机、有重量 |
关键差异在「可打断」 :手指中途改变方向时,弹簧动画能保留当前速度继续算(所以顺滑),而 CSS 缓动会「重新开始一段动画」(所以有顿挫)。
💡 这也是为什么苹果强调「用弹簧替代缓动」------不是因为弹簧"更好看",而是因为弹簧在多次打断下依然连续。
8. 动画曲线对比(实测截图)
下面这张图是同一动作的两种实现对比:左边是普通滚动(边界硬停),右边是苹果肉感(越界阻尼 + 回弹);下半部分是「线性缓动」与「弹簧动画」的时间-位移曲线对比。

- 左侧曲线:到边界直接截断,形成硬折角------手感「脆」。
- 右侧曲线:越界后平滑收敛,还带一个小回弹------手感「有肉」。
- 底部曲线:红色是匀速直线(无物理),绿色是先冲过目标再回落(弹簧)。
9. 完整可运行代码:橡皮筋 + 弹簧求解器
下面是一段复制粘贴就能跑 的 Python 代码,实现两个核心函数:rubber_band() 做越界阻尼,spring_step() 做弹簧物理积分。不用第三方库,直接打印位移序列,你能看到「越拉越沉」和「过冲回落」。
python
# 苹果肉感的两块核心:越界阻尼 + 弹簧求解器
def rubber_band(overshoot, dimension, constant=0.55):
"""
越界阻尼:把越界位移压缩成渐近饱和的真实位移
参数:
overshoot: 手指越界的距离(>0)
dimension: 容器尺寸(如屏幕高度)
constant: 阻尼常数,越小越"硬"
返回: 压缩后的真实越界位移
"""
return (overshoot * dimension * constant) / (dimension + constant * abs(overshoot))
def spring_step(x, v, target, stiffness=180.0, damping=20.0, dt=1/60):
"""
弹簧物理积分一步:返回新的位置与速度
参数:
x: 当前位置, v: 当前速度, target: 目标位置
stiffness: 刚度(越大回得越快)
damping: 阻尼(越大晃得越少)
dt: 时间步长(帧间隔)
返回: (新位置, 新速度)
"""
force = -stiffness * (x - target) # 弹簧力:越远拉力越大
accel = force - damping * v # 加速度 = 弹簧力 - 阻尼
v = v + accel * dt # 速度积分
x = x + v * dt # 位置积分
return x, v
if __name__ == "__main__":
# ① 越界阻尼:越拉越沉,渐近饱和(永远拉不到无穷)
print("越界阻尼:手指拉得越远,实际位移增长越慢")
for pull in [0, 50, 100, 200, 400, 800]:
real = rubber_band(pull, 800)
print(f" 手指越界 {pull:>3}px → 实际位移 {real:6.1f}px")
# ② 弹簧回弹:从偏移位置回到 0,会轻微过冲
print("\n弹簧回弹(目标 0,初始偏移 100)")
x, v = 100.0, 0.0
for frame in range(40):
x, v = spring_step(x, v, 0.0)
if frame % 4 == 0: # 每 4 帧打印一次
print(f" 第 {frame:>2} 帧:位置 {x:7.2f},速度 {v:8.2f}")
怎么读这段代码 :rubber_band() 就是第 4 节的阻尼公式------你可以看到手指拉 400px 时实际只走 150 多 px,拉 800px 也没到 200px,永远到不了无穷 ;spring_step() 是第 5 节的弹簧积分------force = -stiffness * (x - target) 是胡克定律,减去 damping * v 消耗能量,位置会先冲过 0 再回弹 (打印结果里会出现负值)。把这两个函数接到浏览器里(spring_step 每帧调用、rubber_band 用于越界位移),就是一套最小可用的「苹果肉感」。
10. 对照表:症状 → 原因 → 解法
| 症状 | 原因 | 解法 |
|---|---|---|
| 滑动「脆」、没重量 | 用 linear / ease 缓动 |
换弹簧物理(stiffness + damping) |
| 拖动手感「滑腻、不跟手」 | 拖动阶段加了 transition |
拖动时位移 1:1,松手才动画 |
| 滑到边界「撞墙」很硬 | 没有越界阻尼 | 加 rubber band 阻尼函数 |
| 松手立即停、没有惯性 | 没读速度做动量预测 | 读取松手速度 → 投影目标位置 |
| 中途改变方向会顿挫 | 缓动被打断就重算 | 用保留速度的弹簧动画 |
| 回弹晃得太过、头晕 | 阻尼比太低(过冲太大) | 提高 damping(接近 1.0) |
11. 判断收尾
什么时候该上这套物理 :你的产品有拖动、滚动、翻页、抽屉 这类直接操作,且你希望手感「高级、有重量」------这时弹簧 + 越界阻尼几乎是标配,投入产出比很高。什么时候可以不用 :纯表单、低频点击的后台页面,或性能极弱的设备------这时简单的 ease 足够,上物理动画反而增加复杂度与性能负担。判断标准只有一条:用户会不会「用手拖」这个界面------会拖,就值得做肉感;只点不拖,就别为动效加复杂度。
本专栏《全栈开发》覆盖前端工程、UI 与交互设计、开发工具链,每篇配可直接运行的代码与实战对比,持续更新。想做出「有手感」的界面,可在专栏页订阅。
参考资料(综合):