講白一點:Flutter 的 icons 沒處理好,整個 UI 就會有股「怪味」
先把重點丟出來:在 Flutter 裡,icons 看起來小小一顆,但真的超會搞事,好的時候會讓整個畫面看起來很「完整」、很 Flutter;壞的時候,就會變成一堆小小難點、顏色又不對、在暗色模式直接消失,還點不太到。
我自己看很多專案(不管是 GitHub 上的、還是同事 side project),icon 常見翻車點幾乎都一樣:顏色寫死、明明是可以點的卻只用 `Icon` 搭 `GestureDetector`、hit area 小到手抖一下就 miss、還有完全沒考慮到螢幕閱讀器怎麼唸。
這篇要幹嘛:就是把這些 icon 常見坑一個一個挖出來、踩給你看,然後用「可以直接跑」的 Flutter code 把正確做法示範給你。
讀完你大概會比較有底:什麼時候該用 `Icon`、什麼時候該用 `IconButton` 或 `InkWell`,icon 要怎麼吃 theme、不去毀掉 dark mode,tap 區域怎麼抓,還有無障礙(a11y)到底要補哪幾個地方。
先有一個能跑的最小 app,才知道自己在改什麼
寫 Flutter 的老習慣:先弄一個超小的 demo app,確認環境跟 theme 都是你預期的,再開始玩 icon,不然你有時候會以為是 icon 有問題,其實是整個 `MaterialApp` 設定就怪怪的。
一個很裸的 scaffold,大概像這樣:
import 'package:flutter/material.dart';
void main() => runApp(const IntroApp());
class IntroApp extends StatelessWidget {
const IntroApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Icons Intro',
theme: ThemeData(
useMaterial3: true,
colorSchemeSeed: Colors.indigo,
),
home: Scaffold(
appBar: AppBar(
title: const Text('Icons Intro'),
actions: const [
Padding(
padding: EdgeInsets.only(right: 8.0),
child: Icon(Icons.search), // 直接吃 app bar 的顏色
),
],
),
body: const Center(
child: Icon(
Icons.favorite,
size: 48, // 先丟一顆大的在中間當對照
),
),
),
);
}
}
這種小 app 很無聊沒錯,但它是後面所有「改 icon 顏色、size、動畫、a11y」的 baseline,你要知道「不動任何設定時,Flutter 會怎麼處理 icons」。
搞懂一件事就好:Icon 本質上就是一個字型 glyph
很多人一開始都會把 icon 當圖片:但在 Flutter 這邊,`Icon` 其實比較像是「從一個特別的字型裡拿一個字出來」——那個字型就是 Material Icons(或你用的其他 icon font)。
`Icon` 這個 widget 本身只是拿著一個 `IconData`,真正決定它長什麼樣子的,是附近的兩個東西:
1. 最近的 `IconTheme`:負責 size、color、opacity 這種 icon 級的樣式。
2. 現在的 `Theme`:比如你是 light 還是 dark、`colorScheme` 長怎樣,`AppBar` 用什麼 foreground color… 這些都會影響到 icon。
有點像我之前聽人家形容:
IconTheme 就像一個房間裡的「服裝規定」,你說今天全部人都穿黑色 T-shirt,除非有人自己硬要穿紅色,不然大家就一起黑到底。
丟個簡單範例,感覺一下那個「房間」的概念:
import 'package:flutter/material.dart';
void main() => runApp(const ConceptApp());
class ConceptApp extends StatelessWidget {
const ConceptApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
theme: ThemeData(
useMaterial3: true,
colorSchemeSeed: Colors.teal,
),
home: Scaffold(
appBar: AppBar(title: const Text('IconTheme Basics')),
body: Center(
child: IconTheme(
data: const IconThemeData(
size: 40,
opacity: 0.9,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: const [
Icon(Icons.home),
SizedBox(width: 16),
Icon(Icons.star),
SizedBox(width: 16),
Icon(Icons.settings),
],
),
),
),
),
);
}
}
這三顆 icon 都沒特別設定 size、color,卻長一樣,就是因為被包在同一個 `IconTheme` 房間裡。你只要抓到這個 inheritance 的感覺,後面很多「為什麼這顆 icon 顏色怪怪的」的 debug 就會比較有方向。
先分清楚:Icon / IconButton / InkWell 到底誰管什麼
這邊超多人搞混:明明是要做一個可以點的 icon,結果就直接 `GestureDetector + Icon`,然後 tap 區域小小一坨,沒有 ripple,也沒有 semantics。
簡單粗暴分一下:
● `Icon`:純裝飾、純顯示,不負責互動,適合那種「只是放在文字旁邊點綴一下」的狀況。
● `IconButton`:要點擊、要有 ripple、要有 tooltip、要有合理的 hit area,就用它。它已經幫你把很多無障礙跟互動的東西都包好了。
● `InkWell` / `InkResponse`:你要的是「一整塊都能點」的卡片或區域,icon 只是裡面的一部分,那就用這個把整塊包起來。
import 'package:flutter/material.dart';
void main() => runApp(const WhichWidgetApp());
class WhichWidgetApp extends StatelessWidget {
const WhichWidgetApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
theme: ThemeData(
useMaterial3: true,
colorSchemeSeed: Colors.deepPurple,
),
home: Scaffold(
appBar: AppBar(title: const Text('Icon vs IconButton')),
body: Center(
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.info), // 真的只是裝飾
const SizedBox(width: 24),
IconButton(
icon: const Icon(Icons.favorite),
tooltip: 'Like',
onPressed: () {},
),
const SizedBox(width: 24),
InkWell(
borderRadius: BorderRadius.circular(24),
onTap: () {},
child: const Padding(
padding: EdgeInsets.all(8.0),
child: Icon(Icons.share),
),
),
],
),
),
),
);
}
}
很多在台灣公司接到的 app 設計稿,右上角一排小 icon(搜尋、收藏、分享)看起來都一樣,但實作上應該是三種不同語意:有的是 action、有的是 purely decorative、有的是整塊卡片點擊,這邊 widget 選錯,後面你要補無障礙跟 hit area 會超痛苦。
顏色不要亂寫:讓 theme 幫你撐住 light / dark mode
先承認一件事:大家第一次寫 Flutter(或 React、Vue 也一樣)超愛直接 `color: Colors.black`,因為這樣看起來「很確定」。然後一開 dark mode 整個 icon 直接消失在背景裡。
比較健康的做法,是把顏色交給 `Theme` 和 `IconTheme`,然後你只決定「大概是 primary 還是 secondary」,不要直接寫死 black / white。
import 'package:flutter/material.dart';
void main() => runApp(const ThemeIconsApp());
class ThemeIconsApp extends StatefulWidget {
const ThemeIconsApp({super.key});
@override
State createState() => _ThemeIconsAppState();
}
class _ThemeIconsAppState extends State {
bool dark = false;
@override
Widget build(BuildContext context) {
final seed = Colors.orange;
return MaterialApp(
themeMode: dark ? ThemeMode.dark : ThemeMode.light,
theme: ThemeData(
useMaterial3: true,
colorSchemeSeed: seed,
iconTheme: const IconThemeData(size: 28),
),
darkTheme: ThemeData(
useMaterial3: true,
colorSchemeSeed: seed,
brightness: Brightness.dark,
iconTheme: const IconThemeData(size: 28),
),
home: Scaffold(
appBar: AppBar(
title: const Text('Themed Icons'),
actions: [
IconButton(
icon: Icon(dark ? Icons.light_mode : Icons.dark_mode),
tooltip: 'Toggle theme',
onPressed: () => setState(() => dark = !dark),
),
],
),
body: Center(
child: Row(
mainAxisSize: MainAxisSize.min,
children: const [
Icon(Icons.check_circle),
SizedBox(width: 16),
Icon(Icons.warning),
SizedBox(width: 16),
Icon(Icons.error_outline),
],
),
),
),
);
}
}
你在這個小 app 裡狂切 light/dark,就會發現一個很重要的感覺:你什麼都沒動,icon 自己會變成適合的顏色,對比也會維持在可讀範圍。
如果你有品牌色:像很多台灣的服務都會有一個「momo 粉」、「蝦皮橘」這種品牌色,最穩的是丟到 `colorSchemeSeed`,讓 Flutter 幫你推導整套顏色,然後你就用 `Theme.of(context).colorScheme.primary` 去上在少數需要強調的 icon 上。
icon size 跟可點擊範圍,其實是兩個不同的東西
這個坑真的超常見:你看到設計稿上 icon 看起來 18dp,就直接 `size: 18`,然後外面什麼都不包,結果實機上使用者手指一碰就 miss,一直抱怨「這 app 很難點」。
設計 guideline(像 Google Material)通常都會說:mobile 上可點擊區域大概要 48x48 dp 左右。`IconButton` 已經幫你做掉這件事;如果你硬要自己用 `Icon`,就記得用 padding 把 hit area 撐大。
import 'package:flutter/material.dart';
void main() => runApp(const SizeLayoutApp());
class SizeLayoutApp extends StatelessWidget {
const SizeLayoutApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
theme: ThemeData(useMaterial3: true),
home: Scaffold(
appBar: AppBar(title: const Text('Icon Sizes & Hit Areas')),
body: Center(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.zoom_in, size: 18), // 小小裝飾
const SizedBox(width: 12),
IconButton(
icon: const Icon(Icons.zoom_in),
onPressed: () {},
),
const SizedBox(width: 12),
InkWell(
borderRadius: BorderRadius.circular(24),
onTap: () {},
child: const Padding(
padding: EdgeInsets.all(12), // 把 hit area 撐大
child: Icon(Icons.zoom_out, size: 20),
),
),
],
),
),
),
);
}
}
你可以把手指真的放在實機上試一下,會發現「看起來一樣大的 icon」,用 `IconButton` 跟沒用 padding 的 `Icon`,那個容錯率完全不同。
無障礙這件事:icon 要「講話」,不然螢幕閱讀器會很尷尬
台灣很多專案老實說對 a11y 沒那麼敏感:但你只要遇過一次政府標案或大一點的金融客戶,就會突然發現「螢幕閱讀器」這四個字超級現實。
icon 本身對視覺使用者很直覺,但對螢幕閱讀器來說,如果你不給任何 label,它就是一個沉默的圖案。
幾個基本觀念先抓好:
● 可以點的 icon:用 `IconButton`,順便給 `tooltip`,這樣長按或 web hover 也有提示。
● 純裝飾 icon:用 `ExcludeSemantics` 把它從螢幕閱讀器的世界藏起來,避免亂唸。
● 有意義但不能點:例如「定位已開啟」這種狀態 icon,用 `Semantics(label: ...)` 包起來。
import 'package:flutter/material.dart';
void main() => runApp(const A11yIconsApp());
class A11yIconsApp extends StatelessWidget {
const A11yIconsApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
theme: ThemeData(useMaterial3: true),
home: Scaffold(
appBar: AppBar(title: const Text('Accessible Icons')),
body: Center(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Icons.delete),
tooltip: 'Delete item',
onPressed: () {},
),
const SizedBox(width: 24),
// 純裝飾,螢幕閱讀器可以跳過
const ExcludeSemantics(
child: Icon(Icons.star),
),
const SizedBox(width: 24),
// 有意義但不能點
const Semantics(
label: 'Location enabled',
child: Icon(Icons.location_on),
),
],
),
),
),
);
}
}
如果你手邊有 Android 裝置,可以開 TalkBack 實際聽聽看這三種 icon 被唸出來的差別,感覺會很直接。
icon 狀態切換的時候,順便加一點動畫,整個質感會直接拉高
最典型的例子:收藏愛心那顆。你點下去,如果只是「border 變成實心」這種瞬間切換,其實也不是不能用,只是會有點硬。
Flutter 給你兩種很好用的東西:
● `AnimatedSwitcher`:你可以把兩顆不同狀態的 icon 丟進去,切換時加個 scale / fade 動畫。
● `AnimatedIcon`:內建一些很常見的動畫組合,像 play/pause、menu/close,直接用 `AnimationController` 控就好。
import 'package:flutter/material.dart';
void main() => runApp(const ToggleIconApp());
class ToggleIconApp extends StatefulWidget {
const ToggleIconApp({super.key});
@override
State createState() => _ToggleIconAppState();
}
class _ToggleIconAppState extends State {
bool liked = false;
@override
Widget build(BuildContext context) {
return MaterialApp(
theme: ThemeData(
useMaterial3: true,
colorSchemeSeed: Colors.pink,
),
home: Scaffold(
appBar: AppBar(title: const Text('Toggle Icon')),
body: Center(
child: IconButton(
tooltip: liked ? 'Unlike' : 'Like',
onPressed: () => setState(() => liked = !liked),
icon: AnimatedSwitcher(
duration: const Duration(milliseconds: 250),
transitionBuilder: (child, anim) {
return ScaleTransition(
scale: anim,
child: child,
);
},
child: liked
? const Icon(
Icons.favorite,
key: ValueKey('on'),
)
: const Icon(
Icons.favorite_border,
key: ValueKey('off'),
),
),
),
),
),
);
}
}
這段你在實機上點幾次就會懂:UI 其實沒做什麼大事,但那 250ms 的縮放動畫,會讓整個 app 感覺比較「有在乎細節」。
如果是播放鍵這種,就可以直接用內建的 `AnimatedIcons.play_pause`:
import 'package:flutter/material.dart';
void main() => runApp(const AnimatedIconDemo());
class AnimatedIconDemo extends StatefulWidget {
const AnimatedIconDemo({super.key});
@override
State createState() => _AnimatedIconDemoState();
}
class _AnimatedIconDemoState extends State
with SingleTickerProviderStateMixin {
late final AnimationController _c = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 300),
);
bool playing = false;
@override
void dispose() {
_c.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return MaterialApp(
theme: ThemeData(useMaterial3: true),
home: Scaffold(
appBar: AppBar(title: const Text('AnimatedIcons.play_pause')),
body: Center(
child: IconButton(
tooltip: playing ? 'Pause' : 'Play',
iconSize: 40,
onPressed: () {
setState(() => playing = !playing);
if (playing) {
_c.forward();
} else {
_c.reverse();
}
},
icon: AnimatedIcon(
icon: AnimatedIcons.play_pause,
progress: _c,
),
),
),
),
);
}
}
導航 icon:Back / Close 真的不要自己畫輪子
這邊有個很偷懶但很實用的原則:只要是「返回」或「關閉」這種超基本的導航 icon,盡量用 Flutter 已經幫你準備好的 `BackButton` 跟 `CloseButton`。
原因其實很多:
● RTL(從右到左)語系:像阿拉伯文那種,back icon 方向會反過來,自己用 `Icon(Icons.arrow_back)` 就要手動處理,但 `BackButton` 幫你顧到了。
● Navigator 行為:`BackButton` 預設會用 `Navigator.of(context).maybePop()`,沒有上一頁的時候不會直接炸掉。
● semantics & hit area:也都幫你處理好了。
import 'package:flutter/material.dart';
void main() => runApp(const NavIconsApp());
class NavIconsApp extends StatelessWidget {
const NavIconsApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
// 真正要支援 RTL 會再加 localizationsDelegates 等等
home: Scaffold(
appBar: AppBar(
leading: const BackButton(),
title: const Text('Navigation Icons'),
actions: const [
CloseButton(),
],
),
body: const Center(
child: Text('Use BackButton/CloseButton when possible'),
),
),
);
}
}
尤其是那種要上架 Google Play、還要過審的 app,這種小地方都用 framework 給你的預設,其實比較不容易出事。
只想改一小塊 icon 風格,用 IconTheme.merge 就好
整體 theme 不動,只想在某張卡片裡 icon 長得不一樣:例如某個「雲端狀態」卡片,你希望裡面的 icon 都比較大、顏色固定是 cyan,不要被外面的 `AppBar` 之類影響。
這種時候就用 `IconTheme.merge` 把一個小範圍包起來,等於開一個小房間,裡面的人都穿你指定的衣服。
import 'package:flutter/material.dart';
void main() => runApp(const MergeThemeApp());
class MergeThemeApp extends StatelessWidget {
const MergeThemeApp({super.key});
@override
Widget build(BuildContext context) {
final base = ThemeData(
useMaterial3: true,
colorSchemeSeed: Colors.cyan,
);
return MaterialApp(
theme: base,
home: Scaffold(
appBar: AppBar(title: const Text('IconTheme.merge Example')),
body: Center(
child: IconTheme.merge(
data: const IconThemeData(
size: 36,
color: Colors.cyan,
),
child: Card(
elevation: 2,
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
mainAxisSize: MainAxisSize.min,
children: const [
Icon(Icons.cloud),
SizedBox(width: 16),
Icon(Icons.cloud_queue),
SizedBox(width: 16),
Icon(Icons.cloud_done),
],
),
),
),
),
),
),
);
}
}
這種寫法在實際專案裡很好用,像你在台灣常見的金融 app 裡,首頁一堆「功能卡片」:每張卡片裡的 icon size / color 可能都要統一,但跟 app bar 又不一樣,這時候就很適合用這一招。
常見錯誤 vs 比較穩的寫法,直接對照一下
整理一下幾個很常見的翻車:這邊做一個小小對照表,方便你之後回來翻。
比較表:icon 常見雷點 & 怎麼修
| 狀況 | NG 寫法 | 比較好的做法 |
|---|---|---|
| 顏色寫死,dark mode 爆炸 | 直接 `color: Colors.black` 或 `Colors.white` | 讓 theme / IconTheme 決定,或用 `colorScheme.primary` |
| 小小一顆還要點 | `GestureDetector + Icon(size: 18)` | `IconButton` 或 `InkWell + Padding` 撐 hit area |
| 螢幕閱讀器不知道它是誰 | 重要 icon 沒有 tooltip / semantics | `IconButton.tooltip` 或 `Semantics(label: ...)` |
| theme 完全不起作用 | 每一顆 icon 都手動寫 `size` / `color` | 用 `IconTheme` 統一,只有少數例外再 override |
硬舉幾段 code 來對照:
// ❌ 顏色寫死,dark mode 基本上會看不見
const Icon(
Icons.settings,
color: Colors.black,
);
// ✅ 交給 theme / IconTheme
const Icon(Icons.settings);
// 或者用 colorScheme 裡的顏色
Icon(
Icons.settings,
color: Theme.of(context).colorScheme.primary,
);
// ❌ 小 hit area、沒有 ripple、語意也怪
GestureDetector(
onTap: () {},
child: const Icon(
Icons.edit,
size: 18,
),
);
// ✅ IconButton 幫你處理一堆東西
IconButton(
icon: const Icon(Icons.edit),
tooltip: 'Edit',
onPressed: () {},
);
// ❌ 沒有 label 的重要 icon,螢幕閱讀器超困惑
Row(
children: const [
Icon(Icons.download),
SizedBox(width: 8),
Icon(Icons.upload),
],
);
// ✅ 有 tooltip / semantics,機器跟人都比較懂
Row(
children: [
IconButton(
icon: const Icon(Icons.download),
tooltip: 'Download file',
onPressed: () {},
),
const SizedBox(width: 8),
const Semantics(
label: 'Upload file',
child: Icon(Icons.upload),
),
],
);
icon 拿不到你預期的顏色?先懷疑它站錯隊
有一種 debug 場景超常見:你在 theme 裡明明有設 `iconTheme`,但某顆 icon 就是長得跟大家不一樣。
這時候可以先想兩件事:
● 它是不是被放在 `IconTheme` 外面?如果整個 subtree 都包在一個 `IconTheme` 裡,而你那顆 icon 剛好寫在外面,那當然吃不到。
● 你有沒有在 local 又 override 掉?只要你在那顆 icon 上自己寫了 `size` 或 `color`,就會蓋掉從上面傳下來的設定。
import 'package:flutter/material.dart';
void main() => runApp(const ContextAwareIconsApp());
class ContextAwareIconsApp extends StatelessWidget {
const ContextAwareIconsApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
theme: ThemeData(
useMaterial3: true,
colorSchemeSeed: Colors.green,
),
home: Scaffold(
appBar: AppBar(title: const Text('Context-Aware Icons')),
body: Center(
child: IconTheme(
data: const IconThemeData(size: 30),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// 這顆吃到 size = 30
const Icon(Icons.map),
const SizedBox(height: 12),
// 這顆自己寫 size,等於故意打破 inheritance
const Icon(
Icons.map,
size: 16,
),
const SizedBox(height: 12),
Builder(
builder: (innerContext) {
// 還是在同一個 IconTheme 房間裡
return const Icon(Icons.map);
},
),
],
),
),
),
),
);
}
}
如果你哪天遇到「為什麼這顆 icon 不跟大家一起變大變小」這種 bug,就可以從 widget tree 的位置跟 local override 兩個方向先檢查。
說真的,我自己在意 icon 的幾個點,就這幾個
整理到這邊,其實可以很粗暴地收斂成幾條原則:
1. 能用 IconButton 就不要自己亂組合。
尤其是可點擊的東西,`IconButton` 幫你顧 hit area、ripple、語意,還有很多看不到的小細節,你省下來的不是幾行 code,是一堆你懶得測的邊界狀況。
2. 把 theme 當成第一順位,而不是「寫完再來補」。
顏色、size 能用 `Theme` / `IconTheme` 就不要每顆都寫死。這在你哪天要上 dark mode 或跟設計一起改整體配色的時候,會救你一次。
3. hit area 跟視覺 size 分開想。
icon 看起來可以小小一顆沒關係,但手指要有地方可以按。`IconButton` 或 `Padding + InkWell` 真的要用到爛掉沒關係。
4. 無障礙不要等客戶要求才補。
tooltip 跟 semantics label 都不難加,但如果你是等產品上線才回頭補,那時候你會覺得自己在還債。乾脆在一開始就順手寫好。
5. 動畫不用多,一點點就夠了。
像收藏、播放這種狀態切換,給它一點 scale 或內建 `AnimatedIcon`,整個 app 的「有用心程度」會看起來高很多,真的。
最後丟一個小 demo,把這些東西串在一起:
import 'package:flutter/material.dart';
void main() => runApp(const BestPracticesMini());
class BestPracticesMini extends StatelessWidget {
const BestPracticesMini({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
theme: ThemeData(
useMaterial3: true,
colorSchemeSeed: Colors.blue,
),
home: Scaffold(
appBar: AppBar(
leading: const BackButton(),
title: const Text('Mini Best Practices'),
actions: [
IconButton(
tooltip: 'Search',
onPressed: () {},
icon: const Icon(Icons.search), // 吃 app bar 的顏色
),
],
),
body: Center(
child: IconTheme.merge(
data: const IconThemeData(size: 32),
child: Row(
mainAxisSize: MainAxisSize.min,
children: const [
Icon(Icons.thumb_up), // 純裝飾
SizedBox(width: 16),
Semantics(
label: 'Location enabled',
child: Icon(Icons.location_on),
),
],
),
),
),
),
);
}
}
最後留一個小小的動作給你:如果你現在專案裡已經有一堆 icons,不用全部重寫,挑一個畫面就好,把那一頁的 icon 改成「全部吃 theme、可點的都換成 IconButton、有語意的都補 tooltip/semantics」。
先試一頁,看看實際感覺差多少,有卡住的地方再回來檢查一下這些段落,通常會找到對應的坑。
