Install
openclaw skills install @cindypapa/apple-designopenclaw skills install @cindypapa/apple-design苹果的界面设计与流体动效方法论,从 WWDC 设计演讲(主要是 WWDC 2018 Designing Fluid Interfaces)浓缩提炼,翻译为 Web 平台(CSS、Pointer Events、requestAnimationFrame、Motion/Framer Motion)。
当以下任一情况出现时,加载本 skill:
界面流畅 = 行为像物理世界:即时响应、连续运动、携带动量、边界受阻、可在运动中重定向。
苹果设计服务四大人性需求:安全感/可预测性、理解、成就、愉悦。每条规则都服务其中之一。
核心理念:动效从当前屏幕值开始,继承用户速度,向前投影动量,并在任意瞬间可被抓住并反转。弹簧是实现这一切的工具,因为它们天然可中断且感知速度。
响应是基础,延迟一旦出现,直接操控感就「断崖式下跌」。
/* 反馈在按下时生效,瞬间 */
.button:active {
transform: scale(0.97);
transition: transform 100ms ease-out;
}
⚠️ 对照检查:按钮 hover 和 active 状态是否有明显区分?拖拽过程中 UI 是否跟随手指?是否有不必要的 debounce 或 setTimeout?
「触摸和内容应该一起移动。」
setPointerCapture,确保手指移出元素边界后仍可追踪。el.addEventListener('pointerdown', (e) => {
el.setPointerCapture(e.pointerId);
const grabOffset = e.clientY - el.getBoundingClientRect().top;
// 追踪位置 + 时间戳历史,用于计算速度
});
⚠️ 对照检查:拖拽时元素是否始终在手指下方?是否保留了抓取偏移量?是否在手指移出元素边界后仍能继续拖拽?
「思考和手势是并行的。」
每个动画必须在任意时刻都可中断、可重定向。用户必须能在动画中途抓住移动中的元素并反转方向,无需等待动画完成。
弹簧是一个可对话的动画系统:新输入只是改变目标,运动保持连续。任何用户可触摸的东西都应该用弹簧。
⚠️ 对照检查:动画进行中用户能否打断?打断后动画是否从当前状态平滑过渡?CSS transition 是否被误用于手势驱动的交互?
苹果用两个设计师友好参数替代了物理三元组(mass/stiffness/damping):
默认策略:
苹果实际参数表:
| 交互类型 | Damping | Response |
|---|---|---|
| 移动/重定位(如画中画) | 1.0 | 0.4 |
| 旋转 | 0.8 | 0.4 |
| 抽屉/Sheet | 0.8 | 0.3 |
Web 映射(Motion / Framer Motion):
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 });
⚠️ 对照检查:静止出现/消失的元素是否误加了弹跳?菜单/弹窗动画是否用了 bounce?用户手势结束后的动画是否继承了动量?
手势结束时,动画必须以手指的精确速度继续,拖拽和动画之间不能有可见的接缝。这是「流畅」与「还行」的最大分水岭。
relativeVelocity = gestureVelocity / (targetValue − currentValue)// 拖拽结束时,把手势速度传给弹簧
animate(el, { y: targetY }, {
type: 'spring',
bounce: 0.2,
duration: 0.4,
velocity: releaseVelocity // px/s
});
⚠️ 对照检查:拖拽松开后元素是否平滑过渡到目标?还是突然变速?手势速度和动画速度之间是否有「接缝」?
「小输入,大输出。」
不要从释放点直接吸附到最近的边界。用速度投影停靠位置——就像滚动的减速过程一样——然后吸附到投影点最近的目标。
苹果的精确投影函数(来自 Designing Fluid Interfaces 示例代码):
// 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) 不是苹果用的 —— 用上面的指数衰减形式。这是好的底部 Sheet 和轮播(Vaul、Embla)的标准行为。
⚠️ 对照检查:轮播/Sheet 滑动后停靠的位置是最近的边界还是动量投影后的边界?用户轻轻滑动和用力滑动是否产生不同的停靠点?
「如果某物从某个方向消失,我们期望它从那里出现。」
transform-origin 到触发器,使按钮与内容的空间关系一目了然。⚠️ 对照检查:弹窗的出现和消失方向是否一致?菜单是否从触发按钮的位置展开?过渡的缓入缓出是否对称?
人类从轨迹预判终点。中间帧的运动应该暗示物体将去向何方——控制中心模块「向上向外朝手指增长」。让中间帧指向结果,而非盲目地线性插值。
⚠️ 对照检查:动画的中间状态是否暗示了最终位置?元素运动轨迹是否自然?还是只是 A→B 的线性插值?
到达边界时,应逐渐增加阻力而非硬停止。硬停读作「冻结」;连续阻力读作「响应了,但没更多内容了」。阻尼随拖拽超出边界的距离增加。
// 超出越多,跟随越少 —— 真实物体在停止前会减缓
function rubberband(overshoot, dimension, constant = 0.55) {
return (overshoot * dimension * constant) / (dimension + constant * Math.abs(overshoot));
}
⚠️ 对照检查:列表/页面滚动到边界时是否有弹性效果?还是硬生生停在边界?Sheet 拖拽超出最大高度时是否有阻尼?
⚠️ 对照检查:按钮是否在按下瞬间就有视觉反馈?手势冲突是否正确处理?是否有不必要的手势识别延迟?
流畅在于帧内的内容,而不仅仅是帧率。
requestAnimationFrame 是 Web 的显示器同步时钟(苹果用 CADisplayLink)。仅动画合成器友好的属性 —— transform 和 opacity —— 并用 will-change 提示即将运动的地方。⚠️ 对照检查:动画是否使用 transform/opacity?是否使用了 will-change?快速移动的元素是否有视觉虚化效果?
苹果用半透明材质作为浮动功能层,带来结构感而不抢占注意力。Web 上用 backdrop-filter 近似。
backdrop-filter: blur() + 半透明背景),内容在下方滚动 —— 而非不透明条占据固定区域。.toolbar {
background: rgba(255, 255, 255, 0.6);
backdrop-filter: blur(20px) saturate(180%);
border-top: 1px solid rgba(255, 255, 255, 0.4); /* 明亮顶边 = 光线捕捉材质 */
}
⚠️ 对照检查:导航栏是否用 backdrop-filter 而非纯色?是否有叠加半透明层的场景?模态弹窗是否有遮罩?滚动内容与浮动 UI 交界处是否有柔和过渡?
三条组合感官的规则(源自 Designing Audio-Haptic Experiences):
⚠️ 对照检查:音效/触觉是否与视觉动作同步?反馈是否过度使用?关键操作的反馈是否有明确的因果对应?
减弱动态 ≠ 没有反馈 —— 是更温和、不触发前庭不适的等效方案。响应三个独立信号并融入组件:
prefers-reduced-motion: reduce —— 将滑动/弹簧/视差替换为短时透明度淡入淡出或静态过渡。丢弃弹性/过冲。保留有助于理解的透明度/颜色变化。prefers-reduced-transparency: reduce —— 让半透明表面更雾化/更实:提高背景不透明度,去除模糊。prefers-contrast: more —— 近乎实色背景配明确对比度的边框。另外:避免全视口移动背景、慢速循环振荡(接近 0.2Hz/每 5 秒一周)、亮度的突然跳跃(暗↔亮主题切换时需缓动)。大移动物体在移动时做半透明处理,大面积表面在大范围重新定位时淡出,停稳后淡入。
@media (prefers-reduced-motion: reduce) {
.sheet { transition: opacity 200ms ease; transform: none !important; }
}
@media (prefers-reduced-transparency: reduce) {
.toolbar { background: white; backdrop-filter: none; }
}
⚠️ 对照检查:是否提供了 reduced-motion 的替代动画?reduced-transparency 下毛玻璃层是否变实?是否有可能导致眩晕的大面积动画?
苹果让字体随尺寸变化形态(源自 WWDC 2020 The Details of UI Typography)。
:root { font: 100%/1.5 system-ui, sans-serif; }
.display {
font-size: clamp(2rem, 5vw, 4rem);
line-height: 1.05; /* 大文字用紧凑行高 */
letter-spacing: -0.02em; /* 大文字用负字距 */
font-optical-sizing: auto;
}
⚠️ 对照检查:不同字号的 letter-spacing 是否差异化?正文行高是否舒适?大标题行高是否紧凑?字号是否用 rem/em?
动效和工艺服务于苹果的八项设计原则(源自 WWDC 2026 Principles of Great Design):
| 需求 | 技术 | 具体数值 |
|---|---|---|
| 默认 UI 弹簧 | 临界阻尼,无过冲 | damping 1.0, response 0.3–0.4 |
| 动量/投掷弹簧 | 欠阻尼,轻微弹跳 | damping ~0.8, response 0.3–0.4 |
| 手势→弹簧速度 | 传递释放速度 | 归一化: gestureVelocity / (target − current) |
| 投掷落点 | 投影动量 | current + (v/1000)·d/(1−d), d≈0.998 |
| 干净中断 | 从 presentation 值开始 | 读取屏幕上实时的 transform |
| 避免反转"撞墙" | 重设目标时继承速度 | 选择能融合速度的弹簧库 |
| 可逆过渡 | 镜像缓动曲线 | 互逆 cubic-bézier |
| 判断反转 vs 提交 | 用速度方向,非位置 | 在释放时刻判断 |
| 1:1 拖拽 | Pointer Events + capture | 尊重抓取偏移量 |
| 反馈 | pointer-down 时,全连续 | 绝不仅结束时 |
| 边界 | 橡皮筋,非硬停 | 渐进阻力 |
| 半透明 UI | backdrop-filter 层 | 内容在下方滚动 |
| 排版字距 | 随字号变化,非固定 | 大文字收紧字距(-0.02em),正文接近 0 |
| 减弱动态 | 淡入淡出,非滑动/弹簧 | @media (prefers-reduced-motion) |
当用户请求 UI 设计审查或交互开发时: