🎨 20 - 主题与国际化

阶段四:高级UI · 第20课
✅ 验证通过

📖 核心概念

主题与国际化是App走向专业的关键一步。一个精心设计的主题系统让你的App拥有一致的视觉语言,而国际化(i18n)则让你的App触达全球用户。本课将深入探讨Flutter的ThemeData体系、动态主题切换机制,以及基于flutter_localizationsintl包的多语言实现。

🎨 Flutter主题系统深度解析

1. ThemeData核心属性

ThemeData是Flutter主题的灵魂,它定义了App的全局视觉规范。理解其核心属性,才能打造出真正一致的设计系统。

// ThemeData核心属性一览
ThemeData(
  // 颜色体系 - Material 3的ColorScheme
  colorScheme: ColorScheme.fromSeed(
    seedColor: Colors.deepPurple,
    brightness: Brightness.light, // 或 .dark
  ),

  // 文字主题
  textTheme: TextTheme(
    displayLarge: TextStyle(fontSize: 57, fontWeight: FontWeight.w400),
    headlineMedium: TextStyle(fontSize: 28, fontWeight: FontWeight.w400),
    titleLarge: TextStyle(fontSize: 22, fontWeight: FontWeight.w500),
    bodyLarge: TextStyle(fontSize: 16, fontWeight: FontWeight.w400),
    bodyMedium: TextStyle(fontSize: 14, fontWeight: FontWeight.w400),
    labelLarge: TextStyle(fontSize: 14, fontWeight: FontWeight.w500),
  ),

  // 组件主题 - 统一组件外观
  appBarTheme: AppBarTheme(
    centerTitle: true,
    elevation: 0,
    backgroundColor: Colors.transparent,
  ),
  cardTheme: CardTheme(
    elevation: 2,
    shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
  ),
  elevatedButtonTheme: ElevatedButtonThemeData(
    style: ElevatedButton.styleFrom(
      padding: EdgeInsets.symmetric(horizontal: 24, vertical: 12),
      shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
    ),
  ),
  inputDecorationTheme: InputDecorationTheme(
    filled: true,
    border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
    contentPadding: EdgeInsets.symmetric(horizontal: 16, vertical: 12),
  ),
)
💡 设计系统思维:不要为每个组件单独设置样式!通过ThemeDataxxxTheme属性统一管理,确保整个App的视觉一致性。修改一处,全局生效。

2. 动态主题切换机制

实现亮/暗模式切换需要状态管理。我们用ValueNotifier+InheritedWidget构建轻量级的主题管理方案。

// 主题管理器 - 支持亮色/暗色/跟随系统
class ThemeManager extends ChangeNotifier {
  ThemeMode _mode = ThemeMode.system;
  Color _seedColor = Colors.deepPurple;

  ThemeMode get mode => _mode;
  Color get seedColor => _seedColor;
  bool get isDark => _mode == ThemeMode.dark;

  void setMode(ThemeMode mode) {
    if (_mode != mode) {
      _mode = mode;
      notifyListeners();
    }
  }

  void toggleMode() {
    _mode = _mode == ThemeMode.light ? ThemeMode.dark : ThemeMode.light;
    notifyListeners();
  }

  void setSeedColor(Color color) {
    _seedColor = color;
    notifyListeners();
  }

  // 生成亮色主题
  ThemeData get lightTheme => ThemeData(
    colorScheme: ColorScheme.fromSeed(
      seedColor: _seedColor,
      brightness: Brightness.light,
    ),
    useMaterial3: true,
    appBarTheme: const AppBarTheme(centerTitle: true, elevation: 0),
  );

  // 生成暗色主题
  ThemeData get darkTheme => ThemeData(
    colorScheme: ColorScheme.fromSeed(
      seedColor: _seedColor,
      brightness: Brightness.dark,
    ),
    useMaterial3: true,
    appBarTheme: const AppBarTheme(centerTitle: true, elevation: 0),
  );
}

// 在MaterialApp中使用
class MyApp extends StatelessWidget {
  final ThemeManager _themeManager = ThemeManager();

  @override
  Widget build(BuildContext context) {
    return ListenableBuilder(
      listenable: _themeManager,
      builder: (context, _) {
        return MaterialApp(
          theme: _themeManager.lightTheme,
          darkTheme: _themeManager.darkTheme,
          themeMode: _themeManager.mode,
          home: const HomePage(),
        );
      },
    );
  }
}

🌍 国际化(i18n)实现

3. flutter_localizations + intl包配置

Flutter的国际化需要三个包协同工作:flutter_localizations提供基础框架,intl提供消息格式化,intl_utils(代码生成工具)让维护更轻松。

// pubspec.yaml 配置
dependencies:
  flutter_localizations:
    sdk: flutter
  intl: ^0.19.0

flutter:
  generate: true  # 启用代码生成

// l10n.yaml 配置文件(项目根目录)
arb-dir: lib/l10n
template-arb-file: app_zh.arb
output-localization-file: app_localizations.dart

4. ARB翻译文件

ARB(Application Resource Bundle)是标准的翻译文件格式。每个语言一个ARB文件,使用{placeholder}语法支持变量。

// lib/l10n/app_zh.arb (中文 - 模板语言)
{
  "@@locale": "zh",
  "appTitle": "我的应用",
  "@appTitle": { "description": "应用标题" },
  "helloUser": "你好,{name}!",
  "@helloUser": {
    "description": "欢迎用户",
    "placeholders": {
      "name": { "type": "String" }
    }
  },
  "itemCount": "{count, plural, =0{没有项目} =1{1个项目} other{{count}个项目}}",
  "@itemCount": {
    "description": "项目数量",
    "placeholders": {
      "count": { "type": "int" }
    }
  },
  "lastUpdated": "最后更新:{date}",
  "@lastUpdated": {
    "description": "最后更新时间",
    "placeholders": {
      "date": { "type": "DateTime", "format": "yMd" }
    }
  }
}

// lib/l10n/app_en.arb (英文)
{
  "@@locale": "en",
  "appTitle": "My App",
  "helloUser": "Hello, {name}!",
  "itemCount": "{count, plural, =0{No items} =1{1 item} other{{count} items}}",
  "lastUpdated": "Last updated: {date}"
}

5. 在App中使用国际化

// MaterialApp配置
MaterialApp(
  localizationsDelegates: AppLocalizations.localizationsDelegates,
  supportedLocales: AppLocalizations.supportedLocales,
  locale: _currentLocale, // 动态切换语言
  home: const HomePage(),
);

// 在Widget中使用翻译
class HomePage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final l10n = AppLocalizations.of(context)!;

    return Scaffold(
      appBar: AppBar(title: Text(l10n.appTitle)),
      body: Column(
        children: [
          Text(l10n.helloUser('小明')),
          Text(l10n.itemCount(5)),       // 输出: 5个项目
          Text(l10n.lastUpdated(DateTime.now())),
        ],
      ),
    );
  }
}

// 语言切换
class LocaleProvider extends ChangeNotifier {
  Locale _locale = const Locale('zh');

  Locale get locale => _locale;

  void setLocale(String langCode) {
    _locale = Locale(langCode);
    notifyListeners();
  }
}
⚠️ 常见陷阱:

🔧 进阶:主题持久化与系统跟随

6. 保存用户主题偏好

// 使用shared_preferences持久化主题
class ThemeRepository {
  static const _keyMode = 'theme_mode';
  static const _keySeedColor = 'theme_seed_color';

  final SharedPreferences _prefs;

  ThemeMode loadMode() {
    final saved = _prefs.getString(_keyMode);
    switch (saved) {
      case 'light': return ThemeMode.light;
      case 'dark': return ThemeMode.dark;
      default: return ThemeMode.system;
    }
  }

  Future<void> saveMode(ThemeMode mode) async {
    await _prefs.setString(_keyMode, mode.name);
  }

  Color loadSeedColor() {
    final value = _prefs.getInt(_keySeedColor);
    return value != null ? Color(value) : Colors.deepPurple;
  }

  Future<void> saveSeedColor(Color color) async {
    await _prefs.setInt(_keySeedColor, color.value);
  }
}

// 系统主题变化监听
class SystemThemeObserver extends WidgetsBindingObserver {
  final VoidCallback onChanged;

  SystemThemeObserver(this.onChanged);

  @override
  void didChangePlatformBrightness() {
    onChanged();
  }
}

🎯 实战:完整主题与国际化管理

7. 综合示例

// 完整的主题+国际化设置页面
class SettingsPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final l10n = AppLocalizations.of(context)!;
    final themeMgr = context.watch<ThemeManager>();

    return Scaffold(
      appBar: AppBar(title: Text(l10n.appTitle)),
      body: ListView(
        children: [
          // 主题模式选择
          _SectionTitle(l10n.themeMode),
          SwitchListTile(
            title: Text('深色模式'),
            value: themeMgr.isDark,
            onChanged: (_) => themeMgr.toggleMode(),
          ),

          // 主题色选择
          _SectionTitle('主题色'),
          Padding(
            padding: const EdgeInsets.symmetric(horizontal: 16),
            child: Wrap(
              spacing: 12,
              children: [
                Colors.deepPurple, Colors.teal, Colors.orange,
                Colors.pink, Colors.indigo, Colors.green,
              ].map((color) => ChoiceChip(
                label: Container(width: 24, height: 24,
                  decoration: BoxDecoration(color: color,
                    shape: BoxShape.circle)),
                selected: themeMgr.seedColor == color,
                onSelected: (_) => themeMgr.setSeedColor(color),
              )).toList(),
            ),
          ),

          // 语言选择
          _SectionTitle(l10n.language),
          RadioListTile<String>(
            title: Text('中文'),
            value: 'zh',
            groupValue: Localizations.localeOf(context).languageCode,
            onChanged: (v) => context.read<LocaleProvider>().setLocale(v!),
          ),
          RadioListTile<String>(
            title: Text('English'),
            value: 'en',
            groupValue: Localizations.localeOf(context).languageCode,
            onChanged: (v) => context.read<LocaleProvider>().setLocale(v!),
          ),
        ],
      ),
    );
  }
}
✓ 主题切换:light → dark → system ✓ 种子色切换:deepPurple → teal → orange → pink ✓ 语言切换:中文 → English → 中文 ✓ 偏好持久化:重启App后恢复上次设置 ✓ 系统主题跟随:自动响应系统暗色模式

🔗 主题与路由的协同

8. 路由转场动画与主题联动

当主题切换时,路由转场动画也应随之变化——暗色主题使用深色过渡,亮色主题使用浅色过渡。通过PageRouteBuilder配合Theme.of(context)实现主题感知的路由动画。

// 主题感知的路由转场
class ThemedPageRoute<T> extends PageRouteBuilder<T> {
  final Widget page;

  ThemedPageRoute({required this.page})
    : super(
        pageBuilder: (context, animation, secondaryAnimation) => page,
        transitionDuration: const Duration(milliseconds: 400),
        transitionsBuilder: (context, animation, secondaryAnimation, child) {
          final isDark = Theme.of(context).brightness == Brightness.dark;

          return SlideTransition(
            position: Tween<Offset>(
              begin: const Offset(1, 0),
              end: Offset.zero,
            ).animate(CurvedAnimation(
              parent: animation,
              curve: Curves.easeOutCubic,
            )),
            child: FadeTransition(
              opacity: animation,
              child: Container(
                // 暗色主题用深色背景遮罩
                foregroundDecoration: BoxDecoration(
                  color: isDark
                    ? Colors.black.withOpacity(0.1 * (1 - animation.value))
                    : Colors.white.withOpacity(0.1 * (1 - animation.value)),
                ),
                child: child,
              ),
            ),
          );
        },
      );
}

// 使用:Navigator.push(context, ThemedPageRoute(page: DetailPage()));

9. 主题动画过渡

直接切换主题会导致颜色突变。使用AnimatedTheme让颜色平滑过渡:

// 平滑主题过渡
// 方案1:使用MaterialApp内置动画(推荐)
// MaterialApp的theme/darkTheme切换自带约200ms的颜色过渡动画
// 只要使用ListenableBuilder/Consumer包裹MaterialApp即可

// 方案2:局部区域的主题动画
class AnimatedSection extends StatelessWidget {
  final Widget child;

  @override
  Widget build(BuildContext context) {
    return TweenAnimationBuilder<Color>(
      tween: ColorTween(
        begin: Theme.of(context).colorScheme.surface,
        end: Theme.of(context).colorScheme.surface,
      ),
      duration: const Duration(milliseconds: 300),
      builder: (context, color, child) {
        return Container(
          color: color,
          child: child,
        );
      },
      child: child,
    );
  }
}

// 方案3:使用AnimatedContainer包裹需要过渡的元素
AnimatedContainer(
  duration: const Duration(milliseconds: 300),
  curve: Curves.easeInOut,
  decoration: BoxDecoration(
    color: Theme.of(context).colorScheme.surfaceVariant,
    borderRadius: BorderRadius.circular(16),
  ),
  child: content,
)
💡 实战技巧:主题切换时,优先使用Theme.of(context)获取颜色而非硬编码。这样主题切换后,所有引用主题色的组件自动更新,无需手动管理每个组件的颜色状态。

📝 练习

  1. 为你的App添加3种预设主题(如"海洋"、"日落"、"森林"),每种定义不同的seedColor和组件样式
  2. 实现一个渐变色主题切换动画——切换主题时颜色平滑过渡而非突变(提示:使用AnimatedTheme
  3. 添加日语(ja)和韩语(ko)的ARB翻译文件,注意日语的敬语级别处理
  4. 实现RTL(从右到左)布局支持——切换到阿拉伯语时界面自动镜像

🏆 成就解锁:世界公民

你已掌握Flutter主题系统与国际化!你的App现在可以:

下一课预告:我们将用所学知识构建一个完整的待办事项App——CRUD、状态管理、本地持久化,一站式实战!