E-Ink 設計系統:為什麼我的 App 故意不做動畫

E-Ink 設計系統:為什麼我的 App 故意不做動畫
App 概念圖,畫面以實際App中的為主。

一個關於「拿掉」的設計決定

路順是一款外勤業務用的路線規劃 App。開發初期,我用了 SwiftUI 預設的樣式:圓角卡片、藍色按鈕、滑入滑出的轉場動畫。看起來跟市面上大多數 App 差不多,功能正常,但沒有任何辨識度。

我想讓路順看起來不一樣。但不是那種「配色大膽」或「加更多動效」的不一樣,而是反過來:把東西拿掉。

最後定下來的方向是電子紙(E-Ink)風格。灰階畫面、無圓角、無平滑動畫、點陣字體,整個 App 看起來像一台 Kindle 或早期的 Apple II 終端機。全畫面只有一個顏色:螢光綠,用在最重要的那顆按鈕上。

設計系統的四條規則

我在 EInkTheme.swift 的開頭寫了四條規則,之後所有的設計決定都從這四條出發:

  1. 沒有平滑動畫。電子紙的畫面是「刷新」,不是「移動」。
  2. 灰階為主。螢光綠只用在一個重點元素上。
  3. 換頁或內容變動時閃一下(refresh flash),搭配觸覺回饋。
  4. 地圖灰階化。點擊後用原生 modal 喚醒全彩地圖。

這四條規則聽起來都是在「限制」,但每一條都讓後續的設計決定變得更快。當你知道不能做什麼的時候,剩下能做的選擇就變少了,而少的選擇往往做得更好。

灰階色票:五個顏色撐起整個 App

路順的 UI 只用了五個顏色:

名稱色碼用途
Paper 紙白#F4F1EA背景。不是純白,帶一點暖黃,模擬紙張質感
Ink 墨黑#1A1A1A主要文字、邊框、標題
Ink Light 淡墨40% 灰次要文字
Ghost 殘影灰80% 灰分隔線、卡片邊框(像電子紙殘影)
Phosphor 螢光綠#33FF33唯一的彩色,只用在主要行動按鈕

背景色選 #F4F1EA 而非純白,是因為真正的電子紙螢幕不是白色的,而是帶著一點米黃。這個微小的色差讓整個介面從「黑白 App」變成「像紙的 App」。

螢光綠只出現在一個地方:主要操作按鈕(例如「開始最佳化路線」)。當整個畫面都是灰階的時候,那一顆綠色按鈕自然就成為視覺焦點,不需要放大字體或加箭頭來引導使用者的注意力。

enum EInk {
    enum Colors {
        static let paper      = Color(red: 0.957, green: 0.945, blue: 0.918)
        static let ink        = Color(red: 0.102, green: 0.102, blue: 0.102)
        static let inkLight   = Color(red: 0.42,  green: 0.42,  blue: 0.40)
        static let ghost      = Color(red: 0.80,  green: 0.79,  blue: 0.76)
        static let phosphor   = Color(red: 0.20,  green: 1.0,   blue: 0.20)
    }
}

拿掉圓角

SwiftUI 的預設圓角半徑很好看,柔和、現代、親切。但圓角也讓所有 SwiftUI App 長得差不多。

路順把所有圓角設成 0。卡片是方的,按鈕是方的,輸入框也是方的。配上邊框線條,整個介面看起來像報紙版面或印刷標籤。

enum AppMetrics {
    static let cardCornerRadius: CGFloat = 0
    static let iconTileCornerRadius: CGFloat = 0
}

卡片元件在左側加了一條細墨條,模擬打印標籤的效果:

struct EInkCard: ViewModifier {
    func body(content: Content) -> some View {
        content
            .padding(16)
            .background(EInk.Colors.paper)
            .overlay(
                Rectangle().strokeBorder(EInk.Colors.ghost, lineWidth: 1)
            )
            .overlay(alignment: .leading) {
                Rectangle().fill(EInk.Colors.ink).frame(width: 3)
            }
    }
}

用「刷新」取代「動畫」

這是整個設計系統裡最核心的決定。

SwiftUI 預設的轉場動畫是滑入滑出,清單項目的增減有彈性動畫,狀態變化有淡入淡出。這些動畫讓介面感覺流暢,但也讓介面感覺像「液態」的。電子紙的畫面更新方式完全不同:舊畫面消失,螢幕閃黑一下,新畫面出現。是離散的,不是連續的。

我用一行程式碼關掉了所有隱式動畫:

.transaction { $0.animation = nil }

然後用 EInkRefreshController 做「刷新閃爍」效果:畫面疊上一層淡墨色(透明度只有 12%),持續 0.12 秒,配合一個 rigid 觸覺回饋。使用者看到的效果是:按下按鈕後,畫面微微閃了一下,內容就已經換好了。

@Observable
final class EInkRefreshController {
    var isFlashing = false

    @MainActor
    func trigger(haptic: Bool = true) async {
        if haptic {
            UIImpactFeedbackGenerator(style: .rigid).impactOccurred()
        }
        isFlashing = true
        try? await Task.sleep(for: .seconds(0.12))
        isFlashing = false
    }
}

早期版本的閃爍效果更強烈,整面蓋上墨黑色。測試時使用者反映太突兀,有被嚇到的感覺,所以改成淡入淡出的微暈染:淡入 0.1 秒、淡出 0.25 秒,透明度壓低到 12%。這是整個「無動畫」原則裡唯一的例外,而且是使用者反饋逼出來的例外。

紙張顆粒質感

為了讓背景更像紙張,我在畫面最上層蓋了一個 PaperGrain 元件。它用 Canvas 在畫面上隨機撒了一些幾乎看不見的點(透明度 5%),模擬紙張的纖維顆粒。

這裡有一個細節:隨機數用的是固定 seed 的偽隨機產生器,而非系統的隨機函數。這樣每次畫面重繪時,顆粒的位置不會變動。如果用系統隨機數,每次 SwiftUI 重繪這個 View,顆粒會跳到新位置,畫面會「閃」。

private struct SeededRandom {
    var state: UInt64
    init(seed: UInt64) { state = seed }
    mutating func next() -> CGFloat {
        state = state &* 6364136223846793005 &+ 1442695040888963407
        return CGFloat(state % 10_000) / 10_000
    }
}

灰階地圖與「喚醒」

路順是路線規劃 App,地圖是核心功能。但全彩地圖放在灰階介面裡,視覺上非常突兀,像在報紙上貼了一張彩色照片。

解法是把地圖也灰階化。SwiftUI 的 Map 元件加上三行 modifier 就能做到:

.saturation(0)      // 去掉所有色彩
.contrast(1.15)     // 提高對比度
.brightness(0.03)   // 稍微提亮

灰階地圖右下角放了一個「TAP TO WAKE ►」的標籤,用點陣字體、白字黑底。使用者點擊灰階地圖後,一個全彩的原生地圖從底部滑出來。從灰色紙張世界切換到全彩地圖,這個反差很有趣:灰階讓全彩變得更有價值。

按鈕的兩種狀態

路順有兩種按鈕樣式。主要按鈕用螢光綠:深底綠框、綠色點陣字。按下去的時候,前景和背景顏色對調(綠底黑字變成黑底綠字),同時觸發一個重量級的觸覺回饋。

次要按鈕用墨線框:白底黑框黑字,按下去變成黑底白字。整個互動都是瞬間切換,沒有漸變。

早期我試過用 SwiftUI 的 .colorInvert() 來做按下時的反色效果,但 #33FF33 反轉出來是紫紅色,視覺上很突兀。最後改成手動指定按下狀態的前景和背景顏色。

struct PhosphorButtonStyle: ButtonStyle {
    func makeBody(configuration: Configuration) -> some View {
        let pressed = configuration.isPressed
        return configuration.label
            .font(EInk.Fonts.dotMatrix(14))
            .tracking(1)
            .foregroundStyle(pressed ? EInk.Colors.phosphorBg : EInk.Colors.phosphor)
            .padding(.vertical, 12)
            .padding(.horizontal, 20)
            .frame(maxWidth: .infinity)
            .background(pressed ? EInk.Colors.phosphor : EInk.Colors.phosphorBg)
            .einkInstant()
            .sensoryFeedback(.impact(weight: .heavy), trigger: configuration.isPressed)
    }
}

為什麼這套設計系統有效

路順開發完後,給多個業務朋友使用,使用者對介面的反應超出預期。好幾則回饋特別提到了「風格很特別」和「看起來很舒服」。一個外勤業務工具能讓人注意到視覺設計,這在同類 App 裡很少見。

回頭看,我覺得這套設計系統有效的原因有兩個。

第一,限制產生辨識度。當你把可用的顏色壓到五個、把圓角拿掉、把動畫關掉,剩下來的每一個視覺元素都必須有明確的功能。螢光綠不是裝飾,它是「這裡可以按」的訊號。刷新閃爍不是炫技,它是「內容已經更新」的提示。每個視覺元素都在做事,沒有純裝飾的成分。

第二,反差創造記憶點。灰階讓螢光綠更亮,無動畫讓刷新閃爍更有存在感,灰階地圖讓全彩 modal 更驚喜。拿掉的東西反而讓留下來的東西更突出。


路順(RouteSync)是一款為外勤業務設計的路線規劃工具,支援繁體中文、英文與日文。

App Store 下載路順

費曼的實習生系列:用簡單的話,記錄不簡單的開發過程。