美文网首页
Flutter主题实践 2022-12-13 周二

Flutter主题实践 2022-12-13 周二

作者: 勇往直前888 | 来源:发表于2022-12-13 12:25 被阅读0次

简介

自从引入了深色模式(暗黑模式),主题切换就一直是一个话题。
虽然,关于主题的知识点并不多,但是真的要落到实处,还是有很多细节要注意。
这里记一下流水账,万一有需要的时候,可以翻翻。

本地缓存

主题的选择跟后台无关,是纯客户端的事。
另外,一般主题切换都用一个单选列表,所以一般可以用通用的单选数据结构。比如字段如下:

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;
    }
  }
}

测试界面

  • 浅色模式
企业微信截图_e10e85c6-e859-4291-b89b-00b4c095290c.png
  • 深色模式
企业微信截图_684896ba-149a-40ac-adb1-38bf4155e9ad.png

界面代码

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为起点的服务初始化,自动执行流程,比如初始化缓存,获取缓存数据,自动拉取用户数据等等。这些都是不可见的准备流程,异步的方式执行,不影响主线的页面展示。

相关文章

网友评论

      本文标题:Flutter主题实践 2022-12-13 周二

      本文链接:https://www.haomeiwen.com/subject/apdcqdtx.html