主题、字体与暗黑模式
主题不是“换个颜色”,而是把颜色、字号、圆角这些决定收拢到一处,让业务代码只写结构、不写样式。Flutter 3.x 默认启用 Material 3,ThemeData 的重心也随之变化:不再逐个指定 primaryColor,而是给一个种子色让 ColorScheme.fromSeed 推导出全套配色。本章按配色、主题扩展、亮暗切换、字体、组件覆盖、系统 UI 的顺序给出一条可落地的主题链路。
ThemeData 的三层结构
| 层 | 关键字段 | 说明 |
|---|---|---|
| 配色 | colorScheme、useMaterial3 | Material 3 下颜色几乎都从 ColorScheme 取,旧的 primaryColor 只做兼容 |
| 字体 | textTheme、fontFamily、fontFamilyFallback | 全局字号阶梯与字体回退链 |
| 组件 | elevatedButtonTheme 等 XxxThemeData | 组件级默认样式,避免每个页面重复写 |
原则:能用 colorScheme 角色色表达的就不要写死 Color(0xFF...),否则暗色模式一定漏改。
用种子色生成亮暗两套配色
import 'package:flutter/material.dart';
const _seed = Color(0xFF3F6BFF); // 只维护一个种子色
ThemeData buildLightTheme() => ThemeData(
useMaterial3: true, // 3.16 起已是默认值,显式写出便于阅读
colorScheme: ColorScheme.fromSeed(seedColor: _seed),
);
ThemeData buildDarkTheme() => ThemeData(
useMaterial3: true,
colorScheme: ColorScheme.fromSeed(
seedColor: _seed,
brightness: Brightness.dark, // 暗色必须在这里指定,不要事后改 colorScheme
),
);
取色一律走 Theme.of(context).colorScheme.primary 这类角色色,不要自己记十六进制。
主题扩展:ThemeExtension
ColorScheme 只覆盖语义色,品牌自定义色(如“已收藏”的金黄)适合用 ThemeExtension 挂在主题上,让它跟随主题切换:
class AppColors extends ThemeExtension<AppColors> {
const AppColors({required this.favorite});
final Color favorite;
@override
AppColors copyWith({Color? favorite}) => AppColors(favorite: favorite ?? this.favorite);
// 不实现 lerp,主题切换时颜色会硬跳
@override
AppColors lerp(ThemeExtension<AppColors>? other, double t) {
if (other is! AppColors) return this;
return AppColors(favorite: Color.lerp(favorite, other.favorite, t)!);
}
}
final light = buildLightTheme().copyWith(
extensions: const <ThemeExtension<dynamic>>[AppColors(favorite: Color(0xFFFFB300))],
);
// 使用:取不到说明扩展没挂上,! 让错误在开发期立刻暴露
final colors = Theme.of(context).extension<AppColors>()!;
亮暗切换:themeMode 与持久化
themeMode | 行为 | 适用 |
|---|---|---|
ThemeMode.system | 跟随系统设置,推荐默认 | 绝大多数应用 |
ThemeMode.light / ThemeMode.dark | 强制单一主题 | 用户手动切换、阅读类应用 |
Future<void> setMode(ThemeMode mode, SharedPreferences prefs) async {
notifyListeners(); // 先刷 UI
await prefs.setInt('themeMode', mode.index); // 再落盘,避免切换卡顿
}
MaterialApp(
theme: buildLightTheme(),
darkTheme: buildDarkTheme(), // 不提供 darkTheme 时暗色就是亮色,等于没适配
themeMode: mode,
);
字体:google_fonts 与本地字体
| 方案 | 包体积 | 首屏 | 适用 |
|---|---|---|---|
| 系统默认字体 | 0 | 最好 | 只求可读,接受各端字形略有差异 |
| 本地中文字体 | +5~10 MB,子集化后可压到 1 MB 内 | 好 | 强设计要求多端字形统一 |
google_fonts | 近乎 0 | 首次可能闪字 | 拉丁字体,或网络可控环境 |
# pubspec.yaml:声明本地字体资源
flutter:
fonts:
- family: NotoSansSC
fonts:
- asset: assets/fonts/NotoSansSC-Regular.otf
- asset: assets/fonts/NotoSansSC-Bold.otf
weight: 700
Text('标题', style: GoogleFonts.notoSansSc(fontSize: 20)); // 运行时下载并缓存
ThemeData(
fontFamily: 'NotoSansSC',
fontFamilyFallback: const ['PingFang SC', 'Microsoft YaHei', 'sans-serif'],
);
中文项目建议:优先用系统字体,只在品牌页对少量文字指定字体;确需打包就用 fonttools 做子集化,只保留用到的字。
组件级样式覆盖
TextTheme:统一标题/正文阶梯,页面里用theme.textTheme.titleLarge取用,避免到处写fontSize。ElevatedButtonThemeData/InputDecorationTheme等:一次定义全站一致,例如把minimumSize设为const Size.fromHeight(48),顺手解决点击区域过小的问题。
状态栏与系统 UI
SystemChrome.setSystemUIOverlayStyle(const SystemUiOverlayStyle(statusBarColor: Colors.transparent));
// 局部覆盖:深色封面页需要浅色图标,离开页面自动恢复
AnnotatedRegion<SystemUiOverlayStyle>(
value: const SystemUiOverlayStyle(
statusBarIconBrightness: Brightness.light, // Android
statusBarBrightness: Brightness.dark, // iOS:语义与 Android 相反
),
child: child,
);
常见坑
| 坑 | 现象 | 正确做法 |
|---|---|---|
只给 theme 不给 darkTheme | 系统切暗色后界面照旧 | 两套主题都提供,themeMode 才有意义 |
颜色写死 Colors.white | 暗色下白底白字 | 一律走 colorScheme 角色色 |
暗色忘加 brightness: Brightness.dark | 暗色偏亮、对比度差 | 在 ColorScheme.fromSeed 里指定 |
ThemeExtension 不实现 lerp | 切换主题时颜色硬跳 | copyWith 与 lerp 都要实现 |
| iOS 状态栏图标色写反 | 图标与背景同色看不见 | iOS 用 statusBarBrightness,语义与 Android 相反 |
小结:主题的核心是“颜色与字号只有一处定义”,用 ColorScheme.fromSeed 生成亮暗两套配色、用 ThemeExtension 承载品牌色、用 themeMode 决定跟随系统还是手动并持久化;字体优先用系统字体、必须打包时做子集化,组件样式统一收拢到 ThemeData,最后用 SystemChrome 与 AnnotatedRegion 处理状态栏。