Apple 设计
初始响应
当此技能首次被调用且未提出具体问题时,仅回复:
我已准备好帮助你在 Web 上构建流畅、Apple 风格的界面,我的知识来自 Apple 的 WWDC 设计讲座,已为 Web 平台转化。
在用户提出问题前,不要提供其他信息。
Apple 如何构建那些不再像电脑、而像你身体延伸的界面。这些知识源自 Apple 的 WWDC 设计讲座——主要是《设计流畅界面》(WWDC 2018)——经过提炼并转化为 Web 平台(CSS, Pointer Events, requestAnimationFrame, 如 Motion/Framer Motion 等弹簧动画库)。
贯穿始终的主线:当动效从当前屏幕值开始、继承用户的速度、向前投射动量、并且可以在任何时刻被抓取和反转时,界面才会显得有生命力。 弹簧正是让这一切变得自然的工具,因为它本质上是可中断且能感知速度的。
核心理念
"当我们将界面与我们的思考和运动方式对齐时,神奇的事情就会发生——它不再像一台电脑,而开始感觉像我们身体的无缝延伸。"
一个流畅的界面,其行为应如同物理世界:事物即时响应、运动连续、携带动量、在边界处产生阻力,并且可以在运动途中被重新定向。下面的一切都是为了更接近这一目标。
Apple 将设计视为服务于人类的四种需求:安全/可预测性、理解、成就感和愉悦。 这里的每一条规则都为其中之一服务。
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. 可中断性——最重要的原则
"思考与手势是并行发生的。"
每一个动画都必须在任何时刻可中断、可重定向。用户必须能够抓住一个运动中元素的中途,并反转它,而无需等待动画完成。一个正在关闭的模态框,如果用户再次抓取它,应该跟随手指——而不是先完成关闭再重新打开。
- 永远不要在过渡期间锁定输入。
- 始终从呈现(当前)值开始动画,而不是目标值。 中断时,读取元素在屏幕上的实时 transform,并从该值开始新动画。从逻辑/目标值开始会导致明显的跳跃。
- 对于手势驱动的元素,避免使用 CSS transitions 和
@keyframes——它们无法在中途被平滑地抓取和反转。弹簧动画默认从当前值开始,这正是中断所需要的。 - 当手势反转时,应混合速度,而不是硬切。 在反转处用一个动画替换另一个动画会造成速度不连续性,就像一堵"砖墙"。能够通过重新定位目标来传递速度的弹簧库可以避免这个问题。(这正是 iOS 的叠加动画原生所有的能力;在 Web 上,选择一个能基于当前速度重新定位目标的弹簧库。)
- 将二维运动分解为独立的 X 和 Y 弹簧。 当 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()+ 半透明背景),内容在其下方滚动——而不是使用不透明栏占据固定区域。 - 材质重量编码层级: 较暗/较重的材质分隔结构区域(侧边栏);较轻的材质吸引对交互元素(按钮)的注意。绝不要将两个浅色半透明表面堆叠在一起——可读性会崩溃。
- 较大的表面应显得更厚: 比小卡片具有更强的模糊和更深的阴影。考虑上下文感知的阴影——在繁忙/文本内容上加重以确保分离度,在纯色背景上则减轻。
- 调暗以聚焦,分离以保持流畅。 模态任务将表面与调暗的遮罩配对,并将背景推后/压下。一个并行的、非阻塞的面板则使用半透明和偏移,而不用遮罩,以免打断流程。对于堆叠的面板,逐渐调暗并向后推每个父层级。
- 活力效果使文本在变化的背景上保持可读。 在模糊/半透明表面上,不要使用扁平灰色文本——使用更高对比度、稍重字重和轻微的字间距提升。将颜色放在实色层上,而不是半透明的前景层上。
- 使用滚动边缘效果,而不是硬分隔线。 与其在粘性标题下加一条 1px 边框,不如在浮动界面实际覆盖内容的地方,淡入一个小的模糊/渐变遮罩。
- 物化出现,而不仅仅是淡入。 对于玻璃/模糊表面,在进入/退出时同时动画模糊半径和缩放,使表面读作一个真实到达的材料,而不是一个纯粹的透明度淡入。
.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. 多模态反馈——运动 + 声音 + 触觉
三种感官结合的三条规则(来自《设计音频-触觉体验》):
- 因果性——反馈的原因必须显而易见。在实际的因果事件(如开关切换、元素到位)上触发反馈,并使其特性与动作的物理性相匹配。
- 和谐性——视觉、声音和触觉必须在同一帧内触发。它们之间的延迟会破坏这种错觉。不要让声音尝试跟随一个已经脱离的动画。同时触发视觉和触觉触发。使用触觉时间线事件在你需要的地方产生触觉。
- 连续性——避免使用仅出现一次、没有锚点的"一次性"声音。使用连续、交互式的音频,并使其与手势的进展同步变化,这样它就成为控制的一部分,而不是一种效果。
确保声音与动作的物理性相匹配:轻触匹配轻音,重推匹配更有分量的声音。对于由速度驱动的声音,直接使用手势速度来调制音频参数(例如,音量或 pitch)。