Flutter 深色模式分析与实践

5,898 阅读9分钟

深色模式(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 是由应用程序根 MaterialAppTheme

/// 全局主题在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)
    )
    

参考

更多文章阅读,请搜索微信公众号: OldBirds