Flutter: 主题与样式系统
主题是应用的穿搭——统一的风格让用户一眼认出你的品牌。
📋 前置知识:需要先掌握以下内容
- 第15课:动画系统
1. 你将学到
- ThemeData 完整配置:colorScheme、textTheme、appBarTheme、cardTheme
- 动态主题切换:Light / Dark / 自定义品牌主题(Riverpod 管理)
- ThemeExtension
<T>自定义扩展主题令牌 - 组件级主题覆盖:Theme(data: ..., child: ...)
- ShopApp:品牌主题系统(品牌色 + 暗色模式 + 大字体无障碍)
2. 一个品牌混乱的真实故事
(1) 痛点:一百个页面一百种蓝色
Bob 的 ShopApp 由 3 个开发共同开发,每人定义了自己的颜色:Colors.blue、Color(0xFF2196F3)、Color(0xFF1976D2)。100 个页面上有 12 种不同的蓝色,按钮圆角有 4px、8px、12px 三种。暗色模式更混乱——有人用 Colors.white 文字在暗色背景上,有人用 Colors.grey[300]。品牌辨识度极低。
(2) ThemeData 的解法
ThemeData 统一定义所有颜色、字体、形状,组件自动从主题获取样式,一处修改全局生效。
DART
import 'package:flutter/material.dart';
MaterialApp(
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Color(0xFF0066CC)),
appBarTheme: const AppBarTheme(centerTitle: true),
cardTheme: CardThemeData(shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12))),
),
)
TEXT
📖 仅展示
> **输出:** 在本地 Flutter SDK(Flutter 3.x / Dart 3.x)运行。Piston 服务器未安装 Flutter,请在本机 `flutter run` 实操对照。实际 UI/状态会因平台略有差异。
(3) 收益:品牌统一 + 一次切换全局生效
Bob 定义 ShopApp 品牌主题后,所有页面自动统一风格。暗色模式只需切换 ThemeData,无需逐页面修改。
3. ThemeData 配置体系
graph TD
MT[MaterialApp.theme] --> TD[ThemeData]
TD --> CS[ColorScheme]
TD --> TM[TextTheme]
TD --> AT[AppBarTheme]
TD --> CT[CardTheme]
TD --> TE[ThemeExtension]
TE --> Brand[BrandTokens]
CS --> |light| Light[Light Scheme]
CS --> |dark| Dark[Dark Scheme]
TEXT
📖 仅展示
> **输出:** 在本地 Flutter SDK(Flutter 3.x / Dart 3.x)运行。Piston 服务器未安装 Flutter,请在本机 `flutter run` 实操对照。实际 UI/状态会因平台略有差异。
(1) ColorScheme 颜色体系
| 颜色角色 | 用途 | 示例 |
|---|---|---|
primary |
主色调(按钮/Tab) | #0066CC |
onPrimary |
主色调上文字 | #FFFFFF |
secondary |
辅助色调 | #FF6B35 |
surface |
卡片/背景 | #FFFFFF/#1C1C1E |
onSurface |
表面文字色 | #1C1C1E/#FFFFFF |
error |
错误色 | #B3261E |
outline |
边框/分割线 | #79747E |
▶ 示例
TEXT
📖 仅展示
> **输出:** 在本地 Flutter SDK(Flutter 3.x / Dart 3.x)运行。Piston 服务器未安装 Flutter,请在本机 `flutter run` 实操对照。实际 UI/状态会因平台略有差异。
:ShopApp 品牌色定义
DART
import 'package:flutter/material.dart';
class ShopAppColors {
static const primary = Color(0xFF0066CC);
static const secondary = Color(0xFFFF6B35);
static const surface = Color(0xFFFFFFFF);
static const onSurface = Color(0xFF1C1C1E);
static final light = ColorScheme.light(
primary: primary,
secondary: secondary,
surface: surface,
onSurface: onSurface,
error: const Color(0xFFB3261E),
);
static final dark = ColorScheme.dark(
primary: const Color(0xFF80B3FF),
secondary: const Color(0xFFFF9B75),
surface: const Color(0xFF1C1C1E),
onSurface: const Color(0xFFE6E6E6),
error: const Color(0xFFF2B8B5),
);
}
TEXT
📖 仅展示
> **输出:** 在本地 Flutter SDK(Flutter 3.x / Dart 3.x)运行。Piston 服务器未安装 Flutter,请在本机 `flutter run` 实操对照。实际 UI/状态会因平台略有差异。
4. ThemeData 完整配置
▶ 示例
TEXT
📖 仅展示
> **输出:** 在本地 Flutter SDK(Flutter 3.x / Dart 3.x)运行。Piston 服务器未安装 Flutter,请在本机 `flutter run` 实操对照。实际 UI/状态会因平台略有差异。
:ShopApp 完整主题
DART
import 'package:flutter/material.dart';
// 自定义类定义来源:
// - ShopAppColors: 见本课第3节品牌色定义
ThemeData shopAppLightTheme(ColorScheme colorScheme) => ThemeData(
useMaterial3: true,
colorScheme: colorScheme,
// AppBar
appBarTheme: AppBarTheme(
centerTitle: true,
elevation: 0,
backgroundColor: colorScheme.surface,
foregroundColor: colorScheme.onSurface,
),
// Card
cardTheme: CardThemeData(
elevation: 2,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
clipBehavior: Clip.antiAlias,
),
// Elevated/Filled Button
filledButtonTheme: FilledButtonThemeData(
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
),
),
// Input
inputDecorationTheme: InputDecorationTheme(
filled: true,
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
),
// Bottom Nav
navigationBarTheme: NavigationBarThemeData(
indicatorColor: colorScheme.primary.withOpacity(0.1),
),
// Text
textTheme: ThemeData.light().textTheme.apply(
fontFamily: 'Roboto',
),
);
ThemeData shopAppDarkTheme(ColorScheme colorScheme) => shopAppLightTheme(colorScheme).copyWith(
brightness: Brightness.dark,
scaffoldBackgroundColor: const Color(0xFF0D0D0F),
);
TEXT
📖 仅展示
> **输出:** 在本地 Flutter SDK(Flutter 3.x / Dart 3.x)运行。Piston 服务器未安装 Flutter,请在本机 `flutter run` 实操对照。实际 UI/状态会因平台略有差异。
5. 动态主题切换
▶ 示例
TEXT
📖 仅展示
> **输出:** 在本地 Flutter SDK(Flutter 3.x / Dart 3.x)运行。Piston 服务器未安装 Flutter,请在本机 `flutter run` 实操对照。实际 UI/状态会因平台略有差异。
:Riverpod 主题管理
DART
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:shared_preferences/shared_preferences.dart';
// ⚙️ **安装依赖**:flutter pub add flutter_riverpod riverpod_annotation shared_preferences
// ⚙️ **开发依赖**:flutter pub add --dev riverpod_generator build_runner
// 自定义类定义来源:
// - shopAppLightTheme/shopAppDarkTheme: 见本课第4节
// - ShopAppColors: 见本课第3节品牌色定义
enum AppThemeMode { light, dark, system }
@riverpod
class ThemeModeNotifier extends _$ThemeModeNotifier {
@override
AppThemeMode build() => AppThemeMode.system;
void setMode(AppThemeMode mode) {
state = mode;
SharedPreferences.getInstance().then((prefs) => prefs.setString('theme_mode', mode.name));
}
ThemeMode get flutterThemeMode => switch (state) {
AppThemeMode.light => ThemeMode.light,
AppThemeMode.dark => ThemeMode.dark,
AppThemeMode.system => ThemeMode.system,
};
}
// In MaterialApp
class ShopApp extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final themeMode = ref.watch(themeModeNotifierProvider).flutterThemeMode;
return MaterialApp(
theme: shopAppLightTheme(ShopAppColors.light),
darkTheme: shopAppDarkTheme(ShopAppColors.dark),
themeMode: themeMode,
);
}
}
TEXT
📖 仅展示
> **输出:** 在本地 Flutter SDK(Flutter 3.x / Dart 3.x)运行。Piston 服务器未安装 Flutter,请在本机 `flutter run` 实操对照。实际 UI/状态会因平台略有差异。
▶ 示例
TEXT
📖 仅展示
> **输出:** 在本地 Flutter SDK(Flutter 3.x / Dart 3.x)运行。Piston 服务器未安装 Flutter,请在本机 `flutter run` 实操对照。实际 UI/状态会因平台略有差异。
:主题切换设置页
DART
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
// ⚙️ **安装依赖**:flutter pub add flutter_riverpod
// 自定义类定义来源:
// - AppThemeMode/themeModeNotifierProvider: 见本课第5节 Riverpod 主题管理
class ThemeSettingsPage extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final current = ref.watch(themeModeNotifierProvider);
return Scaffold(
appBar: AppBar(title: const Text('Appearance')),
body: ListView(children: AppThemeMode.values.map((mode) {
final label = switch (mode) {
AppThemeMode.light => 'Light',
AppThemeMode.dark => 'Dark',
AppThemeMode.system => 'System',
};
return RadioListTile<AppThemeMode>(
title: Text(label),
value: mode,
groupValue: current,
onChanged: (v) => ref.read(themeModeNotifierProvider.notifier).setMode(v!),
);
}).toList()),
);
}
}
TEXT
📖 仅展示
> **输出:** 在本地 Flutter SDK(Flutter 3.x / Dart 3.x)运行。Piston 服务器未安装 Flutter,请在本机 `flutter run` 实操对照。实际 UI/状态会因平台略有差异。
6. ThemeExtension 自定义扩展
▶ 示例
TEXT
📖 仅展示
> **输出:** 在本地 Flutter SDK(Flutter 3.x / Dart 3.x)运行。Piston 服务器未安装 Flutter,请在本机 `flutter run` 实操对照。实际 UI/状态会因平台略有差异。
:品牌令牌扩展
DART
import 'package:flutter/material.dart';
import 'dart:ui';
// 自定义类定义来源:BrandTokens 为本课自定义 ThemeExtension
class BrandTokens extends ThemeExtension<BrandTokens> {
final Color salePrice;
final Color discount;
final Color rating;
final double cardRadius;
final double productImageRatio;
const BrandTokens({
this.salePrice = Colors.green,
this.discount = Colors.red,
this.rating = Colors.amber,
this.cardRadius = 12,
this.productImageRatio = 0.75,
});
@override
BrandTokens copyWith({Color? salePrice, Color? discount, Color? rating,
double? cardRadius, double? productImageRatio}) {
return BrandTokens(
salePrice: salePrice ?? this.salePrice,
discount: discount ?? this.discount,
rating: rating ?? this.rating,
cardRadius: cardRadius ?? this.cardRadius,
productImageRatio: productImageRatio ?? this.productImageRatio,
);
}
@override
BrandTokens lerp(covariant BrandTokens? other, double t) {
if (other == null) return this;
return BrandTokens(
salePrice: Color.lerp(salePrice, other.salePrice, t)!,
discount: Color.lerp(discount, other.discount, t)!,
rating: Color.lerp(rating, other.rating, t)!,
cardRadius: lerpDouble(cardRadius, other.cardRadius, t)!,
productImageRatio: lerpDouble(productImageRatio, other.productImageRatio, t)!,
);
}
}
// Use in ThemeData
ThemeData(extensions: const [BrandTokens()])
// Access in widget
final brand = Theme.of(context).extension<BrandTokens>()!;
Text('\$99.99', style: TextStyle(color: brand.salePrice));
TEXT
📖 仅展示
> **输出:** 在本地 Flutter SDK(Flutter 3.x / Dart 3.x)运行。Piston 服务器未安装 Flutter,请在本机 `flutter run` 实操对照。实际 UI/状态会因平台略有差异。
7. 无障碍大字体模式
▶ 示例
TEXT
📖 仅展示
> **输出:** 在本地 Flutter SDK(Flutter 3.x / Dart 3.x)运行。Piston 服务器未安装 Flutter,请在本机 `flutter run` 实操对照。实际 UI/状态会因平台略有差异。
:响应式文字缩放
DART
import 'package:flutter/material.dart';
class AccessibleText extends StatelessWidget {
final String text;
final TextStyle? style;
const AccessibleText(this.text, {super.key, this.style});
@override
Widget build(BuildContext context) {
final mediaQuery = MediaQuery.of(context);
// Clamp text scale factor for accessibility
final scaleFactor = mediaQuery.textScaleFactor.clamp(0.8, 2.0);
return MediaQuery(
data: mediaQuery.copyWith(textScaleFactor: scaleFactor),
child: Text(text, style: style),
);
}
}
// Global text scale limit in MaterialApp
builder: (context, child) {
final mq = MediaQuery.of(context);
return MediaQuery(
data: mq.copyWith(textScaleFactor: mq.textScaleFactor.clamp(0.8, 1.5)),
child: child!,
);
}
TEXT
📖 仅展示
> **输出:** 在本地 Flutter SDK(Flutter 3.x / Dart 3.x)运行。Piston 服务器未安装 Flutter,请在本机 `flutter run` 实操对照。实际 UI/状态会因平台略有差异。
8. 完整示例:ShopApp 主题系统
DART
import 'package:flutter/material.dart';
// 自定义类定义来源:
// - ShopAppColors: 见本课第3节品牌色定义
// - BrandTokens: 见本课第6节 ThemeExtension
class ShopAppTheme {
static ThemeData light() => ThemeData(
useMaterial3: true,
colorScheme: ShopAppColors.light,
extensions: const [BrandTokens()],
appBarTheme: const AppBarTheme(centerTitle: true, elevation: 0),
cardTheme: CardThemeData(elevation: 2,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12))),
filledButtonTheme: FilledButtonThemeData(
style: FilledButton.styleFrom(shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)))),
inputDecorationTheme: InputDecorationTheme(filled: true,
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8))),
);
static ThemeData dark() => ThemeData(
useMaterial3: true,
colorScheme: ShopAppColors.dark,
extensions: const [BrandTokens(
salePrice: Color(0xFF4CAF50),
discount: Color(0xFFEF5350),
)],
appBarTheme: const AppBarTheme(centerTitle: true, elevation: 0),
scaffoldBackgroundColor: const Color(0xFF0D0D0F),
cardTheme: CardThemeData(elevation: 1, color: const Color(0xFF1C1C1E),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12))),
);
}
TEXT
📖 仅展示
> **输出:** 在本地 Flutter SDK(Flutter 3.x / Dart 3.x)运行。Piston 服务器未安装 Flutter,请在本机 `flutter run` 实操对照。实际 UI/状态会因平台略有差异。
❓ 常见问题
Q ColorScheme.fromSeed 和手动定义有什么区别?
A fromSeed 自动生成完整的 harmonious 调色板(12 色),手动定义更灵活但需要自己保证和谐。推荐 fromSeed 作为起点再微调。
Q Theme.of(context) 有性能开销吗?
A 有,每次调用都会重建。在 build 外缓存:
final theme = Theme.of(context);,然后复用 theme 变量。Q 组件怎么覆盖全局主题?
A 用
Theme(data: Theme.of(context).copyWith(...), child: widget) 包裹需要覆盖的子树。Q 暗色模式的图片/图标颜色怎么处理?
A 用 ColorScheme 中的角色色(如 onSurface),避免硬编码颜色。SVG 图标用 colorFilter 着色。
Q ThemeExtension 的 lerp 方法干什么用?
A lerp 实现主题切换时的插值动画,Light→Dark 切换时颜色平滑过渡而不是跳变。
Q 怎么让第三方包的组件也用自定义主题?
A 大部分 Material 包自动跟随 ThemeData。非 Material 组件需要手动传主题参数。
📖 小节
- ThemeData 统一管理颜色、字体、形状,一处修改全局生效
- ColorScheme 定义语义化颜色角色,自动适配 Light/Dark
- Riverpod 管理主题切换状态,SharedPreferences 持久化选择
- ThemeExtension 扩展自定义品牌令牌(促销色/圆角/比例)
- MediaQuery.textScaleFactor 限制文字缩放,保障无障碍
📝 作业
- 基础题(难度⭐):用 ColorScheme.fromSeed 创建品牌色主题,应用到 MaterialApp。
- 进阶题(难度⭐⭐):实现 Light/Dark/System 三模式切换,用 Riverpod 管理 + SharedPreferences 持久化。
- 挑战题(难度⭐⭐⭐):创建完整的 ShopApp 主题系统:BrandTokens 扩展 + Light/Dark 双主题 + 大字体无障碍模式 + 组件级主题覆盖。