Flutter适配暗黑模式的实践

由 Kerrinz 发布
  | 5092 次浏览

随着SDK版本的迭代,Flutter官方已经很好地支持了暗黑模式了,想适配暗黑模式并非是一件难事。本文从最简单基础的写法开始,逐步引入provider、shared_preferences完善暗黑/夜间模式的适配,并提供Demo演示和源代码。


前言

在开始之前我们需要了解两个枚举类:BrightnessThemeMode,还有根组件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.lighttheme
ThemeMode.darkdarkTheme
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,这里就模仿写一个切换按钮。
长这样:
brightness_button.png

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使用开关组件来控制是否开启夜间(暗黑)模式,长这样:
brightness_switch.png

这个也挺好写的

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第三方库实现跨组件状态共享。

  1. 首先在pubspec.yaml引入:

    dependencies:
      # ...
      provider: ^5.0.0
  2. 使用Provider作状态管理,编写ThemeMode的Provider如下:

    class ThemeProvider with ChangeNotifier {
      ThemeMode themeMode = ThemeMode.system;
      
      setThemeMode(ThemeMode mode) {
        themeMode = mode;
        notifyListeners();
      }}
  3. 在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...
            );
          }
        );
    }}
  4. 组件中的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作持久化存储。

  1. 首先在pubspec.yaml引入:

    dependencies:
        # ...
      shared_preferences: ^2.0.5
  2. 封装一个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运行过程中就不需要再初始化了,这个方法常用于设定跨类的全局变量。

  3. 在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()程序入口处进行初始化多种必要的数据,亦或者单独起一个开屏加载页面去初始化。

  4. 实现在跟随系统模式下点击切换主题按钮进行弹窗提示

    /// 在点击事件里进行
    // 如果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 编写

要点总结

  1. 通过Theme.of(context).colorScheme.brightness可以获取App当前处于哪种亮度色调。
  2. 当 ThemeMode 值为 system 时无法判断 App 目前处于亮色还是暗色,应使用 Brightness 的方式(即上一条)。
  3. 组件若需要监听 Brightness 的值进行变化,需要在组件外部套上 Builder() 或其他含有 context 的 Widget,否则会导致UI没有更新。

Demo

Demo演示

源代码


版权属于: Kerrinz
本文链接:https://kerrinz.com/archives/187.html
作品采用《知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议》进行许可,转载请务必注明出处!

暂无评论

发表评论