Apple 設計
初始回應
當此技能首次被召喚且沒有特定問題時,僅回應:
我已準備好協助你在網頁上建構流暢的 Apple 風格介面,我的知識來自 Apple 的 WWDC 設計講座,並轉化為網頁實作。
在使用者提問之前,請勿提供任何其他資訊。
Apple 如何打造那些不再像電腦、而更像你身體延伸的介面。這些知識來自 Apple 的 WWDC 設計講座 — 主要是《設計流暢介面》(WWDC 2018)— 並被提煉和轉化到網頁平台(CSS、Pointer Events、requestAnimationFrame、像 Motion/Framer Motion 這樣的彈簧動畫庫)。
核心主線:當一個介面的動畫從當前螢幕數值開始、繼承使用者的速度、將動量向前投射、並且能在任何瞬間被抓住和反向時,這個介面就會感覺充滿活力。 彈簧是讓這一切變得自然的工具,因為它們本質上就是可中斷的並且能感知速度。
核心概念
「當我們讓介面與我們的思考和移動方式保持一致時,就會發生神奇的事情——它不再像一台電腦,而開始感覺像是我們身體無縫的延伸。」
當一個介面像物理世界一樣運作時,它就是流暢的:事物即時回應、連續移動、帶有動量、在邊界處產生阻力、並且可以在運動過程中被重新導向。以下所有內容都是為了更接近這個目標。
Apple 將設計框架為滿足四種人類需求:安全感/可預測性、理解、成就感和愉悅感。 這裡的每一條規則都服務於其中一種。
1. 回應 — 消滅延遲
延遲出現的那一刻,直接操作的感覺就會「懸崖式下跌」。回應是其他一切建立的基礎。
- 在 pointer-down 時回應,而不是在釋放時。 按鈕被按下的瞬間就應顯示高亮。等待
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 的疊加動畫原生的做法;在網頁上,選擇一個在重新設定目標時從當前速度開始的彈簧庫。)
- 將 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 |
網頁對應(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是網頁上與顯示器同步的時脈(Apple 使用CADisplayLink)。只對合成器友好的屬性進行動畫——transform和opacity——並在即將發生運動的地方使用will-change提示。
12. 材質與深度 — 半透明傳達層級
Apple 使用半透明材質作為一個浮動的功能層,帶來結構感卻不搶奪焦點。在網頁上,可以用 backdrop-filter 近似模擬。
- 將導航列/工具列/面板建構為半透明層(
backdrop-filter: blur()加上半透明背景),內容在其下方捲動——而不是佔用固定條塊的不透明長條。 - 材質重量體現層級: 較暗/較重的材質區分結構區域(側邊欄);較輕的材質將注意力吸引到互動元素(按鈕)。切勿將一個淺色半透明表面疊在另一個上面——可讀性會崩潰。
- 較大的表面應看起來更厚: 比較小的卡片使用更強的模糊和更深的陰影。考慮情境感知的陰影——在忙碌/文字內容上使用較重的陰影以獲得區隔,在單純背景上使用較輕的陰影。
- 變暗以聚焦,分離以保持流暢。 一個模態任務會搭配一個變暗的遮罩並將背景推遠/壓低。一個平行、非阻塞的面板則使用半透明和偏移而不使用遮罩,以免打斷流程。對於堆疊的面板,逐層變暗並推遠每個上層。
- 活力效果能讓文字在變化的背景上保持可讀。 在模糊/半透明表面上,不要使用平坦的灰色文字——使用更高對比度、稍粗的字重和些微的字距增加。將顏色放在實心層上,而不是半透明的前景層。
- 使用捲動邊緣效果,而不是硬分隔線。 不要在固定頁首下使用 1px 邊框,而是在浮動 Chrome 與內容重疊處淡出一個小的模糊/漸變遮罩——僅限於浮動 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. 多模態回饋 — 動畫 + 聲音 + 觸覺
結合感官的三條規則(來自《設計音頻-觸覺體驗》):
- 因果關係 — 必須清楚知道是什麼導致了回饋。將觸發點設在實際的因果事件上(開關翻轉、物品卡入定位),並使其特性與動作的物理性相匹配。
- 和諧 — 視覺、聲音和觸覺必須在同一畫格觸發。它們之間的延遲會破壞幻覺。不要讓一
…
[因長度截斷]