Apple Design
初始响应
当首次调用此技能且没有具体问题时,仅回复:
我已准备好帮助您构建流畅的、苹果风格的Web界面。我的知识源自苹果WWDC设计讲座,已转化为Web实践。
在用户提出问题之前,不要提供任何其他信息。
苹果如何构建那些不再像电脑、而像你自身延伸的界面。这些知识来自苹果的WWDC设计讲座——主要是《设计流畅界面》(WWDC 2018)——经过提炼并转化为Web平台(CSS、指针事件、requestAnimationFrame、Motion/Framer Motion等弹簧动画库)。
贯穿始终的主线是:当动画从屏幕上的当前值开始,继承用户的速度,向前投射动量,并可在任何瞬间被抓住和逆转时,界面就有了生命力。 弹簧动画是让这一切变得自然的工具,因为它们天生具有可中断性和速度感知能力。
核心理念
“当我们让界面与我们的思考和移动方式对齐时,就会发生神奇的事情——它不再感觉像一台电脑,而开始感觉像我们自身的无缝延伸。”
一个流畅的界面,其行为就像物理世界:事物瞬间响应,连续移动,携带动量,在边界处产生阻力,并且可以在运动中途被重新定向。以下所有内容都是为了让界面更接近这种状态。
苹果将设计目标定义为满足人类的四种需求:安全感/可预测性、理解、成就和愉悦。 这里的每一条规则都服务于其中至少一项。
1. 响应——消除延迟
一旦出现延迟,直接操控的感觉就会“断崖式下跌”。响应是所有其他一切的基础。
- 在手指按下时响应,而不是在松手时。 按钮被按下的瞬间就应给出视觉反馈。等待
click/触摸抬起事件才显示反馈会显得死气沉沉。 - 警惕每一次延迟。 检查防抖、人为计时器、过渡等待时间以及大约 300ms 的点击延迟。任何输入路径上非必要的延迟都是倒退。
- 交互过程中反馈必须持续 存在,而不仅仅在结束时。 对于拖拽、滑块或抽屉,在指针移动的全过程中,UI 必须与指针 1:1 同步更新——绝不要只在手势完成时才播放动画。
/* 反馈存在于按下瞬间,且需即时触发 */
.button:active {
transform: scale(0.97);
transition: transform 100ms ease-out;
}
2. 直接操作——1:1 追踪
“触摸和内容应该一起移动。”
当用户拖拽某个元素时,它必须像粘在手指上一样移动——并且要尊重 抓取时的偏移量。在抓取时瞬间将元素吸附到中心会立刻打破沉浸感。
- 使用带
setPointerCapture的指针事件,这样即使指针移出元素边界,追踪也能持续。 - 记录一个简短的速度/位置历史(最近几次
pointermove事件),而不仅仅是当前点——你需要在释放时用到速度信息。
el.addEventListener('pointerdown', (e) => {
el.setPointerCapture(e.pointerId);
const grabOffset = e.clientY - el.getBoundingClientRect().top; // 尊重抓取位置
// ...记录位置+时间戳历史以计算速度
});
3. 可中断性——最重要的原则
“想法和手势是同时发生的。”
每一个动画都必须是可中断的,并且可以在任何时刻被重定向。用户必须能够在半空中抓住一个正在移动的元素并反向拖动它,而无需等待动画完成。一个用户再次抓取的关闭中的弹窗,应该跟随手指移动——而不是先完成关闭动作,再重新打开。
- 绝不在过渡期间锁定输入。
- 始终从 当前显示(实时)值开始动画,而不是目标值。 中断时,读取元素在屏幕上的实时变换属性,并从这个值开始新动画。从逻辑/目标值开始会导致可见的跳跃。
- 对于手势驱动的交互,避免使用 CSS 过渡和
@keyframes——它们无法在半空中被平滑地抓取和逆转。弹簧动画默认从当前值开始动画,这正好满足了可中断性的需求。 - 当手势反转时,融合速度——不要硬切。 在反转点用新动画替换旧动画会造成速度的不连续性,如同撞上一堵“砖墙”。那些能在重新指定目标时携带速度的弹簧库可以避免这个问题。(这是 iOS 原生 叠加动画 的做法;在 Web 上,选择一个能在重设目标时继承当前速度的弹簧库。)
- 将 2D 运动分解为独立的 X 和 Y 弹簧。 在一个 2D 距离上使用单弹簧,会在 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()+ 半透明背景),内容可在其下方滚动——而不是用不透明的条状物占据固定区域。 - 材质深浅编码层级: 较深/较重的材质分隔结构区域(侧边栏);较轻的材质吸引用户注意力到交互元素(按钮)。切勿将一个浅色半透明表面堆叠在另一个之上——可读性会崩溃。
- 越大的表面应显得越厚: 比较小的元素使用更强的模糊和更深的阴影。考虑上下文相关的阴影——在繁忙/有文本的内容上使用更重的阴影以增加分离度,在纯色背景上使用更轻的阴影。
- 调暗以聚焦,分离以保持流程。 模态任务将表面与变暗的遮罩层配对,并将背景推远/下移。并行的、非阻塞的面板使用半透明和偏移量但不使用遮罩层,使流程不被中断。对于堆叠的面板,逐渐调暗并后退每个父层。
- 活力色彩让文本在变化的背景上保持可读。 在模糊/半透明表面上,不要使用平坦的灰色文本——应使用更高对比度、稍重字重以及略微增加的字间距。将颜色放在实色层上,而不是半透明的前景层上。
- 使用滚动边缘效果,而非硬分割线。 不要在粘性标题下使用 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. 多模态反馈——运动 + 声音 + 触觉
结合多种感官的三个规则(来自《设计音频-触觉体验》讲座):
- 因果性——反馈的原因必须明确。在真实的因果事件上触发反馈(如开关拨动、元素归位),并且其特性要与动作的物理性相匹配。
- 和谐——视觉、声音和触觉必须在同一帧内触发。它们之间的延迟会破坏沉浸感。不要让一个动画……
…[内容因篇幅限制而截断]