“深色模式(Dark Mode),也被稱為暗黑模式,是一種高對比度,或者反色模式的顯示模式,開啟之後在夜間可以緩解疲勞,更易於閱讀,同時也能在一定程度上達到省電的效果。iOS和安卓分別從 iOS 13 和 Android 10(不同廠商不盡相同,部分 Android 9 也支援) 開始加入深色模式的支援,各大瀏覽器紛紛開始支援深色模式,強如微信也終於在 iOS 客戶端 7.0.12、Android 客戶端 7.0.13 支援了深色模式,等網頁端適配深色模式後將更進一步提高使用者體驗的一致性。
最近在業餘時間開發自己的 App,起初並開始考慮深色模式的適配,到晚上的時候,介面慘不忍睹。雖然可以手動在系統設定裡配置外觀,但是全域性修改也會影響其他 App(很討厭修改了自己而影響了別人,比較傾向自完備性)。
對我來說,適配深色模式是勢在必行的:
個人很喜歡深色模式, 獨立做一款符合自己品味的 App 也是一大幸事。 也不知道哪天 Apple 會硬性要求適配深色模式。如今硬體的效能越來越強大,記憶體也越來越大,人們對色彩的感知也越來越強烈。 App 除了能解決使用者的痛點之外,互動、色彩也變得越來越重要。 寫過很多 App,但對主題這塊都沒涉及過,可以借這個契機學習一波。
需求
使用者可以主動設定深色模式、淺色模式、跟隨系統
要實現這個需求,可以先問幾個問題:
如何設定主題 如何去切換主題 如何儲存切換的狀態
分析
我們一起逐個攻破上面的問題。
如何設定主題
Flutter 提供了 Theme 元件,它可以設定 Widget
的主題,Theme
元件可以為 Material
App 定義主題資料(ThemeData
)。Material 元件庫裡很多元件都使用了主題資料,如導航欄顏色、標題字型、Icon樣式等。Theme 內會使用 InheritedWidget
來為其子樹共享樣式資料。它有兩種:
全域性 Theme 區域性 Theme
全域性 Theme 是由應用程式根 MaterialApp
的 Theme
:
/// 全域性主題在MaterialApp的theme屬性
/// 全域性生效
MaterialApp(
title: 'demo',
theme: ThemeData( // 這裡就是引數
brightness: Brightness.dark,
primaryColor: Colors.lightBlue[800],
accentColor: Colors.cyan[600],
),
);
複製程式碼
區域性 Theme:
/// 假如我們要給 FloatingActionButton 設定主題樣式
/// 直接寫個 Theme 包裹 FloatingActionButton 元件
/// 然後設定 data,接收型別依然是 ThemeData,裡面填寫我們的引數
/// (如果沒有設定區域性主題則預設使用全域性主題)
Theme(
data: ThemeData(
accentColor: Colors.red,
),
child: FloatingActionButton(
onPressed: () {},
child: Icon(Icons.add),
),
);
複製程式碼
Theme 使用舉例
擴充套件父主題:
擴充套件父主題時無需覆蓋所有的主題屬性,可以通過使用 copyWith
方法來實現。
Theme(
data: Theme.of(context).copyWith(accentColor: Colors.yellow),
child: FloatingActionButton(
onPressed: (){},
child: new Icon(Icons.add),
),
);
複製程式碼
Theme.of(context)
將查詢 Widget
樹並返回樹中最近的 Theme
。如果 Widget
之上有一個單獨的 Theme
定義,則返回該值。如果沒有,則返回 App 主題。
區分平臺顯示指定主題
我們也可以使用 io 包裡的 Platform 來進行判斷。
MaterialApp(
theme: defaultTargetPlatform == TargetPlatform.iOS
? iOSTheme
: AndroidTheme,
title: 'Flutter Theme',
home: new MyHomePage(),
)
複製程式碼
根據當前展示的模式指定顏色
通過 Theme.of(context).brightness
的來判斷現在是深色還是淺色模式。
var isDarkTheme = Theme.of(context).brightness == Brightness.dark;
Text("APP",
color : isDarkTheme ? AppColors.darkPink : AppColors.textBlack,
)
複製程式碼
ThemeData 解讀
上面說了這麼多主題的使用,但是當我們真正要進行適配的時候,還是無從下手,因為我們不知道設定主題後到底起了哪些樣式變化,那麼 ThemeData
就是我們的答案。
ThemeData({
Brightness brightness, // 應用程式整體主題的亮度。 由按鈕等 Widget 使用,以確定在不使用主色或強調色時要選擇的顏色
MaterialColor primarySwatch, // 主題顏色樣本
Color primaryColor, // 前景色(文字、按鈕等)
Brightness primaryColorBrightness, // primaryColor 的亮度
Color primaryColorLight, // primaryColor 的較亮版本
Color primaryColorDark, // primaryColor 的較暗版本
Color accentColor, // 前景色(文字、按鈕等)
Brightness accentColorBrightness, // accentColor的亮度。 用於確定放置在突出顏色頂部的文字和圖示的顏色(例如FloatingButton上的圖示)
Color canvasColor, // MaterialType.canvas Material 的預設顏色
Color scaffoldBackgroundColor, // 作為Scaffold基礎的Material預設顏色,典型Material應用或應用內頁面的背景顏色。
Color bottomAppBarColor, // BottomAppBar 的預設顏色
Color cardColor, // Material被用作Card時的顏色
Color dividerColor, // Dividers 和 PopupMenuDividers的顏色,也用於ListTiles中間,和DataTables 的每行中間
Color focusColor, // 焦點獲取時的顏色,例如,一些按鈕焦點、輸入框焦點。
Color hoverColor, // 點選之後徘徊中的顏色,例如,按鈕長按,按住之後的顏色
Color highlightColor, // 用於類似墨水噴濺動畫或指示選單被選中的高亮顏色。
Color splashColor, // 墨水噴濺的顏色。
InteractiveInkFeatureFactory splashFactory, // 定義InkWall和InkResponse生成的墨水噴濺的外觀。
Color selectedRowColor, // 選中行時的高亮顏色
Color unselectedWidgetColor, // 用於 Widget 處於非活動(但已啟用)狀態的顏色。 例如,未選中的核取方塊。 通常與 accentColor 形成對比。
Color disabledColor, // 用於 Widget 無效的顏色,無論任何狀態。例如禁用核取方塊
Color buttonColor, // Material 中 RaisedButtons 使用的預設填充色
ButtonThemeData buttonTheme, // 定義了按鈕等控制元件的預設配置
ToggleButtonsThemeData toggleButtonsTheme, // Flutter 1.9 全新元件 ToggleButtons 的主題
Color secondaryHeaderColor, // 有選定行時 PaginatedDataTable 標題的顏色
Color textSelectionColor, // 文字欄位中選中文字的顏色,例如 TextField
Color cursorColor, // 輸入框游標顏色
Color textSelectionHandleColor, // 用於調整當前文字的哪個部分的控制程式碼顏色
Color backgroundColor, // 與 primaryColor 對比的顏色(例如 用作進度條的剩餘部分)
Color dialogBackgroundColor, // Dialog 元素的背景色
Color indicatorColor, // TabBar 中選項選中的指示器顏色。
Color hintColor, // 用於提示文字或佔位符文字的顏色,例如在 TextField 中。
Color errorColor, // 用於輸入驗證錯誤的顏色,例如在 TextField 中
Color toggleableActiveColor, // 用於突出顯示切換Widget(如Switch,Radio和Checkbox)的活動狀態的顏色。
String fontFamily, // 字型樣式
TextTheme textTheme, // 與卡片和畫布對比的文字顏色
TextTheme primaryTextTheme, // 一個與主色對比的文字主題
TextTheme accentTextTheme, // 與突出顏色對照的文字主題
InputDecorationTheme inputDecorationTheme, // InputDecorator,TextField 和 TextFormField 的預設 InputDecoration 值基於此主題
IconThemeData iconTheme, // 與卡片和畫布顏色形成對比的圖示主題
IconThemeData primaryIconTheme, // 一個與主色對比的圖片主題
IconThemeData accentIconTheme, // 與突出顏色對照的圖片主題
SliderThemeData sliderTheme, // 用於渲染 Slider 的顏色和形狀
TabBarTheme tabBarTheme, // TabBar 的主題樣式
TooltipThemeData tooltipTheme, // tooltip 提示的主題樣式
CardTheme cardTheme, // 卡片的主題樣式
ChipThemeData chipTheme, // 用於渲染Chip的顏色和樣式
TargetPlatform platform, // Widget 需要適配的目標型別
MaterialTapTargetSize materialTapTargetSize, // Chip 等元件的尺寸主題設定
bool applyElevationOverlayColor, // 是否應用 elevation 覆蓋顏色
PageTransitionsTheme pageTransitionsTheme, // 頁面轉場主題樣式
AppBarTheme appBarTheme, // AppBar 主題樣式
BottomAppBarTheme bottomAppBarTheme, // 底部導航主題樣式
ColorScheme colorScheme, // scheme組顏色,一組13種顏色,可用於配置大多陣列件的顏色屬性
DialogTheme dialogTheme, // 對話方塊主題樣式
FloatingActionButtonThemeData floatingActionButtonTheme, // FloatingActionButton 的主題樣式,也就是 Scaffold 屬性的那個
Typography typography, // 用於配置 TextTheme、primaryTextTheme 和 accentTextTheme的顏色和幾何文字主題值
CupertinoThemeData cupertinoOverrideTheme, // cupertino 覆蓋的主題樣式
SnackBarThemeData snackBarTheme, // 彈出的 snackBar 的主題樣式
BottomSheetThemeData bottomSheetTheme, // 底部滑出對話方塊的主題樣式
PopupMenuThemeData popupMenuTheme, // 彈出選單對話方塊的主題樣式
MaterialBannerThemeData bannerTheme, // Material 材質的 Banner 主題樣式
DividerThemeData dividerTheme, // Divider 元件的主題樣式,也就是那個橫向線條元件
ButtonBarThemeData buttonBarTheme,
})
複製程式碼
更多完成資訊,大家可參閱它的原始碼註釋。
屬性很是比較多的,通常我們用到的 5 ~ 10 個左右,如果要高度定製可能會更多點。
primarySwatch 它是主題顏色的一個 樣本色
, 通過這個樣本色可以在一些條件下生成一些其它的屬性,例如,如果沒有指定 primaryColor,並且當前主題不是深色主題,那麼 primaryColor 就會預設為primarySwatch 指定的顏色,還有一些相似的屬性如 accentColor 、indicatorColor 等也會受primarySwatch 影響。
切換 & 儲存
我們可以通過 shared_preferences
儲存使用者設定,通過 Provider
實現狀態管理。
新增依賴
provider: ^4.0.5
flustars: ^0.2.6+1
複製程式碼
實踐
定義淺色主題
// light_color.dart
import 'package:flutter/material.dart';
const MaterialColor lightColor =
MaterialColor(_lightColorPrimaryValue, <int, Color>{
50: Color(0xFFFDEAE7),
100: Color(0xFFFACBC3),
200: Color(0xFFF7A89C),
300: Color(0xFFF48574),
400: Color(0xFFF16B56),
500: Color(_lightColorPrimaryValue),
600: Color(0xFFED4A32),
700: Color(0xFFEB402B),
800: Color(0xFFE83724),
900: Color(0xFFE42717),
});
const int _lightColorPrimaryValue = 0xFFEF5138;
const MaterialColor lightColorAccent =
MaterialColor(_lightColorAccentValue, <int, Color>{
100: Color(0xFFFFFFFF),
200: Color(_lightColorAccentValue),
400: Color(0xFFFFB4AF),
700: Color(0xFFFF9C96),
});
const int _lightColorAccentValue = 0xFFFFE4E2;
複製程式碼
定義好自己的主題色0xFFEF5138
, 然後通過工具生成。工具地址: mbitson/mcg
通用深色模式 Provider Model 類
// theme_state.dart
class ThemeState with ChangeNotifier {
/// 0:淺色模式 1:深色模式 2:跟隨系統
int _darkMode;
int get darkMode => _darkMode;
static const Map<int, String> darkModeMap = {0: '淺色模式', 1: '深色模式', 2: '跟隨系統'};
ThemeData get lightTheme =>
ThemeData(brightness: Brightness.light, primarySwatch: lightColor);
ThemeData get darkTheme => ThemeData.dark();
ThemeState() {
_init();
}
void _init() async {
await SpUtil.getInstance();
int localModel = SpUtil.getInt('kDarkMode', defValue: 2);
changeMode(localModel);
}
void changeMode(int darkMode) async {
_darkMode = darkMode;
notifyListeners();
SpUtil.putInt("kDarkMode", darkMode);
}
}
複製程式碼
主題選擇頁面
// theme_page.dart
class ThemePage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
elevation: 0,
title: Text('主題選擇'),
leading: GestureDetector(
onTap: () {
Navigator.of(context).pop();
},
child: Icon(Icons.arrow_back_ios),
),
),
body: Consumer<ThemeState>(
builder: (context, themeState, child) {
Map items = ThemeState.darkModeMap;
return ListView.builder(
itemBuilder: (context, index) {
return ListTile(
onTap: () {
themeState.changeMode(items.keys.toList()[index]);
},
title: Text(
items.values.toList()[index],
style: TextStyle(
color: index == themeState.darkMode
? Colors.red
: Color(0xff333333)),
),
);
},
itemCount: items.length,
);
},
));
}
}
複製程式碼
在 main.dart 整合呼叫
void main() {
runApp(MyApp());
}
class MyApp extends StatefulWidget {
@override
_MyAppState createState() => _MyAppState();
}
class _MyAppState extends State<MyApp> {
@override
Widget build(BuildContext context) {
return MultiProvider(
providers: [
ChangeNotifierProvider(create: (ctx) => ThemeState())
],
child: Consumer<ThemeState>(
builder: (context, themeState, child) {
if (themeState.darkMode == 2) { // 跟隨系統
return MaterialApp(
title: 'Oldbirds',
theme: themeState.lightTheme,
darkTheme: themeState.darkTheme,
onGenerateRoute: generateRoute,
initialRoute: SplashRoute,
debugShowCheckedModeBanner: false,
);
} else {
return MaterialApp(
title: 'Oldbirds',
theme: themeState.darkMode == 1 // 深色模式
? themeState.darkTheme
: themeState.lightTheme,
onGenerateRoute: generateRoute,
initialRoute: SplashRoute,
debugShowCheckedModeBanner: false,
);
}
},
));
}
}
複製程式碼
心得
上面的配置完成後,深色適配的功能完成 80% 左右,還有殘餘的,需要區域性按需設定,有些當然還需按設計的色彩進行改動。
全域性配置儘量通用,需要規範專業級別的 ui 設計(因為一般會有設計規範)。
如果不得不改,那麼就是 去同存異:
比如指定的文字樣式與全域性配置相同時,就刪除它
如果文字顏色相同,但是字號不同。那就刪除顏色配置資訊,保留字號設定
Text( "僅保留不同", style: Theme.of(context).textTheme.body1.copyWith(fontSize: 14.0) ) 複製程式碼
顏色不同,因為深色模式主要就是顏色變化:
Text( "僅保留不同", style: Theme.of(context).textTheme.body1.copyWith(color: Colors.red, fontSize: 14.0) ) 複製程式碼
參考
快速適配 Flutter 之深色模式 Flutter適配夜間模式 App主題色控制 Themes in Flutter: Part 1 Flutter Dynamic Theme: Dark Mode & Custom Themes
更多文章閱讀,請搜尋微信公眾號: OldBirds