Apple 設計
初始回應
當此技能首次被叫用,且沒有特定問題時,只回應:
我已準備好協助你在網頁上建置流暢的 Apple 風格介面。我的知識源自 Apple 的 WWDC 設計講座,並已轉化為適用於網頁的內容。
在使用者提問之前,請勿提供任何其他資訊。
Apple 如何打造那些不再像電腦、反而像是你自身延伸的介面。這些知識來自 Apple 的 WWDC 設計講座——主要是《設計流暢介面》(WWDC 2018)——經由提煉並轉化為網頁平台(CSS、Pointer Events、requestAnimationFrame、像 Motion/Framer Motion 這類的彈簧函式庫)。
主軸:當一個介面的動畫從螢幕上的當前數值開始、繼承使用者的速度、將動量向前投射,並且可以在任何瞬間被抓住與反向時,它就會感覺是活生生的。彈簧(Spring)是讓這一切變得自然的工具,因為它們本質上可中斷且能感知速度。
核心概念
「當我們將介面與我們的思考與移動方式對齊時,神奇的事就發生了——它不再感覺像一台電腦,而是感覺像我們自身的無縫延伸。」
一個介面要流暢,就必須像物理世界一樣運作:東西即時回應、連續移動、攜带动量、在邊界處產生阻力,並且可以在運動中途被重新導向。以下所有內容都是為了更接近這個目標。
Apple 將設計視為滿足四種人類需求:安全感/可預測性、理解、成就與喜悅。 這裡的每一條規則都是為了服務其中一項。
1. 回應——消除延遲
延遲出現的那一刻,直接操作的感覺就會「急遽下降」。回應是其他一切的基礎。
- 在指標按下時回應,而不是在放開時。 按鈕被按下的瞬間就應該給予回饋。等待
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. 可中斷性——最重要的單一原則
「思考與手勢是同步發生的。」
每個動畫都必須能在任何時刻被中斷與重新導向。使用者必須能夠在半空中抓住一個正在移動的元素並反向它,而不需要等待動畫完成。一個正在關閉的模態視窗,如果使用者再次抓住它,應該要跟著手指移動——而不是先完成關閉,然後再重新打開。
- 絕不要在轉場期間鎖住輸入。
- 永遠從當前呈現(presentation)的值開始動畫,而不是目標值。 中斷時,讀取元素畫面上的即時變形數值,並從那裡開始新的動畫。從邏輯/目標值開始會導致明顯的跳動。
- 對任何由手勢驅動的互動,避免使用 CSS transitions 和
@keyframes——它們無法被順利地抓住並在中途反向。彈簧動畫預設從當前數值開始,這正是中斷所需要的。 - 當手勢反向時,要混合速度——不要突然切斷它。 在反向點用一個動畫取代另一個會產生速度上的不連續,就像一道「磚牆」。能夠在重新設定目標時繼承速度的彈簧函式庫可以避免這個問題。(這就是 iOS 的 additive animations 原生做法;在網頁上,選擇一個能從當前速度重新設定目標的彈簧函式庫。)
- 將 2D 運動分解為獨立的 X 和 Y 彈簧。 對一個 2D 距離使用單一彈簧,當 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 |
網頁對應(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設定給觸發元素,這樣按鈕與內容之間的空間關係就很明顯。(這與 popover 從觸發元素而非中心縮放是相同原點意識的概念。) - 在可逆轉場上鏡像其緩動函數,使離開路徑與返回路徑匹配(對兩個方向使用反向的 cubic-bézier 控制點)。
8. 在手勢方向提供暗示
人類會從軌跡預測最終狀態。中間的畫格應該要暗示事物將去向何方——「控制中心」的模組會「朝著你的手指向上、向外生長」。讓中間畫格指向結果,而不只是盲目地在兩點之間內插。
9. Rubber-banding(橡皮筋效應)——軟邊界
在邊界處,要逐步產生阻力,而不是突然停止。突然停止感覺像「凍結」;連續的阻力感覺像「有回應,但這裡沒有更多東西了」。應用隨著使用者拖曳越過邊界而增加的阻尼。
// 越過邊界越多,元素跟隨得越少——真實世界的物體在停止前會減速
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是網頁上與顯示器同步的時鐘(Apple 使用CADisplayLink)。只對合成器友善的屬性進行動畫——transform和opacity——並在即將發生運動的地方使用will-change提示。
12. 材質與深度——半透明傳達層級
Apple 使用半透明材質作為一個浮動的功能層,在帶來結構的同時不搶走焦點。在網頁上,可以使用 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. 多模態回饋——動畫 + 聲音 + 觸覺
結合感官的三個規則(來自《設計音訊觸覺體驗》):
- 因果關係——回饋的原因必須顯而易見。在實際的因果事件上觸發它(切換開關、項目就定位),並使其特徵與動作的物理性相匹配。
- 和諧——視覺、聲音與觸覺必須在同一畫格觸發。它們之間的延遲會摧毀這個幻覺。不要讓一個...
…
[內容因長度限制而截斷]