在这里插入图片描述

深色模式已经成为现代应用的标配。用户在晚上看手机,刺眼的白色背景很不舒服,深色模式就能解决这个问题。今天咱们就来聊聊,怎么给应用加上主题切换功能。

主题切换的三种模式

Flutter的主题系统支持三种模式,每种都有自己的使用场景。

浅色模式就是传统的白色背景、黑色文字。适合白天使用,光线充足的环境下看得清楚。大部分应用默认都是浅色模式。

深色模式是黑色背景、白色文字。适合晚上使用,减少屏幕亮度,保护眼睛。很多用户晚上刷手机都喜欢开深色模式。

跟随系统是最智能的选择。白天系统是浅色,应用就用浅色;晚上系统切换到深色,应用也自动切换。用户不用手动调整,很方便。

咱们的应用三种模式都支持,让用户自己选择。

ThemeProvider的设计

主题状态需要全局共享,所以用Provider管理。先看看基本结构:

class ThemeProvider extends ChangeNotifier {
  ThemeMode _themeMode = ThemeMode.system;
  
  ThemeMode get themeMode => _themeMode;

_themeMode是私有变量,存储当前的主题模式。默认值是ThemeMode.system,跟随系统,这是最合理的默认值。

themeMode是公开的getter,外部只能读取,不能直接修改。所有修改都要通过setThemeMode方法,这样才能保证数据持久化和UI更新。

构造函数里加载保存的主题:

ThemeProvider() {
  _loadThemeMode();
}

应用启动时Provider就会创建,立即从本地加载用户上次选择的主题。这样用户下次打开应用,主题还是上次的设置。

从本地加载主题设置

加载主题的逻辑稍微复杂点,因为要处理枚举和字符串的转换:

Future<void> _loadThemeMode() async {
  final prefs = await SharedPreferences.getInstance();
  final themeModeString = prefs.getString('themeMode') ?? 'system';

首先从SharedPreferences读取保存的主题字符串。如果没有保存过,默认用’system’。注意这里存的是字符串,不是枚举,因为SharedPreferences不支持枚举类型。

  _themeMode = ThemeMode.values.firstWhere(
    (e) => e.toString() == 'ThemeMode.$themeModeString',
    orElse: () => ThemeMode.system,
  );

这行代码把字符串转回枚举。ThemeMode.values是所有枚举值的列表,firstWhere找到匹配的那个。为什么要比较**‘ThemeMode.$themeModeString’?因为枚举的toString()返回的是’ThemeMode.light’**这种格式。

orElse是找不到时的默认值,返回ThemeMode.system。虽然正常情况不会找不到,但加上这个参数更安全。

  notifyListeners();
}

加载完要通知监听者,MaterialApp会收到通知,应用主题就更新了。

保存主题设置

用户切换主题时,要保存到本地:

Future<void> setThemeMode(ThemeMode mode) async {
  _themeMode = mode;

先更新内存中的值,UI会立即响应。

  final prefs = await SharedPreferences.getInstance();
  await prefs.setString('themeMode', mode.toString().split('.').last);

然后保存到本地。mode.toString()返回’ThemeMode.light’,用split(‘.’)分割后取最后一部分,得到’light’。这样存储的字符串更简洁。

  notifyListeners();
}

最后通知监听者。虽然前面已经更新了_themeMode,但还是要调用notifyListeners,确保所有监听者都收到通知。

注意这里用了await,确保数据真的写入了。如果不await,应用崩溃时数据可能还没保存,用户的设置就丢了。

浅色主题的定义

Flutter的主题系统很强大,可以定制各种细节。先看浅色主题:

ThemeData get lightTheme => ThemeData(
  useMaterial3: true,
  brightness: Brightness.light,
  colorScheme: ColorScheme.fromSeed(
    seedColor: Colors.blue,
    brightness: Brightness.light,
  ),

useMaterial3: true启用Material Design 3,这是最新的设计规范,组件更圆润、动画更流畅。

brightness: Brightness.light明确指定这是浅色主题。虽然colorScheme里也指定了,但这里再指定一次更保险。

ColorScheme.fromSeed是Material 3的新特性,从一个种子颜色生成整套配色方案。seedColor: Colors.blue表示主色调是蓝色,系统会自动生成primary、secondary、tertiary等一系列颜色,而且保证它们搭配和谐。

这比手动指定每个颜色方便多了,而且生成的配色方案符合Material Design规范,不会出现颜色不搭的情况。

  appBarTheme: const AppBarTheme(
    centerTitle: true,
    elevation: 0,
  ),

AppBarTheme定制AppBar的样式。centerTitle: true让标题居中,这是iOS的习惯,Android默认是左对齐。咱们选择居中,看起来更平衡。

elevation: 0去掉AppBar的阴影。Material 3推荐扁平化设计,不要太多阴影。而且没有阴影,AppBar和body的分界更清晰。

  cardTheme: CardTheme(
    elevation: 2,
    shape: RoundedRectangleBorder(
      borderRadius: BorderRadius.circular(12),
    ),
  ),
);

CardTheme定制Card的样式。elevation: 2给Card加一点点阴影,让它有层次感,但不会太明显。

shape定义Card的形状。RoundedRectangleBorder是圆角矩形,borderRadius: 12表示圆角半径12。这个圆角大小刚刚好,不会太圆也不会太方。

深色主题的定义

深色主题和浅色主题结构一样,只是brightness不同:

ThemeData get darkTheme => ThemeData(
  useMaterial3: true,
  brightness: Brightness.dark,
  colorScheme: ColorScheme.fromSeed(
    seedColor: Colors.blue,
    brightness: Brightness.dark,
  ),
  appBarTheme: const AppBarTheme(
    centerTitle: true,
    elevation: 0,
  ),
  cardTheme: CardTheme(
    elevation: 2,
    shape: RoundedRectangleBorder(
      borderRadius: BorderRadius.circular(12),
    ),
  ),
);

注意seedColor还是Colors.blue,和浅色主题一样。这样两个主题的色调是一致的,只是明暗不同。用户切换主题时,不会觉得换了个应用。

ColorScheme.fromSeed会根据brightness自动调整颜色。浅色主题的primary是亮蓝色,深色主题的primary会自动变成暗蓝色,而且保证在黑色背景上看得清楚。

AppBar和Card的配置和浅色主题完全一样,保持一致性。

在MaterialApp中使用主题

有了ThemeProvider,怎么让整个应用使用它?在main.dart里配置:

class MyApp extends StatelessWidget {
  
  Widget build(BuildContext context) {
    return ChangeNotifierProvider(
      create: (_) => ThemeProvider(),
      child: Consumer<ThemeProvider>(
        builder: (context, themeProvider, child) {
          return MaterialApp(
            theme: themeProvider.lightTheme,
            darkTheme: themeProvider.darkTheme,
            themeMode: themeProvider.themeMode,
            home: const MainScreen(),
          );
        },
      ),
    );
  }
}

ChangeNotifierProvider创建ThemeProvider实例,整个应用都能访问它。

Consumer监听ThemeProvider的变化。用户切换主题时,Consumer会重建,MaterialApp就会用新的主题。

MaterialApp的三个主题参数:

  • theme是浅色主题,用户选择浅色或系统是浅色时使用
  • darkTheme是深色主题,用户选择深色或系统是深色时使用
  • themeMode决定用哪个主题

这三个参数配合,就能实现完整的主题切换功能。

主题切换的UI实现

在个人中心或设置页,加个主题切换开关:

Consumer<ThemeProvider>(
  builder: (context, themeProvider, child) {
    return SwitchListTile(
      secondary: Icon(
        themeProvider.themeMode == ThemeMode.dark
            ? Icons.dark_mode
            : Icons.light_mode,
      ),
      title: const Text('深色模式'),
      value: themeProvider.themeMode == ThemeMode.dark,
      onChanged: (value) {
        themeProvider.setThemeMode(
          value ? ThemeMode.dark : ThemeMode.light,
        );
      },
    );
  },
)

这是个简单的开关,只能在浅色和深色之间切换。如果要支持"跟随系统",需要用对话框:

ListTile(
  leading: const Icon(Icons.palette_outlined),
  title: const Text('主题模式'),
  subtitle: Text(_getThemeModeText(themeProvider.themeMode)),
  trailing: const Icon(Icons.chevron_right),
  onTap: () {
    showDialog(
      context: context,
      builder: (context) => AlertDialog(
        title: const Text('选择主题'),
        content: Column(
          mainAxisSize: MainAxisSize.min,
          children: [
            RadioListTile<ThemeMode>(
              title: const Text('浅色'),
              value: ThemeMode.light,
              groupValue: themeProvider.themeMode,
              onChanged: (value) {
                if (value != null) {
                  themeProvider.setThemeMode(value);
                  Navigator.pop(context);
                }
              },
            ),
            RadioListTile<ThemeMode>(
              title: const Text('深色'),
              value: ThemeMode.dark,
              groupValue: themeProvider.themeMode,
              onChanged: (value) {
                if (value != null) {
                  themeProvider.setThemeMode(value);
                  Navigator.pop(context);
                }
              },
            ),
            RadioListTile<ThemeMode>(
              title: const Text('跟随系统'),
              value: ThemeMode.system,
              groupValue: themeProvider.themeMode,
              onChanged: (value) {
                if (value != null) {
                  themeProvider.setThemeMode(value);
                  Navigator.pop(context);
                }
              },
            ),
          ],
        ),
      ),
    );
  },
)

对话框里用RadioListTile实现单选,三个选项对应三种主题模式。用户选择后自动关闭对话框,体验很流畅。

主题切换的动画效果

Flutter的主题切换默认有动画,但可以自定义。在MaterialApp里加个参数:

MaterialApp(
  theme: themeProvider.lightTheme,
  darkTheme: themeProvider.darkTheme,
  themeMode: themeProvider.themeMode,
  themeAnimationDuration: const Duration(milliseconds: 300),
  themeAnimationCurve: Curves.easeInOut,
  home: const MainScreen(),
)

themeAnimationDuration是动画时长,300毫秒刚刚好,不会太快也不会太慢。

themeAnimationCurve是动画曲线,Curves.easeInOut是先加速后减速,看起来很自然。

这样切换主题时,颜色会平滑过渡,不会突然变化。

适配不同组件的主题

有些组件需要根据主题调整样式。比如文字颜色:

Text(
  '标题',
  style: TextStyle(
    color: Theme.of(context).colorScheme.onSurface,
  ),
)

**Theme.of(context)**获取当前主题,colorScheme.onSurface是表面上的文字颜色。浅色主题是黑色,深色主题是白色,自动适配。

不要硬编码颜色:

// 错误:硬编码黑色
Text('标题', style: TextStyle(color: Colors.black))

// 正确:使用主题颜色
Text('标题', style: TextStyle(color: Theme.of(context).colorScheme.onSurface))

硬编码的颜色在深色主题下可能看不清。

自定义颜色的适配

如果要用自定义颜色,也要适配主题:

Container(
  color: Theme.of(context).brightness == Brightness.dark
      ? Colors.grey[800]
      : Colors.grey[200],
)

根据brightness判断当前是深色还是浅色,然后用不同的颜色。

或者用ColorScheme的颜色:

Container(
  color: Theme.of(context).colorScheme.surface,
)

surface是表面颜色,浅色主题是白色,深色主题是深灰色,自动适配。

图片的主题适配

有些图片在深色主题下看不清,需要换一张:

Image.asset(
  Theme.of(context).brightness == Brightness.dark
      ? 'assets/logo_dark.png'
      : 'assets/logo_light.png',
)

或者给图片加个颜色滤镜:

Image.asset(
  'assets/logo.png',
  color: Theme.of(context).brightness == Brightness.dark
      ? Colors.white
      : Colors.black,
)

状态栏和导航栏的适配

Android的状态栏和导航栏也要适配主题:

import 'package:flutter/services.dart';

void _updateSystemUI(BuildContext context) {
  final brightness = Theme.of(context).brightness;
  SystemChrome.setSystemUIOverlayStyle(
    SystemUiOverlayStyle(
      statusBarColor: Colors.transparent,
      statusBarIconBrightness: brightness == Brightness.dark
          ? Brightness.light
          : Brightness.dark,
      systemNavigationBarColor: Theme.of(context).colorScheme.surface,
      systemNavigationBarIconBrightness: brightness == Brightness.dark
          ? Brightness.light
          : Brightness.dark,
    ),
  );
}

statusBarColor设置为透明,让状态栏和AppBar融为一体。

statusBarIconBrightness控制状态栏图标的颜色。深色主题用浅色图标,浅色主题用深色图标,这样才看得清。

systemNavigationBarColor是导航栏的背景色,用主题的surface颜色。

systemNavigationBarIconBrightness是导航栏图标的颜色,和状态栏一样。

在MaterialApp的builder里调用:

MaterialApp(
  builder: (context, child) {
    _updateSystemUI(context);
    return child!;
  },
)

主题切换的性能优化

主题切换会重建整个应用,如果页面很复杂,可能会卡顿。可以用这些方法优化:

使用const构造函数

const Text('标题')  // 不会重建
Text('标题')        // 会重建

const的Widget不会重建,性能更好。

缓存复杂的Widget

class MyWidget extends StatelessWidget {
  static final _cachedChild = ExpensiveWidget();
  
  
  Widget build(BuildContext context) {
    return Container(
      child: _cachedChild,
    );
  }
}

如果某个Widget很复杂,可以缓存起来,避免重复创建。

使用RepaintBoundary

RepaintBoundary(
  child: ComplexWidget(),
)

RepaintBoundary会把子Widget单独渲染,主题切换时不会重绘它。

一些实用的扩展

主题系统还可以加很多实用功能。

自定义主题颜色

让用户选择主色调:

class ThemeProvider extends ChangeNotifier {
  Color _seedColor = Colors.blue;
  
  void setSeedColor(Color color) {
    _seedColor = color;
    notifyListeners();
  }
  
  ThemeData get lightTheme => ThemeData(
    colorScheme: ColorScheme.fromSeed(
      seedColor: _seedColor,
      brightness: Brightness.light,
    ),
  );
}

定时切换主题

晚上自动切换到深色模式:

void _scheduleThemeSwitch() {
  final now = DateTime.now();
  final nightStart = DateTime(now.year, now.month, now.day, 22, 0);
  final dayStart = DateTime(now.year, now.month, now.day, 7, 0);
  
  if (now.hour >= 22 || now.hour < 7) {
    setThemeMode(ThemeMode.dark);
  } else {
    setThemeMode(ThemeMode.light);
  }
}

主题预览

切换主题前先预览效果:

void _showThemePreview(ThemeMode mode) {
  showDialog(
    context: context,
    builder: (context) => Theme(
      data: mode == ThemeMode.dark ? darkTheme : lightTheme,
      child: AlertDialog(
        title: const Text('主题预览'),
        content: const Text('这是预览效果'),
        actions: [
          TextButton(
            onPressed: () => Navigator.pop(context),
            child: const Text('取消'),
          ),
          TextButton(
            onPressed: () {
              setThemeMode(mode);
              Navigator.pop(context);
            },
            child: const Text('应用'),
          ),
        ],
      ),
    ),
  );
}

常见问题

主题切换后状态丢失:可能是Widget被重建了。用AutomaticKeepAliveClientMixin保持状态:

class MyPage extends StatefulWidget {
  
  _MyPageState createState() => _MyPageState();
}

class _MyPageState extends State<MyPage> with AutomaticKeepAliveClientMixin {
  
  bool get wantKeepAlive => true;
  
  
  Widget build(BuildContext context) {
    super.build(context);
    return Container();
  }
}

某些颜色不适配主题:检查是否硬编码了颜色。改用Theme.of(context).colorScheme的颜色。

主题切换有闪烁:可能是动画时长太短。增加themeAnimationDuration到500毫秒。

写在最后

主题切换功能看起来简单,但要做好不容易。要考虑数据持久化、颜色适配、动画效果、性能优化。

最重要的是用户体验。切换要流畅,颜色要和谐,所有组件都要适配。这些细节做好了,用户才会喜欢用你的应用。

代码写完了,记得在不同场景下测试。白天切换到深色会不会太暗?晚上切换到浅色会不会太亮?跟随系统能不能正常工作?这些都要测试到。


欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net

在这里你可以找到更多Flutter开发资源,与其他开发者交流经验,共同进步。

Logo

腾讯云面向开发者汇聚海量精品云计算使用和开发经验,营造开放的云计算技术生态圈。

更多推荐