Apple Design
苹果如何构建那些不再像电脑、而更像你自身延伸的界面。这些知识源自苹果 WWDC 的设计演讲——主要是《设计流体界面》(WWDC 2018)——经过提炼并转化为 Web 平台(CSS、Pointer Events、requestAnimationFrame、Motion/Framer Motion 等弹簧库)。
贯穿始终的主线是:当界面的运动从当前屏幕显示的值开始,继承用户的速度,向前投影动量,并且可以在任意时刻被抓住并反转时,它就有了生命。 弹簧是实现这一切自然流畅的工具,因为它天生可中断且感知速度。
核心理念
"当我们将界面与我们思考和移动的方式对齐时,神奇的事情发生了——它不再感觉像一台电脑,而开始感觉像我们自身的无缝延伸。"
当界面像物理世界一样运作时,它就是流畅的:事物瞬间响应,连续移动,携带动量,在边界处产生阻力,并且可以在运动中途被重新导向。以下是实现这一目标的各种方法。
苹果将设计定义为服务于人类的四种需求:安全/可预测性、理解、成就和愉悦。 每一条规则都在服务于其中某一种需求。
1. 响应——消灭延迟
一旦出现滞后,直接操控的感觉就会"断崖式下跌"。响应是所有其他一切的基础。
- 在指针按下时响应,而不是在释放时。 按钮被按下的瞬间就要高亮。等待
click/触摸抬起才显示反馈会让人感觉死气沉沉。 - 警惕每一次延迟。 审查去抖、人为计时器、过渡等待以及约 300ms 的点击延迟。输入路径上任何非必要的东西都是退化。
- 反馈必须在交互过程中持续,而不仅仅在结束时。 对于拖拽、滑块或抽屉,UI 必须全程 1:1 跟随指针——绝不要只在手势完成时进行动画。
/* 反馈存在于按下瞬间,且是即时的 */
.button:active {
transform: scale(0.97);
transition: transform 100ms ease-out;
}
2. 直接操控——1:1 跟踪
"触摸和内容应该一起移动。"
当用户拖拽某物时,它必须粘在手指上——并且要尊重它们抓取时的偏移。在抓取时将元素居中会立即打破这种错觉。
- 使用 Pointer Events 和
setPointerCapture,这样即使指针离开元素边界,跟踪也能继续。 - 跟踪一个简短的速度/位置历史(最近几次
pointermove事件),而不仅仅是当前点——你会在释放时用到速度。
el.addEventListener('pointerdown', (e) => {
el.setPointerCapture(e.pointerId);
const grabOffset = e.clientY - el.getBoundingClientRect().top; // 尊重抓取位置
// ...记录位置+时间戳历史用于速度计算
});
3. 可中断性——最重要的原则
"思维和手势是并行发生的。"
每个动画必须可以在任何时刻被中断和重定向。用户必须能够抓住一个正在移动的元素并反向移动它,而无需等待动画完成。一个正在关闭的、用户又重新抓住的模态框应该跟随手指——而不是先完成关闭再重新打开。
- 永远不要在过渡期间锁定输入。
- 始终从展示(当前)值开始动画,而不是目标值。 发生中断时,读取元素的实时屏幕变换,并从那里开始新的动画。从逻辑/目标值开始会导致可见的跳跃。
- 避免对任何手势驱动的内容使用 CSS 过渡和
@keyframes——它们无法在运动中途被平滑地抓住和反转。弹簧默认从当前值开始动画,这正是中断所需要的。 - 当手势反转时,混合速度——不要硬切。 在反转点用一个动画替换另一个动画会产生速度不连续性,就像撞到"砖墙"。弹簧库在重新定位时会携带速度,从而避免这一点。(这就是 iOS 的加法动画的原生做法;在 Web 上,选择一种能从当前速度重新定位的弹簧库。)
- 将二维运动分解为独立的 X 和 Y 弹簧。 当 X 和 Y 速度不同时,一个二维距离上的单一弹簧会失去同步。
4. 行为优于动画——使用弹簧
"将动画视为你与物体之间的对话,而不是界面强加的东西。"
预设好固定时长的动画无法响应新的输入。而弹簧可以——新的输入只需改变目标,运动就能保持连续。对于任何用户可以触摸的东西,都使用弹簧。
苹果有意将物理三要素(质量/刚度/阻尼)替换为两个设计师友好的参数。用这些来思考:
- 阻尼比——控制过冲。
1.0= 临界阻尼,无弹跳,平滑稳定。< 1.0= 过冲并振荡。值越低越有弹性。 - 响应——值到达目标的速度,以秒为单位。值越低越快。这不是"持续时间"——弹簧没有固定时长;它的稳定时间由参数共同决定。
默认值:
- 大多数 UI 起始设为阻尼
1.0(临界阻尼)——优雅且不分散注意力。 - 仅当手势自身携带动量时(轻拂、抛掷、拖拽释放)才添加弹跳(阻尼约
0.8)。一个刚刚淡出的菜单过度反弹感觉不对;而你轻拂的卡片过度反弹则感觉自然。
苹果实际使用的具体数值:
| 交互 | 阻尼 | 响应 |
|---|---|---|
| 移动/重新定位(例如画中画) | 1.0 | 0.4 |
| 旋转 | 0.8 | 0.4 |
| 抽屉/面板 | 0.8 | 0.3 |
Web 映射(Motion / Framer Motion): bounce + duration 弹簧 API 与苹果的阻尼+响应非常接近。一种安全的基线样式是默认在所有地方使用 damping: 1.0 的弹簧;保留弹跳给动量驱动的、物理性的交互。
import { animate } from 'motion';
// 临界阻尼默认(无过冲)
animate(el, { y: 0 }, { type: 'spring', bounce: 0, duration: 0.4 });
// 动量交互——少量弹跳,仅因为前面有轻拂动作
animate(el, { y: target }, { type: 'spring', bounce: 0.2, duration: 0.4 });
5. 速度交接——拖拽与动画之间的无缝衔接
当手势结束时,动画必须以手指的确切速度继续,这样拖拽和动画之间就没有可见的接缝。这是区分"流畅"与"还行"的关键细节。
将指针的释放速度作为弹簧的初始速度传递。某些弹簧 API 需要相对速度——将其除以到目标的剩余距离进行归一化:
relativeVelocity = gestureVelocity / (targetValue − currentValue)
示例:元素在 y=50,目标 y=150(还有 100px 距离),手指移动速度 50px/s → 初始弹簧速度 = 50 / 100 = 0.5。Framer Motion / Motion 直接接受绝对 px/s 速度(velocity 选项),所以通常直接传递原始值。
6. 动量投影——动画到手势要去的地方
"用小输入产生大输出。"
不要从释放点快照到最近的边界。利用速度投影静止位置——就像滚动减速一样——然后快照到投影点最近的`目标。这就是为什么轻拂会感觉像把元素扔出去。
苹果的精确投影函数(来自《设计流体界面》示例代码):
// decelerationRate ≈ 0.998 为正常滚动感;0.99 为更干脆
function project(initialVelocity /* px/s */, decelerationRate = 0.998) {
return (initialVelocity / 1000) * decelerationRate / (1 - decelerationRate);
}
const projectedEndpoint = currentPosition + project(releaseVelocity);
const target = nearestSnapPoint(projectedEndpoint); // 从投影中选择目标
animateSpringTo(target, { velocity: releaseVelocity }); // 然后交接速度(§5)
注意:物理教科书中的 v²/(2·decel) 不是苹果实际使用的——请使用上述指数衰减形式。这是好的底部面板和轮播(Vaul、Embla)中的标准行为。
7. 空间一致性——对称路径、锚定原点
"如果某样东西以一种方式消失,我们期望它从它来的地方出现。"
- 进入和退出应走同一路径。 从右侧滑入的面板必须从右侧消失。从右侧进入/从底部出去会让人感觉脱节和困惑。
- 将交互锚定到其来源。 菜单、弹窗或面板应从触发它的元素处开始——将
transform-origin设置为触发器,这样按钮和内容之间的空间关系就显而易见了。(这与弹窗从其触发器缩放而不是其中心缩放的原点感知是一样的。) - 在可逆的过渡上镜像缓动,以便退出路径与返回路径匹配(为两个方向使用反相三次贝塞尔控制点)。
8. 向手势方向提供提示
人类通过轨迹预测最终状态。中间运动应该暗示事物将要去哪里——控制中心模块"向你的手指方向生长和展开"。让中间帧指向结果,而不是盲目地插值到它。
9. 橡皮筋效果——软边界
在边界处,逐渐抵抗而不是硬停。硬停感觉像"冻结";持续抵抗感觉像"有响应,但这里已经到头了"。应用阻尼,用户拖得越界越多,阻力越大。
// 超出边界越多,元素跟随越少——真实物体在停止前会减速
function rubberband(overshoot, dimension, constant = 0.55) {
return (overshoot * dimension * constant) / (dimension + constant * Math.abs(overshoot));
}
10. 手势设计细节("感觉"检查清单)
- 点击: 触摸按下时高亮(立即),触摸抬起时提交。在目标周围添加约 10px 的迟滞/点击填充,并允许通过拖走再回来取消。
- 拖拽/滑动: 在承诺一个方向之前,需要一个小量的移动阈值(迟滞,约 10px),然后 1:1 跟踪。
- 从第一次移动开始并行检测所有合理的手势,一旦意图明确,自信地取消失败者。避免只报告最终状态的识别器(如
swipeleft类事件)——它们丢弃了你做反馈所需的连续跟踪。 - 最小化解歧延迟。 双击检测不可避免地会延迟单击;只在确实存在双击的地方付出这个代价。
11. 逐帧平滑度
平滑度关乎帧中有什么,而不仅仅是帧率。
- 保持每帧位置变化低于感知阈值,以避免频闪效应。
- 对于非常快的运动,细微的运动模糊/拉伸能编码速度,比锐利的硬块能更好读。
requestAnimationFrame是 Web 的显示同步时钟(苹果使用CADisplayLink)。仅对合成友好的属性——transform和opacity——进行动画,并在运动即将发生时使用will-change提示。
12. 材质与深度——半透明传达层级
苹果使用半透明材质作为浮动的功能层,带来结构感而不抢焦点。在 Web 上,用 backdrop-filter 近似实现。
- 将导航栏/工具栏/面板构建为半透明层(
backdrop-filter: blur()+ 半透明背景),内容在其下方滚动——而不是用不透明的条占用固定区域。 - 材质重量传达层级: 更暗/更重的材质分隔结构区域(侧边栏);更轻的材质吸引注意力到交互元素(按钮)。绝不要将浅色半透明表面叠加在另一个上面——可读性会崩溃。
- 较大的表面应显得更厚: 比较小的"碎片"具有更强的模糊和更深的阴影。考虑上下文感知的阴影——在复杂的/有文本的内容上更重以获得分离,在简单的背景上更轻。
- 调暗以聚焦,分离以保持流程。 模态任务将表面与调暗的遮罩配对,并将背景推远/向下。并行的、非阻塞的面板使用半透明和偏移而不加遮罩,以免打断流程。对于堆叠的面板,逐步调暗并向远推每个父层。
- 色斑(Vibrancy)确保文本在变化背景上的可读性。 在模糊/半透明表面上,不要使用纯灰色文本——使用更高对比度、稍重字重和轻微的字间距提升。将颜色放在实色层上,而不是半透明的前景。
- 使用滚动边缘效果,而不是硬分割线。 不在粘性标题下放 1px 边框,而是在浮动 UI 与实际内容重叠的地方淡出一个小的模糊/渐变遮罩。
- 物质化,而不仅仅是淡入淡出。 对于玻璃/模糊表面,在进入/退出时同时动画模糊半径和缩放,使表面读起来像是真实的材料在到达,而不是简单的透明度淡入。
.toolbar {
background: rgba(255, 255, 255, 0.6);
backdrop-filter: blur(20px) saturate(180%);
border-top: 1px solid rgba(255, 255, 255, 0.4); /* 亮顶边 = 光照到材料上 */
}
13. 多模态反馈——运动+声音+触觉
结合感官的三个规则(来自《设计音频-触觉体验》):
- 因果性——反馈的原因必须显而易见。在实际的因果事件(开关翻转、项目卡入到位)时触发它,并使它的特征与动作的物理特性相匹配。
- 和谐——视觉、声音和触觉必须在同一帧触发。它们之间的延迟会摧毁错觉。不要让 CSS 过渡滞后于音频/触觉(Vibration API)。
- 实用性——只在有价值的地方添加反馈。保留触觉/声音给有意义的时刻(成功、错误、确认、对齐)。过度反馈会让用户忽略所有反馈。
14. 减少运动与无障碍
减少运动并不意味着没有反馈…
…[为翻译截断]