最近在弄 Flutter App,有個東西真的…嗯,該怎麼說,就是那種讓你一開始覺得很煩,但搞懂了又覺得蠻爽的,就是「設計系統」。
一開始你可能會覺得,啊不就是管一下顏色、字體大小嗎?有必要搞得這麼複雜?但專案一變大,人一多,你就會發現…天啊,這邊的藍色跟那邊的藍色不一樣,按鈕圓角有三種,間距全憑工程師的手感…整個 App 看起來就像拼裝車。真的會瘋掉。
先說結論
老實說,搞一個設計系統,重點不是為了讓 UI/UX 設計師開心而已。它真正的價值,是幫未來的你省下大把時間,讓整個專案可以跑得更遠、更穩。簡單講,就是一套規則,讓大家照著走,不會亂掉。
你的 App 是不是也長這樣?
想像一下,你接手一個沒規矩的專案。A 頁面的主色是 #0A74FF,B 頁面設計師憑感覺選了個 #007AFF,看起來差不多,但就是不一樣。然後,C 頁面的工程師為了方便,直接用了 `Colors.blue`。
結果就是:
- 看起來不專業:使用者說不出來哪裡怪,但就是覺得這 App 有點廉價感。
- 維護是惡夢:今天老闆說「我們品牌色要換一個更活潑的藍」,你要去幾個檔案裡大海撈針,手動改幾十個地方?改漏了還會被罵。
- 溝通成本超高:設計師和工程師整天在為「這個間距是 8 還是 10?」、「這個灰是用哪個?」這種小事吵架。
說真的,這就是混亂的開始。
核心概念:從「寫死」到「取名字」
解決上面那個混亂的方法,其實概念很單純,就是不要再「寫死」那些數值了。不管是色碼 #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 (原子級) | Semantic Tokens (語意級) | |
|---|---|---|
| 簡單講 | 就是原料。最純粹的數值,沒什麼特別的意義。 | 有意義的「用途名稱」。告訴你這個原料該用在哪。 |
| 舉個例子 | blue-500: #0A74FFspacing-4: 16.0 |
interactive-color: blue-500card-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),
),
],
);
最後,也是最酷的一步,在 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 精神完全一致,都是從最小的單位開始建立規則。
所以,下一步呢?
嗯…今天講的這些,算是打地基吧。先把觀念弄懂,知道為什麼要用 Token,然後學會在 Flutter 裡用 `ThemeData` 和 `ThemeExtension` 把這些規則安放好。
光是這樣,就已經能解決 80% 的 UI 混亂問題了。我自己是覺得,最重要的不是馬上把所有東西都做成最完美的 token,而是先「開始」把顏色、字體、間距這些最常用的東西抽出來管理。
這只是第一步。之後還有很多可以玩的,像是把這些設計 token 存成 JSON 檔,讓設計師也能直接修改,或是做 App 內動態切換主題之類的。不過那些都是後話了,先把基礎打穩比較實在。
你在開發 App 時,最讓你頭痛的 UI 混亂是什麼?是永遠對不齊的間距,還是到處亂飄的顏色?留言分享一下吧。
