Apple 設計
Apple 如何打造那些不再像電腦、而更像你自身延伸的介面。這份知識來自 Apple 的 WWDC 設計講座——主要是《設計流暢介面》(WWDC 2018)——並將其提煉和轉化到 Web 平台(CSS、Pointer Events、requestAnimationFrame、像 Motion/Framer Motion 這類的彈簧函式庫)。
核心主線:當動畫從螢幕上的當前值開始、繼承使用者的速度、向前投射動量,並且能夠在任何瞬間被抓住並反向時,介面就會感覺有生命力。 彈簧是讓這一切變得自然自如的工具,因為它們本質上可中斷且能感知速度。
核心概念
「當我們讓介面與我們的思考和移動方式保持一致時,神奇的事情就會發生——它不再感覺像一台電腦,而開始感覺像我們自身天衣無縫的延伸。」
一個介面是流暢的,當它像物理世界一樣運作:事物立即回應、連續移動、攜帶動量、在邊界處產生阻力,並且可以在運動途中被重新導向。以下所有的原則都是為了更接近這個目標。
Apple 將設計框架定義為服務四種人類需求:安全感/可預測性、理解、成就感、以及愉悅感。 這裡的每一條規則都服務於其中一項需求。
1. 回應——消除延遲
當延遲出現的那一刻,直接操作的感覺就會「從懸崖邊墜落」。回應是所有其他環節的基礎。
- 在指標按下時回應,而不是在釋放時。 按鈕在按下的瞬間就應該被高亮。等待
click/觸摸抬起才顯示回饋,感覺就像死了一樣。 - 警惕每一毫秒的延遲。 審查所有防抖動 (debounce)、人為計時器、過渡等待,以及約 300 毫秒的點擊延遲。輸入路徑上任何非必要的東西都是退步。
- 回饋必須在互動過程中持續存在,而不只是在結束時。 對於拖曳、滑桿或抽屜,整個過程中 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 刻意用兩個設計師友善的參數取代了物理三元素(質量/剛度/阻尼)。用這兩個參數思考:
- 阻尼比 (Damping ratio)——控制過衝 (overshoot)。
1.0= 臨界阻尼,無彈跳,平穩停止。< 1.0= 過衝並擺動。數值越低越彈。 - 響應時間 (Response)——值到達目標的速度,單位是秒。數值越低反應越快。這不是「持續時間」——彈簧沒有固定的持續時間;它的穩定時間是從參數中浮現出來的。
預設值:
- 大多數 UI 從阻尼
1.0(臨界阻尼)開始——優雅且不分散注意力。 - 只有在手勢本身帶有動量時(一個輕彈、一個拋擲、一個拖曳釋放)才添加彈跳(阻尼約
0.8)。一個剛剛淡入的功能表如果過衝感覺很奇怪;但一個被你輕彈過的卡片過衝則感覺恰到好處。
Apple 實際出貨的具體數值:
| 互動 | 阻尼 | 響應時間 |
|---|---|---|
| 移動/重新定位(例如子母畫面) | 1.0 | 0.4 |
| 旋轉 | 0.8 | 0.4 |
| 抽屜 / 面板 | 0.8 | 0.3 |
Web 對應(Motion / Framer Motion):bounce + duration 的 spring API 與 Apple 的 damping + response 非常接近。一個安全的預設樣式是到處都使用 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. 影格級別的平滑度
平滑度關乎的是影格裡的內容,而不僅僅是影格率。
- 保持每個影格的位置變化低於感知閾值,以避免閃爍感 (strobing)。
- 對於非常快速的運動,微妙的動態模糊 / 拉伸可以編碼速度,並且比銳利的硬線條看起來更好。
requestAnimationFrame是 Web 上與顯示器同步的時鐘(Apple 使用CADisplayLink)。只對合成器友善的屬性進行動畫——transform和opacity——並在即將發生動畫的地方使用will-change提示。
12. 材質與深度——半透明傳達層級
Apple 使用半透明材質作為一個浮動的功能層,在不過度搶眼的情況下帶來結構感。在 Web 上,使用 backdrop-filter 近似。
- 將導覽列/工具列/面板創建為半透明層(
backdrop-filter: blur()+ 半透明背景),內容在下方滾動——而不是佔據固定條帶的不透明條。 - 材質重量編碼層級: 較深/較重的材質區分結構區域(側邊欄);較輕的材質吸引注意力到互動元素(按鈕)。永遠不要將一個淺色半透明表面疊在另一個上面——可讀性會崩潰。
- 較大的表面應感覺較厚: 比較小的碎片有更強的模糊和更深的陰影。考慮情境感知的陰影——在繁忙/文字內容上使用較重的陰影以增加分離感,在純色背景上使用較輕的陰影。
- 變暗以集中注意力,分開以保持流程。 一個模態任務將表面與變暗的遮罩配對,並將背景推後/向下。一個並行的、非阻塞的面板使用半透明和偏移而不使用遮罩,這樣流程就不會被中斷。對於堆疊的面板,逐漸變暗並將每個父層推後。
- 生動效果 (Vibrancy) 使文字在變化的背景上保持可讀。 在模糊/半透明表面上,不要使用純灰色文字——使用更高對比度、稍重的字重和微小的字母間距提升。將顏色放在實心層上,而不是半透明的前景上。
- 使用滾動邊緣效果,而不是硬分隔線。 取代在黏性標題下方使用 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. 多模態回饋——動畫 + 聲音 + 觸覺
結合感官的三個原則(來自《設計音頻-觸覺體驗》):
- 因果關係 (Causality)——回饋的原因必須顯而易見。在實際的因果事件(切換開關的瞬間、項目定位到位的瞬間)時觸發它,並使其特性與動作的物理性相匹配。
- 和諧 (Harmony)——視覺、聲音和觸覺必須在同一個影格觸發。它們之間的延遲會摧毀這個幻覺。不要讓 CSS 過渡落後於音頻/觸覺 (Vibration API)。
- 效用 (Utility)——只在有意義的地方添加回饋。將觸覺/聲音保留給重要的時刻(成功、錯誤、提交、到位)。過度回饋會訓練使用者忽略所有回饋。
14. 減少動畫與無障礙
減少動畫並不意味著沒有回饋
……
[因翻譯限制截斷]