今天,我和你分享的主題是,從夜間模式說起,如何定制不同風格的 App 主題。
在上一篇文章中,我與你介紹了組裝與自繪這兩種自定義 Widget 的方式。對于組裝,我們按照從上到下、從左到右的布局順序去分解目標視圖,將基本的 Widget 封裝到 Column、Row 中,從而合成更高級別的 Widget;而對于自繪,我們則通過承載繪制邏輯的載體 CustomPainter,在其 paint 方法中使用畫筆 Paint 與畫布 Canvas,繪制不同風格、不同類型的圖形,從而實現基于自繪的自定義組件。
對于一個產品來說,在業務早期其實更多的是處理基本功能有和無的問題:工程師來負責實現功能,PM 負責功能好用不好用。在產品的基本功能已經完善,做到了六七十分的時候,再往上的如何做增長就需要運營來介入了。
在這其中,如何通過用戶分層去實現 App 的個性化是常見的增長運營手段,而主題樣式更換則是實現個性化中的一項重要技術手段。
比如,微博、UC 瀏覽器和電子書客戶端都提供了對夜間模式的支持,而淘寶、京東這樣的電商類應用,還會在特定的電商活動日自動更新主題樣式,就連現在的手機操作系統也提供了系統級切換展示樣式的能力。
那么,這些在應用內切換樣式的功能是如何實現的呢?在 Flutter 中,在普通的應用上增加切換主題的功能又要做哪些事情呢?這些問題,我都會在今天的這篇文章中與你詳細分享。
## 主題定制
主題,又叫皮膚、配色,一般由顏色、圖片、字號、字體等組成,我們可以把它看做是視覺效果在不同場景下的可視資源,以及相應的配置集合。比如,App 的按鈕,無論在什么場景下都需要背景圖片資源、字體顏色、字號大小等,而所謂的主題切換只是在不同主題之間更新這些資源及配置集合而已。
因此在 App 開發中,我們通常不關心資源和配置的視覺效果好不好看,只要關心資源提供的視覺功能能不能用。比如,對于圖片類資源,我們并不需要關心它渲染出來的實際效果,只需要確定它渲染出來是一張固定寬高尺寸的區域,不影響頁面布局,能把業務流程跑通即可。
**視覺效果是易變的,我們將這些變化的部分抽離出來,把提供不同視覺效果的資源和配置按照主題進行歸類,整合到一個統一的中間層去管理,這樣我們就能實現主題的管理和切換了。**
在 iOS 中,我們通常會將主題的配置信息預先寫到 plist 文件中,通過一個單例來控制 App 應該使用哪種配置;而 Android 的配置信息則寫入各個 style 屬性值的 xml 中,通過 activity 的 setTheme 進行切換;前端的處理方式也類似,簡單更換 css 就可以實現多套主題 / 配色之間的切換。
Flutter 也提供了類似的能力,**由 ThemeData 來統一管理主題的配置信息**。
ThemeData 涵蓋了 Material Design 規范的可自定義部分樣式,比如應用明暗模式 brightness、應用主色調 primaryColor、應用次級色調 accentColor、文本字體 fontFamily、輸入框光標顏色 cursorColor 等。如果你想深入了解 ThemeData 的其他 API 參數,可以參考官方文檔[ThemeData](https://api.flutter.dev/flutter/material/ThemeData/ThemeData.html)。
通過 ThemeData 來自定義應用主題,我們可以實現 App 全局范圍,或是 Widget 局部范圍的樣式切換。接下來,我便分別與你講述這兩種范圍的主題切換。
## 全局統一的視覺風格定制
在 Flutter 中,應用程序類 MaterialApp 的初始化方法,為我們提供了設置主題的能力。我們可以通過參數 theme,選擇改變 App 的主題色、字體等,設置界面在 MaterialApp 下的展示樣式。
以下代碼演示了如何設置 App 全局范圍主題。在這段代碼中,我們設置了 App 的明暗模式 brightness 為暗色、主色調為青色:
~~~
MaterialApp(
title: 'Flutter Demo',// 標題
theme: ThemeData(// 設置主題
brightness: Brightness.dark,// 明暗模式為暗色
primaryColor: Colors.cyan,// 主色調為青色
),
home: MyHomePage(title: 'Flutter Demo Home Page'),
);
~~~
試著運行一下,效果如下:
:-: 
圖 1 Flutter 全局模式主題
可以看到,雖然我們只修改了主色調和明暗模式兩個參數,但按鈕、文字顏色都隨之調整了。這是因為默認情況下,**ThemeData 中很多其他次級視覺屬性,都會受到主色調與明暗模式的影響**。如果我們想要精確控制它們的展示樣式,需要再細化一下主題配置。
下面的例子中,我們將 icon 的顏色調整為黃色,文字顏色調整為紅色,按鈕顏色調整為黑色:
~~~
MaterialApp(
title: 'Flutter Demo',// 標題
theme: ThemeData(// 設置主題
brightness: Brightness.dark,// 設置明暗模式為暗色
accentColor: Colors.black,//(按鈕)Widget 前景色為黑色
primaryColor: Colors.cyan,// 主色調為青色
iconTheme:IconThemeData(color: Colors.yellow),// 設置 icon 主題色為黃色
textTheme: TextTheme(body1: TextStyle(color: Colors.red))// 設置文本顏色為紅色
),
home: MyHomePage(title: 'Flutter Demo Home Page'),
);
~~~
運行一下,可以看到圖標、文字、按鈕的顏色都隨之更改了。
:-: 
圖 2 Flutter 全局模式主題示例 2
## 局部獨立的視覺風格定制
為整個 App 提供統一的視覺呈現效果固然很有必要,但有時我們希望為某個頁面、或是某個區塊設置不同于 App 風格的展現樣式。以主題切換功能為例,我們希望為不同的主題提供不同的展示預覽。
在 Flutter 中,我們可以使用 Theme 來對 App 的主題進行局部覆蓋。Theme 是一個單子 Widget 容器,與 MaterialApp 類似的,我們可以通過設置其 data 屬性,對其子 Widget 進行樣式定制:
* 如果我們不想繼承任何 App 全局的顏色或字體樣式,可以直接新建一個 ThemeData 實例,依次設置對應的樣式;
* 而如果我們不想在局部重寫所有的樣式,則可以繼承 App 的主題,使用 copyWith 方法,只更新部分樣式。
下面的代碼演示了這兩種方式的用法:
~~~
// 新建主題
Theme(
data: ThemeData(iconTheme: IconThemeData(color: Colors.red)),
child: Icon(Icons.favorite)
);
// 繼承主題
Theme(
data: Theme.of(context).copyWith(iconTheme: IconThemeData(color: Colors.green)),
child: Icon(Icons.feedback)
);
~~~
:-: 
圖 3 Theme 局部主題更改示例
對于上述例子而言,由于 Theme 的子 Widget 只有一個 Icon 組件,因此這兩種方式都可以實現覆蓋全局主題,從而更改 Icon 樣式的需求。而像這樣使用局部主題覆蓋全局主題的方式,在 Flutter 中是一種常見的自定義子 Widget 展示樣式的方法。
**除了定義 Material Design 規范中那些可自定義部分樣式外,主題的另一個重要用途是樣式復用。**
比如,如果我們想為一段文字復用 Materia Design 規范中的 title 樣式,或是為某個子 Widget 的背景色復用 App 的主題色,我們就可以通過 Theme.of(context) 方法,取出對應的屬性,應用到這段文字的樣式中。
Theme.of(context) 方法將向上查找 Widget 樹,并返回 Widget 樹中最近的主題 Theme。如果 Widget 的父 Widget 們有一個單獨的主題定義,則使用該主題。如果不是,那就使用 App 全局主題。
在下面的例子中,我們創建了一個包裝了一個 Text 組件的 Container 容器。在 Text 組件的樣式定義中,我們復用了全局的 title 樣式,而在 Container 的背景色定義中,則復用了 App 的主題色:
~~~
Container(
color: Theme.of(context).primaryColor,// 容器背景色復用應用主題色
child: Text(
'Text with a background color',
style: Theme.of(context).textTheme.title,//Text 組件文本樣式復用應用文本樣式
));
~~~
:-: 
圖 4 主題復用示例
## 分平臺主題定制
有時候,**為了滿足不同平臺的用戶需求,我們希望針對特定的平臺設置不同的樣式**。比如,在 iOS 平臺上設置淺色主題,在 Android 平臺上設置深色主題。面對這樣的需求,我們可以根據 defaultTargetPlatform 來判斷當前應用所運行的平臺,從而根據系統類型來設置對應的主題。
在下面的例子中,我們為 iOS 與 Android 分別創建了兩個主題。在 MaterialApp 的初始化方法中,我們根據平臺類型,設置了不同的主題:
~~~
// iOS 淺色主題
final ThemeData kIOSTheme = ThemeData(
brightness: Brightness.light,// 亮色主題
accentColor: Colors.white,//(按鈕)Widget 前景色為白色
primaryColor: Colors.blue,// 主題色為藍色
iconTheme:IconThemeData(color: Colors.grey),//icon 主題為灰色
textTheme: TextTheme(body1: TextStyle(color: Colors.black))// 文本主題為黑色
);
// Android 深色主題
final ThemeData kAndroidTheme = ThemeData(
brightness: Brightness.dark,// 深色主題
accentColor: Colors.black,//(按鈕)Widget 前景色為黑色
primaryColor: Colors.cyan,// 主題色 Wie 青色
iconTheme:IconThemeData(color: Colors.blue),//icon 主題色為藍色
textTheme: TextTheme(body1: TextStyle(color: Colors.red))// 文本主題色為紅色
);
// 應用初始化
MaterialApp(
title: 'Flutter Demo',
theme: defaultTargetPlatform == TargetPlatform.iOS ? kIOSTheme : kAndroidTheme,// 根據平臺選擇不同主題
home: MyHomePage(title: 'Flutter Demo Home Page'),
);
~~~
試著運行一下:
:-: 
(a)iOS 平臺
:-: 
(b)Android 平臺
圖 5 根據不同平臺設置對應主題
當然,除了主題之外,你也可以用 defaultTargetPlatform 這個變量去實現一些其他需要判斷平臺的邏輯,比如在界面上使用更符合 Android 或 iOS 設計風格的組件。
## 總結
好了,今天的分享就到這里。我們簡單回顧一下今天的主要內容吧。
主題設置屬于 App 開發的高級特性,歸根結底其實是提供了一種視覺資源與視覺配置的管理機制。與其他平臺類似,Flutter 也提供了集中式管理主題的機制,可以在遵循 Material Design 規范的 ThemeData 中,定義那些可定制化的樣式。
我們既可以通過設置 MaterialApp 全局主題實現應用整體視覺風格的統一,也可以通過 Theme 單子 Widget 容器使用局部主題覆蓋全局主題,實現局部獨立的視覺風格。
除此之外,在自定義組件過程中,我們還可以使用 Theme.of 方法取出主題對應的屬性值,從而實現多種組件在視覺風格上的復用。
最后,面對常見的分平臺設置主題場景,我們可以根據 defaultTargetPlatform,來精確識別當前應用所處的系統,從而配置對應的主題。
## 思考題
最后,我給你留下一個課后小作業吧。
在上一篇文章中,我與你介紹了如何實現 App Store 升級項 UI 自定義組件布局。現在,請在這個自定義 Widget 的基礎上,增加切換夜間模式的功能。

- 前言
- 開篇詞
- 預習篇
- 01丨預習篇 · 從0開始搭建Flutter工程環境
- 02丨預習篇 · Dart語言概覽
- Flutter開發起步
- 03丨深入理解跨平臺方案的歷史發展邏輯
- 04丨Flutter區別于其他方案的關鍵技術是什么?
- 05丨從標準模板入手,體會Flutter代碼是如何運行在原生系統上的
- Dart語言基礎
- 06丨基礎語法與類型變量:Dart是如何表示信息的?
- 07丨函數、類與運算符:Dart是如何處理信息的?
- 08丨綜合案例:掌握Dart核心特性
- Flutter基礎
- 09丨Widget,構建Flutter界面的基石
- 10丨Widget中的State到底是什么?
- 11丨提到生命周期,我們是在說什么?
- 12丨經典控件(一):文本、圖片和按鈕在Flutter中怎么用?
- 13丨ListView在Flutter中是什么?
- 14 丨 經典布局:如何定義子控件在父容器中排版位置?
- 15 丨 組合與自繪,我該選用何種方式自定義Widget?
- 16 丨 從夜間模式說起,如何定制不同風格的App主題?
- 17丨依賴管理(一):圖片、配置和字體在Flutter中怎么用?
- 18丨依賴管理(二):第三方組件庫在Flutter中要如何管理?
- 19丨用戶交互事件該如何響應?
- 20丨關于跨組件傳遞數據,你只需要記住這三招
- 21丨路由與導航,Flutter是這樣實現頁面切換的
- Flutter進階
- 22丨如何構造炫酷的動畫效果?
- 23丨單線程模型怎么保證UI運行流暢?
- 24丨HTTP網絡編程與JSON解析
- 25丨本地存儲與數據庫的使用和優化
- 26丨如何在Dart層兼容Android-iOS平臺特定實現?(一)
- 27丨如何在Dart層兼容Android-iOS平臺特定實現?(二)
- 28丨如何在原生應用中混編Flutter工程?
- 29丨混合開發,該用何種方案管理導航棧?
- 30丨為什么需要做狀態管理,怎么做?
- 31丨如何實現原生推送能力?
- 32丨適配國際化,除了多語言我們還需要注意什么
- 33丨如何適配不同分辨率的手機屏幕?
- 34丨如何理解Flutter的編譯模式?
- 35丨HotReload是怎么做到的?
- 36丨如何通過工具鏈優化開發調試效率?
- 37丨如何檢測并優化FlutterApp的整體性能表現?
- 38丨如何通過自動化測試提高交付質量?
- Flutter綜合應用
- 39丨線上出現問題,該如何做好異常捕獲與信息采集?
- 40丨衡量FlutterApp線上質量,我們需要關注這三個指標
- 41丨組件化和平臺化,該如何組織合理穩定的Flutter工程結構?
- 42丨如何構建高效的FlutterApp打包發布環境?
- 43丨如何構建自己的Flutter混合開發框架(一)?
- 44丨如何構建自己的Flutter混合開發框架(二)?
- 結束語
- 結束語丨勿畏難,勿輕略