Flutter 設計系統建置步驟:SpaceDesign 可擴展架構實作指南

Published on: | Last updated:

最近在弄 Flutter App,有個東西真的…嗯,該怎麼說,就是那種讓你一開始覺得很煩,但搞懂了又覺得蠻爽的,就是「設計系統」。

一開始你可能會覺得,啊不就是管一下顏色、字體大小嗎?有必要搞得這麼複雜?但專案一變大,人一多,你就會發現…天啊,這邊的藍色跟那邊的藍色不一樣,按鈕圓角有三種,間距全憑工程師的手感…整個 App 看起來就像拼裝車。真的會瘋掉。

先說結論

老實說,搞一個設計系統,重點不是為了讓 UI/UX 設計師開心而已。它真正的價值,是幫未來的你省下大把時間,讓整個專案可以跑得更遠、更穩。簡單講,就是一套規則,讓大家照著走,不會亂掉。

你的 App 是不是也長這樣?

想像一下,你接手一個沒規矩的專案。A 頁面的主色是 #0A74FF,B 頁面設計師憑感覺選了個 #007AFF,看起來差不多,但就是不一樣。然後,C 頁面的工程師為了方便,直接用了 `Colors.blue`。

結果就是:

  • 看起來不專業:使用者說不出來哪裡怪,但就是覺得這 App 有點廉價感。
  • 維護是惡夢:今天老闆說「我們品牌色要換一個更活潑的藍」,你要去幾個檔案裡大海撈針,手動改幾十個地方?改漏了還會被罵。
  • 溝通成本超高:設計師和工程師整天在為「這個間距是 8 還是 10?」、「這個灰是用哪個?」這種小事吵架。

說真的,這就是混亂的開始。

UI 導入設計系統前後的視覺差異
UI 導入設計系統前後的視覺差異

核心概念:從「寫死」到「取名字」

解決上面那個混亂的方法,其實概念很單純,就是不要再「寫死」那些數值了。不管是色碼 #FF5733 還是間距 `16.0`,都不要直接寫在你的 Widget 裡。

我們要做的,是給這些值一個「名字」,這個名字,就是所謂的 **Design Token (設計代幣)**。

例如,不要這樣寫:

// 很不好的做法
Container(
  color: Color(0xFF6200EA), // 這個紫色是什麼?天曉得
  padding: const EdgeInsets.all(16.0), // 16.0 是大還是小?
)

而是改成這樣:

// 比較好的做法
Container(
  color: AppColors.primary, // 啊哈,這是主要顏色
  padding: const EdgeInsets.all(AppSpacing.medium), // 嗯,中等的間距
)

感覺到了嗎?程式碼變得可以讀了,而且如果要改,我只要去改 `AppColors.primary` 這個「名字」所代表的顏色就好,所有用到它的地方就一次全改好了。超方便。

不過 token 又可以再分兩種,這個我覺得蠻重要的,得分清楚。

PRIMITIVE TOKENS vs. SEMANTIC TOKENS 比較表
Primitive Tokens (原子級) Semantic Tokens (語意級)
簡單講 就是原料。最純粹的數值,沒什麼特別的意義。 有意義的「用途名稱」。告訴你這個原料該用在哪。
舉個例子 blue-500: #0A74FF
spacing-4: 16.0
interactive-color: blue-500
card-padding: spacing-4
思考方式 「我手上有這些藍色可以選。」
(像油漆色票)
「所有『可以點的』東西,都要用這個顏色。」
(像施工說明書)
優點 很純粹,選擇多,就是一堆材料在那。 可讀性超高,維護性強。改一個 `interactive-color`,所有按鈕、連結顏色就都換了。
缺點 直接拿來用的話,還是會亂。因為工程師 A 可能覺得 `blue-400` 適合按鈕,工程師 B 覺得 `blue-500` 才對。 需要多一層思考和定義。前期會花一點時間。

那在 Flutter 裡要怎麼做?

好,理論講完了,那在 Flutter 裡面,這些東西要放哪?總不能自己隨便 new 一個 class 來放吧。

Flutter 很貼心,已經幫我們準備好一套機制了,就是 `ThemeData`。基本上,所有跟 App 主題、風格有關的東西,都應該往這裡面塞。

你平常在用 `MaterialApp` 的時候,一定有看過 `theme` 這個參數吧?對,就是它。

MaterialApp(
  theme: lightThemeData, // 亮色模式用的主題
  darkTheme: darkThemeData, // 暗色模式用的主題
  themeMode: ThemeMode.system, // 跟著系統走
  home: HomeScreen(),
);

Flutter 內建的 `ThemeData` 已經幫你定義好很多東西了,像是 `colorScheme` (顏色組合)、`textTheme` (文字樣式) 等等。大部分情況下,用好內建的就已經很夠了。

說到這個,雖然 Google 的 Material Design 是一套很完整的規範,但說真的,在台灣的 App 環境,有時候我們會看到一些調整。這點跟美國那邊的設計思維有點不一樣,可能是使用者習慣吧?比方說,我們好像更習慣在一個畫面上看到更多的資訊,所以行高或間距有時會比 Material Design 建議的再緊湊一點。這沒有對錯,只是在定義自己 App 的 `ThemeData` 時可以思考一下,是不是要完全照搬,還是根據自己的使用者做點在地化調整。

如果內建的不夠用?那就自己擴充吧!

但有時候…就是會遇到內建 `ThemeData` 沒提供、但你又很想統一管理的樣式。例如自訂的陰影效果、或是特殊的漸層色。這時候怎麼辦?

以前可能會用一些比較 hack 的方法,但現在 Flutter 有了官方解法:**Theme Extensions**。

簡單說,它就是讓你可以在 `ThemeData` 上「掛」上你自己定義的樣式包。

比如說,我們想定義一套自己的文字樣式,可以這樣搞:

第一步,先建一個 class 來裝你的自訂樣式,讓它繼承 `ThemeExtension`。

// 這邊有點像在寫一個樣式的「容器」
class MyTextStyleExtension extends ThemeExtension<MyTextStyleExtension> {
  const MyTextStyleExtension({
    required this.pageTitle,
    required this.cardBody,
  });

  final TextStyle? pageTitle;
  final TextStyle? cardBody;

  // 下面這些是 boilerplate code,照著寫就對了
  // copyWith, lerp... 主要是處理主題切換時的動畫和複製
  @override
  ThemeExtension<MyTextStyleExtension> copyWith({
    TextStyle? pageTitle,
    TextStyle? cardBody,
  }) {
    return MyTextStyleExtension(
      pageTitle: pageTitle ?? this.pageTitle,
      cardBody: cardBody ?? this.cardBody,
    );
  }

  @override
  ThemeExtension<MyTextStyleExtension> lerp(
    ThemeExtension<MyTextStyleExtension>? other,
    double t,
  ) {
    // ... 實作 lerp ...
    return this;
  }
}

然後,在建立 `ThemeData` 的時候,把這個擴充塞進 `extensions` 陣列裡:

ThemeData lightTheme = ThemeData.light().copyWith(
  extensions: <ThemeExtension<dynamic>>[
    const MyTextStyleExtension(
      pageTitle: TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
      cardBody: TextStyle(fontSize: 14, color: Colors.grey),
    ),
  ],
);
在 IDE 中使用 Theme Extension 取得自訂樣式
在 IDE 中使用 Theme Extension 取得自訂樣式

最後,也是最酷的一步,在 Widget 裡你就可以這樣用:

// 以前可能要寫 Theme.of(context).textTheme.headline1... 超長
// 現在可以很優雅地這樣寫
Text(
  "我的頁面標題",
  style: Theme.of(context).extension<MyTextStyleExtension>()!.pageTitle,
);

我自己是覺得,一旦你開始用 Theme Extension 來管理那些散落在各處的自訂樣式,你就回不去了。程式碼變得乾淨很多,而且所有東西都在同一個地方定義,要改也超快。

不只是 Token,思考的「顆粒度」也很重要

有了 Token 和 ThemeData 這些工具後,我們還需要一個「組合」這些工具的思考框架。不然,給你一堆樂高零件,你可能還是不知道怎麼蓋出一座城堡。

這邊就要提到一個很有名的概念,叫 **Atomic Design (原子設計)**。這是 Brad Frost 提出來的,老實說,我覺得這個比喻超好。他把 UI 元件拆分成五個層級:

  • Atoms (原子): 最基本、不可再分的元素。像是按鈕、輸入框、一個 Icon。對應到 Flutter 就是 `ElevatedButton`、`Text`、`Icon` 這些。
  • Molecules (分子): 由幾個原子組成的簡單元件。例如一個「搜尋框」,它可能包含一個「輸入框原子」和一個「搜尋 Icon 原子」。
  • Organisms (有機體): 更複雜的區塊,由分子和原子組成。例如一個商品卡片,裡面有圖片、標題(分子)、價格(原子)、購買按鈕(原子)。
  • Templates (模板): 頁面的骨架。它定義了區塊的排版,但裡面沒有真實內容。像是文章頁的模板,有標題區、內文區、側邊欄區。在 Flutter 裡,`Scaffold` 就是一個很典型的模板概念。
  • Pages (頁面): 最終的成品,就是把真實內容填進模板裡,給使用者看到的畫面。

這個概念為什麼重要?因為它強迫你去思考元件的「複用性」。你不會一開始就去刻一個完整的頁面,而是會先去打造那些最小的、到處都會用到的「原子」,然後再慢慢組合上去。這跟我們前面說的 Design Token 精神完全一致,都是從最小的單位開始建立規則。

Atomic Design 方法論的層級示意圖
Atomic Design 方法論的層級示意圖

所以,下一步呢?

嗯…今天講的這些,算是打地基吧。先把觀念弄懂,知道為什麼要用 Token,然後學會在 Flutter 裡用 `ThemeData` 和 `ThemeExtension` 把這些規則安放好。

光是這樣,就已經能解決 80% 的 UI 混亂問題了。我自己是覺得,最重要的不是馬上把所有東西都做成最完美的 token,而是先「開始」把顏色、字體、間距這些最常用的東西抽出來管理。

這只是第一步。之後還有很多可以玩的,像是把這些設計 token 存成 JSON 檔,讓設計師也能直接修改,或是做 App 內動態切換主題之類的。不過那些都是後話了,先把基礎打穩比較實在。


你在開發 App 時,最讓你頭痛的 UI 混亂是什麼?是永遠對不齊的間距,還是到處亂飄的顏色?留言分享一下吧。

Related to this topic:

Comments

撥打專線 LINE免費通話