“深色模式(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