Apple 设计 — 网页版
初始响应
当此技能首次被调用且没有具体问题时,仅回复:
我已准备好帮助你构建流畅的、Apple 风格的 Web 界面。我的知识来自 Apple 的 WWDC 设计演讲,为你翻译到了 Web 平台。
在用户提出问题之前,不要提供任何其他信息。
Apple 如何构建那些不再像计算机、而像你身体延伸的界面。这些知识来自 Apple 的 WWDC 设计演讲——主要是 《设计流畅界面》(WWDC 2018)——经过提炼并转化为 Web 平台(CSS、Pointer Events、requestAnimationFrame、像 Motion/Framer Motion 这样的弹性库)。
贯穿始终的主线是:当界面的运动从当前屏幕值开始,继承用户的速度,向前投射动量,并且可以随时被抓住和反向时,这个界面就有了生命力。 弹性(Spring)是让这一切变得自然的工具,因为它们天生可中断且能感知速度。
核心理念
“当我们使界面与我们思考和移动的方式一致时,神奇的事情就会发生——它不再像一台计算机,而开始感觉像我们自己身体的无缝延伸。”
当界面像物理世界一样运作时,它就是流畅的:事物即时响应,连续运动,携带动量,在边界处抵抗,并且可以在运动中被重新导向。以下所有内容都是为接近这种感觉而服务。
Apple 将设计框架为满足人类的四种需求:安全感/可预测性、理解、成就感和愉悦感。 这里的每一条规则都在服务于其中一种需求。
1. 响应——消除延迟
一旦出现延迟,直接感就会“断崖式下跌”。响应是所有其他功能的基础。
- 在指针按下时立即响应,而不是在松开时。 按钮一被按下就高亮显示。等到
click/触摸释放才展示反馈会感觉死气沉沉。 - 警惕每一次延迟。 审计所有去抖(debounce)、人工定时器、过渡等待以及约 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. 可中断性——最重要的原则
“思考和手势是并行发生的。”
每一个动画都必须在任何时候都是可中断且可重定向的。用户必须能够在元素运动途中抓住它并反向,而无需等待动画完成。当用户再次抓住一个正在关闭的模态框时,它应该跟随手指——而不是先完成关闭再重新打开。
- 永远不要在过渡期间锁定输入。
- 总是从 呈现(当前)值开始动画,而不是目标值。 中断时,读取元素屏幕上的实时 transform,并从那个值开始新的动画。从逻辑/目标值开始会导致明显的跳跃。
- 避免为任何手势驱动的动画使用 CSS transitions 和
@keyframes——它们无法在运动过程中被平滑地抓住和反向。弹性动画默认从当前值开始,这正是中断所需要的。 - 当手势反向时,融合速度——不要硬切。 在反向时用一个动画替换另一个会造成速度不连续,像一堵“砖墙”。能通过重新定位目标来传递速度的弹性库可以避免这个问题。(这就是 iOS 原生 加法动画 所做的;在 Web 上,选择一个能根据当前速度重新定位目标的弹性库。)
- 将 2D 运动分解为独立的 X 和 Y 弹性动画。 如果一个 2D 距离只用一个弹性来驱动,当 X 和 Y 速度不同时会失去同步。
4. 行为重于动画——使用弹性
“把动画想象成你与对象之间的对话,而不是界面预设好的东西。”
一个预先编写好的、固定时长的动画无法响应新的输入。弹性可以——新的输入只是改变目标,运动保持连续。所有用户可以触摸的东西都应使用弹性。
Apple 特意用两个设计师友好的参数替换了物理三元组(质量/刚度/阻尼)。请用这些来思考:
- 阻尼比——控制过冲。
1.0= 临界阻尼,无弹跳,平滑稳定。< 1.0= 过冲并振荡。越低越有弹性。 - 响应时间——值到达目标的速度,以秒为单位。越低越敏捷。这不是“时长”——弹性没有固定时长;它的稳定时间由参数共同决定。
默认值:
- 大多数 UI 从 阻尼
1.0(临界阻尼)开始——优雅且不分散注意力。 - 仅当手势本身带有动量时(一甩、一扔、拖拽释放),才添加弹跳(阻尼约
0.8)。一个刚刚淡入的菜单过冲会感觉很奇怪;但你甩出去的一张卡片过冲感觉却很对。
Apple 发布的实际值:
| 交互 | 阻尼 | 响应时间 |
|---|---|---|
| 移动/重新定位(如画中画) | 1.0 | 0.4 |
| 旋转 | 0.8 | 0.4 |
| 抽屉/面板 | 0.8 | 0.3 |
Web 映射(Motion / Framer Motion): bounce + duration 的弹簧 API 与 Apple 的阻尼+响应非常接近。一个安全的风格是默认在所有地方使用 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. 动量投射——动画到手势 要去 的地方
“用小输入产生大输出。”
不要从 释放点 吸附到最近的边界。使用速度来 投射出静止位置——就像滚动减速一样——然后吸附到离那个投射点最近的目标。这能让一次甩动感觉像是把元素扔出去一样。
Apple 的精确投射函数(来自《设计流畅界面》示例代码):
// 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) 不是 Apple 使用的——请使用上面的指数衰减形式。这是良好的底部面板和轮播(如 Vaul、Embla)中的标准行为。
7. 空间一致性——对称的路径,锚定的原点
“如果某物从一个方向消失,我们期望它从它来的地方出现。”
- 沿相同路径进入和退出。 一个从右侧滑入的面板必须从右侧关闭。从右侧进/从底部出会让人感觉脱节和困惑。
- 将交互锚定到其来源。 菜单、弹出框或面板应从触发它的元素处产生——将
transform-origin设置为触发器,使按钮和内容之间的空间关系显而易见。(这与弹出框从其触发器缩放、而非从中心缩放的原点感知点是相同的。) - 在可逆过渡上镜像缓动效果,使离开路径与返回路径匹配(对两个方向使用相反的 cubic-bézier 控制点)。
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 的与显示同步的时钟(Apple 使用CADisplayLink)。仅对合成器友好的属性做动画——transform和opacity——并在即将发生运动的地方用will-change提示。
12. 材质与层级——半透明传达层级
Apple 使用半透明材质作为浮动的功能层,带来结构感但不抢夺焦点。在 Web 上,使用 backdrop-filter 近似实现。
- 将导航栏/工具栏/面板构建为半透明层(
backdrop-filter: blur()+ 一个半透明背景),内容在其下滚动——而不是占用固定区域的完全不透明条。 - 材质重量编码层级: 更深/更重的材质分离结构性区域(侧边栏);更轻的材质吸引注意力到交互元素(按钮)。永远不要将一个浅色半透明表面叠在另一个上面——可读性会崩溃。
- 更大的表面应显得更厚: 比小卡片有更强的模糊和更深的阴影。考虑上下文相关的阴影——在繁忙/文字内容上更重以获得分离感,在平淡背景上更轻。
- 调暗以聚焦,分离以保持流程。 一个模态任务会将表面配合一个调暗的遮罩层,并将背景推后/压下。一个并行的、非阻塞的面板使用半透明和偏移量,没有遮罩层,以便不中断流程。对于堆叠的面板,逐渐调暗并推后每一个父层。
- 活力效果保持文字在变化背景上的可读性。 在模糊/半透明表面上,不要使用纯灰色文字——使用更高对比度、稍粗的字体重量和一个小的字距增加。把颜色放在实色层上,而不是半透明前景上。
- 滚动边缘效果,而非硬分隔线。 代替 sticky 标题下的 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. 多模态反馈——运动 + 声音 + 触觉
关于组合感官的三条规则(来自《设计音频-触觉体验》):
- 因果关系——反馈的原因必须显而易见。在真正的原因事件上触发它(开关翻转、项目吸附归位),并使其特性与动作的物理性相匹配。
- 和谐——视觉、声音和触觉必须在 同一帧 上触发。它们之间的延迟会摧毁这种错觉。不要让