简介
自从引入了深色模式(暗黑模式),主题切换就一直是一个话题。
虽然,关于主题的知识点并不多,但是真的要落到实处,还是有很多细节要注意。
这里记一下流水账,万一有需要的时候,可以翻翻。
本地缓存
主题的选择跟后台无关,是纯客户端的事。
另外,一般主题切换都用一个单选列表,所以一般可以用通用的单选数据结构。比如字段如下:
class SelectModel {
String name;
int id;
String code;
bool select;
bool enable;
SelectModel({
required this.name,
this.id = 0,
this.code = '',
this.select = false,
this.enable = true,
});
SelectModel copyWith({
String? name,
int? id,
String? code,
bool? select,
bool? enable,
}) {
return SelectModel(
name: name ?? this.name,
id: id ?? this.id,
code: code ?? this.code,
select: select ?? this.select,
enable: enable ?? this.enable,
);
}
Map<String, dynamic> toMap() {
return <String, dynamic>{
'name': name,
'id': id,
'code': code,
'select': select,
'enable': enable,
};
}
factory SelectModel.fromMap(Map<String, dynamic> map) {
return SelectModel(
name: map['name'] as String,
id: map['id'] as int,
code: map['code'] as String,
select: map['select'] as bool,
enable: map['enable'] as bool,
);
}
String toJson() => json.encode(toMap());
factory SelectModel.fromJson(String source) =>
SelectModel.fromMap(json.decode(source) as Map<String, dynamic>);
@override
String toString() {
return 'SelectModel(name: $name, id: $id, code: $code, select: $select, enable: $enable)';
}
@override
bool operator ==(covariant SelectModel other) {
if (identical(this, other)) return true;
return other.name == name &&
other.id == id &&
other.code == code &&
other.select == select &&
other.enable == enable;
}
@override
int get hashCode {
return name.hashCode ^
id.hashCode ^
code.hashCode ^
select.hashCode ^
enable.hashCode;
}
}
-
那么就是选项的文字,这个一般少不了,所以要求必选。id,或者code就是为编码用的。id用起来方便,code是字符串,可读性强一点。select和enable两个字段完全是为处理单选准备的。
-
只要定义好字段,其他的方便函数可以通过VSCode的插件Dart Data Class Generator自动生成,很方便。由于要存本地,所以toJson是必须的,把自定义对象转化为字符串进行存储。
主题数据
-
系统提供了ThemeData,可用可不用。不过,一般都会定义两个全局的ThemeData,分别代表浅色和深色模式,这样好理解一点。
-
ThemeData中有一个ThemeExtension可以用来放自定义的数据。
-
除了颜色,有些自定义icon也是要根据主题更换的。这个可以和颜色用相同的处理方法,加入ThemeExtension中就可以了。
-
自定义颜色:
/// 自定义颜色
class CustomColor extends ThemeExtension<CustomColor> {
final bool isDarkMode;
CustomColor(this.isDarkMode);
/// 这两个方法都不实现,简单返回自身
@override
ThemeExtension<CustomColor> copyWith() {
return this;
}
@override
ThemeExtension<CustomColor> lerp(
ThemeExtension<CustomColor>? other, double t) {
return this;
}
/// 颜色定义,分为深色,浅色两套
Color get backgrouderIcon => isDarkMode
? ColorUtil.hexStringColor('#666666')
: ColorUtil.hexStringColor('#DDDDDD');
}
- 自定义图标
class CustomIcon extends ThemeExtension<CustomIcon> {
final bool isDarkMode;
CustomIcon(this.isDarkMode);
/// 这两个方法都不实现,简单返回自身
@override
ThemeExtension<CustomIcon> copyWith() {
return this;
}
@override
ThemeExtension<CustomIcon> lerp(ThemeExtension<CustomIcon>? other, double t) {
return this;
}
/// 图标名称,分为深色,浅色两套
String get order40 =>
isDarkMode ? R.assetsImagesOrder40Dark : R.assetsImagesOrder40;
String get warehouse40 =>
isDarkMode ? R.assetsImagesWarehouse40Dark : R.assetsImagesWarehouse40;
String get rehearsal40 =>
isDarkMode ? R.assetsImagesRehearsal40Dark : R.assetsImagesRehearsal40;
String get parcel40 =>
isDarkMode ? R.assetsImagesParcel40Dark : R.assetsImagesParcel40;
}
- 浅色ThemeData
/// 浅色主题
ThemeData lightTheme = ThemeData.light().copyWith(
extensions: <ThemeExtension<dynamic>>[
CustomColor(false),
CustomIcon(false),
],
);
- 深色ThemeData
/// 深色主题
ThemeData darkTheme = ThemeData.dark().copyWith(
extensions: <ThemeExtension<dynamic>>[
CustomColor(true),
CustomIcon(true),
],
);
主题数据封装类
-
每次使用的时候,都需要Theme.of(context)也是比较繁琐的
-
特别是ThemeExtension中的自定义数据,要取出来更加麻烦
-
借助GetX,可以很方便地处理又麻烦又讨厌的context
-
所以考虑集中在一个文件中,采用静态get的方式,方便使用。
静态属性存在不更新的问题,加个get关键字,就成了函数,就解决了不更新的问题。同时,用起来和静态属性一模一样,习惯不用改。
class ThemeConfig {
/// 颜色
static CustomColor get _customColor =>
Theme.of(Get.context!).extension<CustomColor>()!;
static Color get backgrouderIcon => _customColor.backgrouderIcon;
static Color get backgrouderButton => Theme.of(Get.context!).primaryColor;
/// 图标
static CustomIcon get _customIcon =>
Theme.of(Get.context!).extension<CustomIcon>()!;
static String get order40 => _customIcon.order40;
static String get warehouse40 => _customIcon.warehouse40;
static String get rehearsal40 => _customIcon.rehearsal40;
static String get parcel40 => _customIcon.parcel40;
}
主题工具类
-
主题切换,包括更换ThemeMode和ThemeData两部分。另外,自定义的颜色和icon路径不能自动更新,所以还要加上强制更新的代码。
-
本地存一个SelectModel结构,name字段只用来显示,考虑到潜在的多语言需求,不能用作判断依据。
-
判断依据这里选择code字段,可读性强一点。Dart的字符串比较很方便,枚举又太弱,也不需要和后台交换,没必要用id。(如果是OC的话,首先要避开id这个关键字,字符串比较也麻烦,用整数作为判断依据更合适)
-
跟随系统是需要特殊考虑的情况,这种场景,GetX的isDarkMode就失效了。这个时候要改为判断系统模式(浅色还是深色)。所以,这里扩展了isDarkMode的使用范围。
class ThemeUtil {
/// 切换主题;主题模式和主题数据一并切换
static changeTheme() {
ThemeMode mode = getCachedThemeModel();
ThemeData themeData = getThemeData();
Get.changeThemeMode(mode);
Get.changeTheme(themeData);
updateTheme();
}
/// 更新app,使主题切换生效
/// ThemeData中的预定义字段不需要这个,会自动更新;自定义字段需要这个,不然不会更新;
static updateTheme() {
Future.delayed(const Duration(milliseconds: 300), () {
Get.forceAppUpdate();
});
}
/// 获取本地存储; 返回对应的主题模式
static getCachedThemeModel() {
SelectModel? theme = CacheService.of.getTheme();
ThemeMode themeMode = ThemeMode.light;
if (theme?.code == 'light') {
themeMode = ThemeMode.light;
} else if (theme?.code == 'dark') {
themeMode = ThemeMode.dark;
} else if (theme?.code == 'system') {
themeMode = ThemeMode.system;
}
return themeMode;
}
/// 获取本地存储; 返回对应的主题数据
static getThemeData() {
SelectModel? theme = CacheService.of.getTheme();
ThemeData themeData = lightTheme;
if (theme?.code == 'light') {
themeData = lightTheme;
} else if (theme?.code == 'dark') {
themeData = darkTheme;
} else if (theme?.code == 'system') {
if (isDark()) {
themeData = darkTheme;
} else {
themeData = lightTheme;
}
}
return themeData;
}
/// 这里要切换主题;所以跟随系统的时候(ThemeMode.system),要得到系统设置的主题模式
static isDark() {
SelectModel? theme = CacheService.of.getTheme();
if (theme?.code == 'system') {
return MediaQuery.of(Get.context!).platformBrightness == Brightness.dark;
} else {
return Get.isDarkMode;
}
}
}
测试界面
- 浅色模式

- 深色模式

界面代码
class ThemeTestPage extends GetView<ThemeTestController> {
const ThemeTestPage({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return GetBuilder<ThemeTestController>(
builder: (context) {
return Scaffold(
appBar: AppBar(
title: const Text('主题测试'),
centerTitle: true,
),
body: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
color: ThemeConfig.backgrouderButton,
margin: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 10,
),
padding: const EdgeInsets.symmetric(
horizontal: 20,
vertical: 10,
),
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () {
controller.setDartTheme();
},
child: const Text(
'设置深色模式',
style: TextStyle(fontSize: 20),
textAlign: TextAlign.center,
),
),
),
Container(
color: ThemeConfig.backgrouderButton,
margin: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 10,
),
padding: const EdgeInsets.symmetric(
horizontal: 20,
vertical: 10,
),
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () {
controller.setLightTheme();
},
child: const Text(
'设置浅色模式',
style: TextStyle(fontSize: 20),
textAlign: TextAlign.center,
),
),
),
Container(
color: ThemeConfig.backgrouderButton,
margin: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 10,
),
padding: const EdgeInsets.symmetric(
horizontal: 20,
vertical: 10,
),
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () {
controller.setSystemTheme();
},
child: const Text(
'跟随系统',
style: TextStyle(fontSize: 20),
textAlign: TextAlign.center,
),
),
),
Container(
color: ThemeConfig.backgrouderButton,
margin: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 10,
),
padding: const EdgeInsets.symmetric(
horizontal: 20,
vertical: 10,
),
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () {
controller.checkTheme();
},
child: const Text(
'检查模式',
style: TextStyle(fontSize: 20),
textAlign: TextAlign.center,
),
),
),
Container(
margin: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 60,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
Container(
width: 80,
height: 80,
color: ThemeConfig.backgrouderIcon,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Image.asset(
ThemeConfig.order40,
),
const Text('我的订单'),
],
),
),
Container(
width: 80,
height: 80,
color: ThemeConfig.backgrouderIcon,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Image.asset(
ThemeConfig.warehouse40,
),
const Text('我的仓库'),
],
),
),
Container(
width: 80,
height: 80,
color: ThemeConfig.backgrouderIcon,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Image.asset(
ThemeConfig.rehearsal40,
),
const Text('我的预演'),
],
),
),
Container(
width: 80,
height: 80,
color: ThemeConfig.backgrouderIcon,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Image.asset(
ThemeConfig.parcel40,
),
const Text('我的包裹'),
],
),
),
],
),
),
],
),
);
},
);
}
}
-
这里的按钮用了Container加GestureDetector模拟;好处是方便灵活,缺点是没有按压等过渡状态。
-
这里很多没有施加任何影响,比如导航栏,文本颜色等等,都是系统默认的颜色;
-
这里也有用了系统默认ThemeData字段的,比如按钮背景色(Container背景色),就用了primaryColor字段;封装为ThemeConfig.backgrouderButton
-
这里也有用了自定义的颜色,比如图标的背景色,封装为ThemeConfig.backgrouderIcon
-
这里也有用了自定义的图标,封装为ThemeConfig.order40, ThemeConfig.warehouse40等等
按钮响应代码
class ThemeTestController extends GetxController {
/// 浅色模式
void setLightTheme() {
SelectModel light = SelectModel(
name: '浅色模式',
code: 'light',
);
CacheService.of.setTheme(light);
ThemeUtil.changeTheme();
}
/// 深色模式
void setDartTheme() {
SelectModel dark = SelectModel(
name: '深色模式',
code: 'dark',
);
CacheService.of.setTheme(dark);
ThemeUtil.changeTheme();
}
/// 跟随系统
void setSystemTheme() {
SelectModel system = SelectModel(
name: '跟随系统',
code: 'system',
);
CacheService.of.setTheme(system);
ThemeUtil.changeTheme();
}
/// 检查主题; 这里只检查应用当前的模式
void checkTheme() {
if (Get.isDarkMode) {
EasyLoading.showToast('深色模式');
} else {
EasyLoading.showToast('浅色模式');
}
}
}
-
这里只用了name和code两个字段。name字段用来显示,code字段用了判断。
-
这里的checkTheme是为了检查应用本身的模式,所以直接用Get.isDarkMode就足够了。
监听系统变化
-
改为跟随系统的时候,程序从后台回到前台,就该去查一下系统的模式是否有变化。
-
监听系统变化的系统类是WidgetsBindingObserver;这是一个抽象类;继承它就可以了。
-
网上文章绝大多数都是用一个statuful widget,和一个state整合在一起。个人认为这种方式并不好。系统监听跟某个页面联系起来,这个明显不合理。(虽然可以说主页一直在,可以当做监控页面,但是也不是很好)
-
用一个单例来专门做这个事是最好的。GetX中的service符合这个概念。
-
Dart中的with关键字可以解决多继承的问题,所以此方案可行。
class AppLifecycleService extends GetxService with WidgetsBindingObserver {
/// 需要先Get.put,不然会报错;ApplicationBindings统一初始化
static AppLifecycleService get of => Get.find();
/// 具体的初始化方法
Future<AppLifecycleService> init() async {
log('CacheService initial finished.');
return this;
}
@override
void onInit() {
super.onInit();
WidgetsBinding.instance.addObserver(this);
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
super.didChangeAppLifecycleState(state);
switch (state) {
case AppLifecycleState.resumed:
ThemeUtil.changeTheme();
break;
default:
}
}
@override
void onClose() {
WidgetsBinding.instance.removeObserver(this);
super.onClose();
}
}
启动初始化
-
由于主题模式保存在本地缓存中,所以如果一开始就要确定模式的话,那么就要求本地缓存读写要准备好。但是Flutter常用的缓存插件是异步的。
-
方案1是main之后,初始化系统服务,包括本地缓存等等,然后再展示主页面。这样的话,启动速度慢了一点,但是一切准备就绪,逻辑上简单很多。
-
方案2是分开,默认就给个浅色模式,然后异步初始化服务,等服务好了之后,自动执行一次模式判断。这样的好处是不影响主页启动,但是会有一个可见的闪屏。(如果本地保存的是深色模式)
-
这里取的是方案2,加快主页的展示。以后如果有广告页,闪屏页,那刚好可以用来等服务启动。
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({Key? key}) : super(key: key);
// This widget is the root of your application.
@override
Widget build(BuildContext context) {
return GetMaterialApp(
debugShowCheckedModeBanner: false,
initialBinding: ApplicationBindings(),
initialRoute: AppPages.INITIAL,
getPages: AppPages.routes,
builder: EasyLoading.init(),
translations: MultiLanguage(),
locale: Get.deviceLocale,
fallbackLocale: const Locale('zh'),
theme: lightTheme,
darkTheme: darkTheme,
themeMode: ThemeMode.light,
);
}
}
class ApplicationBindings extends Bindings {
@override
void dependencies() async {
/// 初始化服务
await initServices();
/// 自动运行
await autoRunItems();
}
/// 服务集中初始化
Future<void> initServices() async {
log('starting services ...');
await Get.putAsync(() => StorageService().init());
await Get.putAsync(() => CacheService().init());
await Get.putAsync(() => AppLifecycleService().init());
log('All services started...');
}
/// 自动运行
Future<void> autoRunItems() async {
log('starting autoRunItems ...');
ThemeUtil.changeTheme();
log('finish autoRunItems ...');
}
}
- 这个可以理解为两条线启动流程。一条线是GetMaterialApp,这个就是传统的页面展示流程,保持默认就好,尽量少做事,尽早展示页面。
另外一条线就是GetMaterialApp中的参数initialBinding为起点的服务初始化,自动执行流程,比如初始化缓存,获取缓存数据,自动拉取用户数据等等。这些都是不可见的准备流程,异步的方式执行,不影响主线的页面展示。
网友评论