随着SDK版本的迭代,Flutter官方已经很好地支持了暗黑模式了,想适配暗黑模式并非是一件难事。本文从最简单基础的写法开始,逐步引入provider、shared_preferences完善暗黑/夜间模式的适配,并提供Demo演示和源代码。
前言
在开始之前我们需要了解两个枚举类:Brightness
与ThemeMode
,还有根组件MaterialApp
的相关配置
Brightness
Brightness翻译是亮度,不过我觉得用“色调”去理解也可以,分为亮色(浅色)与暗色(深色)。Brightness.dark表示暗色,Brightness.light表示亮色。在获取App当前的亮度色调、主题配置里会用到这个枚举类。
// 位于 flutter > bin > cache > pkg > sky_ engine > lib > ui > window.dart
enum Brightness {
/// The color is dark and will require a light text color to achieve readable
/// contrast.
/// For example, the color might be dark grey, requiring white text.
dark,
/// The color is light and will require a dark text color to achieve readable
/// contrast.
/// For example, the color might be bright white, requiring black text.
light,
}
那么如何获取App当前的亮度色调(Brightness)呢?
在ThemeData类中有这么一个属性:
// flutter > packages > flutter > lib > src > material > theme_ data.dart
/// The overall theme brightness.
/// The default [TextStyle] color for the [textTheme] is black if the
/// theme is constructed with [Brightness.light] and white if the
/// theme is constructed with [Brightness.dark].
Brightness get brightness => colorScheme.brightness;
由此可以推断出:通过Theme.of(context).colorScheme.brightness
可以获取到App当前处于哪种亮度色调。
当然,使用Theme.of(context).brightness
也是一样的。Theme.of(context)获取到的即是当前的主题配置(ThemeData),而colorScheme是主题的颜色配置。
ThemeMode
// 位于> flutter > packages > flutter > lib > src > material > app.dart
/// Describes which theme will be used by [MaterialApp].
enum ThemeMode {
/// Use either the light or dark theme based on what the user has selected in
/// the system settings.
system,
/// Always use the light mode regardless of system preference.
light,
/// Always use the dark mode (if available) regardless of system preference.
dark,
}
ThemeMode(直译过来就是主题模式)与Brightness不同的是多了个system
,表示跟随系统。毕竟亮度色调虽然只有亮、暗,但我们平常使用的软件一般会有个“跟随系统”的模式来自动切换亮色、暗色。
MaterialApp
在MaterialApp里有这么三个属性:
const MaterialApp({
// ...more...
final ThemeData? theme; // 亮色主题
final ThemeData? darkTheme; // 暗色主题
final ThemeMode? themeMode; // 主题模式
// ...more...
})
这三个属性值是适配暗黑模式和个性化主题的关键。
ThemeData类即为主题配置类,可自由搭配主题的样式,在这里不多做展开。theme
为亮色下的主题样式,darkTheme
为暗色下的主题样式,而themeMode
即为前文中讲到的主题模式。
App当前应用哪个主题样式由themeMode的值决定!如下表:
当themeMode的值为 | App调用的主题 | |
---|---|---|
ThemeMode.light | theme | |
ThemeMode.dark | darkTheme | |
ThemeMode.system | 跟随系统,系统为亮色调用theme,系统为暗色则调用darkTheme |
最简单的适配
一、配置MaterialApp
为根组件MaterialApp配置亮色与暗色主题,并使themeMode作为一个变量赋值,以便切换主题使用
class MyAppState extends State<MyApp> {
ThemeMode themeMode = ThemeMode.system; // 这里默认跟随系统
@override
Widget build(BuildContext context) {
return MaterialApp(
theme: ThemeData(colorScheme: const ColorScheme.light()), // 亮色主题
darkTheme: ThemeData(colorScheme: const ColorScheme.dark()), // 暗色主题
themeMode: themeMode, // 主题模式
home: // ...more...
);
}
}
当我们变更themeMode变量的值,并调用setState()更新UI,即可使得App自动根据themeMode的值去切换相应主题。
二、编写切换主题的组件
1. 编写切换主题模式的按钮
有的App在个人或设置页面的右上角会有一个快速切换亮暗色的按钮,比如哔哩哔哩App,这里就模仿写一个切换按钮。
长这样:
Builder(builder: (BuildContext context) {
// 当前是否处于暗色模式
bool isDarkMode = Theme.of(context).colorScheme.brightness == Brightness.dark;
return IconButton(
icon: Icon(isDarkMode ? Icons.light_mode : Icons.mode_night),
tooltip: "切换为${isDarkMode ? '亮色' : '暗黑'}模式",
onPressed: () async {
// 如果APP主题正处于跟随系统设置的情况下
if (themeMode == ThemeMode.system) {
// ...拓展:在这里可以进行弹窗提示用户切换主题模式会关闭主题跟随系统
}
// 切换主题模式
setState(() {
themeMode = (isDarkMode ? ThemeMode.light : ThemeMode.dark);
});
},
);
}),
需要注意:
- 这里不能仅通过themeMode变量判断处于亮还是暗,因为当themeMode值为system时无法判断;而Brightness即使主题模式在跟随系统的情况下也能获取到App处于亮色还是暗色。
- 监听brightness需要在组件外部套上Builder(),或者StatefulBuilder()等含有context的Widget,否则会导致调用setState()后UI依然没有更新!
2. 编写暗黑模式的开关
有的App使用开关组件来控制是否开启夜间(暗黑)模式,长这样:
这个也挺好写的
Builder(builder: (context) {
return Switch(
value: Theme.of(context).colorScheme.brightness == Brightness.dark,
onChanged: (bool value) {
setState(() {
themeMode = (themeMode == ThemeMode.light ? ThemeMode.dark : ThemeMode.light);
});
},
);
}),
需要注意的点同1
进阶——使用provider
基本的切换功能我们已经晓得了,接下来就引用provider
第三方库实现跨组件状态共享。
首先在pubspec.yaml引入:
dependencies: # ... provider: ^5.0.0
使用Provider作状态管理,编写ThemeMode的Provider如下:
class ThemeProvider with ChangeNotifier { ThemeMode themeMode = ThemeMode.system; setThemeMode(ThemeMode mode) { themeMode = mode; notifyListeners(); }}
在MaterialApp外部套上ChangeNotifierProvider,如下:
class MyAppState extends State<MyApp> { final ThemeProvider _themeProvider = ThemeProvider(); // 在这里初始化ThemeProvider @override Widget build(BuildContext context) { return ChangeNotifierProvider( create: (context) => _themeProvider, child: Consumer(builder: (context, ThemeProvider provider, child) { return MaterialApp( theme: ThemeData(colorScheme: const ColorScheme.light()), // 亮色主题 darkTheme: ThemeData(colorScheme: const ColorScheme.dark()), // 暗色主题 themeMode: themeMode, // 主题模式 home: // ...more... ); } ); }}
组件中的setState({ xxx })方法改为
_themeProvider.setThemeMode(xxx);
例如对切换按钮进行修改(第11行):Builder(builder: (BuildContext context) { bool isDarkMode = Theme.of(context).colorScheme.brightness == Brightness.dark; return IconButton( icon: Icon(isDarkMode ? Icons.light_mode : Icons.mode_night), tooltip: "切换为${isDarkMode ? '亮色' : '暗黑'}模式", onPressed: () async { if (themeMode == ThemeMode.system) { // ...拓展:在这里可以进行弹窗提示用户切换主题模式会关闭主题跟随系统 } // 切换主题模式 _themeProvider.setThemeMode(isDarkMode ? ThemeMode.light : ThemeMode.dark); }, ); }),
进阶——持久化存储
切换主题有了,但想要重启App后依然保持上次的主题模式,就需要引入shared_preferences
作持久化存储。
首先在pubspec.yaml引入:
dependencies: # ... shared_preferences: ^2.0.5
封装一个ThemeStore用于读取、存储主题模式
由于ThemeMode是枚举类型,shared_preferences不能持久化存储枚举类型,所以我们还需要用数字或字符类型常量去代替枚举类实现存储。import 'package:flutter/material.dart'; import 'package:shared_preferences/shared_preferences.dart'; class ThemeStore { static late SharedPreferences sharedPreferences; // 持久化存储实例 static const _mode = "theme_mode"; // 用于持久化存储的key /// 将 [ThemeMode] 映射成 [int] 类型 static const Map<int, ThemeMode> modeMap = { 0: ThemeMode.system, 1: ThemeMode.light, 2: ThemeMode.dark, }; /// 初始化 static Future init() async { sharedPreferences = await SharedPreferences.getInstance(); } /// 设置主题模式 static Future setThemeMode(ThemeMode themeMode) async { return await sharedPreferences.setInt( _mode, modeMap.entries.firstWhere(((element) => element.value == themeMode)).key); } /// 获取主题模式 static ThemeMode getThemeMode() { int? result = sharedPreferences.getInt(_mode); return modeMap[modeMap.keys.contains(result) ? result : 0]!; } }
这里定义了一个 sharedPreferences 的静态(static)变量,在使用之前需要调用
init()
异步方法去初始化这个 sharedPreferences 变量。由于静态的特性,只需要调用一次初始化,整个App运行过程中就不需要再初始化了,这个方法常用于设定跨类的全局变量。在App初始化之前获取并设定主题模式
class MyAppState extends State<MyApp> { // ...more... @override void initState() { // 初始化ThemeStore,之后赋值到themeProvider中 ThemeStore.init().then((value) => _themeProvider.setThemeMode(ThemeStore.getThemeMode())); super.initState(); } }
这里重载父类State的initState()方法,该方法可以在该页面初始化之前执行。
通常开发比较习惯在main.dart中void main()
程序入口处进行初始化多种必要的数据,亦或者单独起一个开屏加载页面去初始化。实现在跟随系统模式下点击切换主题按钮进行弹窗提示
/// 在点击事件里进行 // 如果APP主题正处于跟随系统设置的情况下 if (themeProvider.themeMode == ThemeMode.system) { bool isCancel = true; // 标记用户是否取消了变更主题的请求 await showDialog( context: context, builder: (context) { return AlertDialog( title: const Text("提示"), content: const Text("切换模式将会关闭自动跟随系统,可以在主题设置里再次开启"), actions: <Widget>[ TextButton(child: const Text("取消"), onPressed: () => Navigator.pop(context)), TextButton( child: const Text("切换"), onPressed: () { isCancel = false; // 标记为不取消 Navigator.pop(context); }, ), ], ); }, ); if (isCancel) return; // 取消则不执行后续代码直接结束事件 // 后续代码变更主题
注:本文基于Flutter SDK 2.10.3、provider: ^5.0.0、shared_preferences: ^2.0.5 编写
要点总结
- 通过
Theme.of(context).colorScheme.brightness
可以获取App当前处于哪种亮度色调。 - 当 ThemeMode 值为 system 时无法判断 App 目前处于亮色还是暗色,应使用 Brightness 的方式(即上一条)。
- 组件若需要监听 Brightness 的值进行变化,需要在组件外部套上 Builder() 或其他含有 context 的 Widget,否则会导致UI没有更新。