Chapter 20

[v0.14.0以下版] StateNotifierProviderでTheme(ライト・ダーク)を切り替える

村松龍之介
村松龍之介
2023.02.12に更新

サンプルリポジトリのリンク

ThemeSecector のコード全容はこちらのリンクから、GitHubリポジトリにて確認できます。
https://github.com/Riscait/flutter_playground/tree/main/lib/src/features/theme_selector


なぜStateNotifierか

単純にユーザーが選択したテーマの状態を一時的に保存するためであればStateProviderでも実現できそうです。

しかし、アプリを再起動したときに選択したテーマを保持するためには何らかの保存領域に記憶しておかねばなりません。
つまり、記憶領域への保存、アプリ起動時に取得することが必要になってきます。

そういった付随した処理が必要なときは、StateNotifierが便利です。
StateNotifierで作ったクラスにメソッドを用意しておけば、複雑な状態操作ロジックをWidgetから分離しやすくなります。

まずは、StateNotifierを使って、Themeの変更・記憶を行うクラスを作成します。

今回は、 shared_preferences プラグイン[1]を使ってテーマ選択状態をローカルに保存することにします。

先に、保存・読み込みに必要なKeyを定義しておきましょう。

theme_provider.dart
/// SharedPreferences で使用するテーマ保存用のキー
const _themePrefsKey = 'selectedThemeKey';

ユーザーが選択したテーマをStateに保持するStateNotifierを作成します。

theme_provider.dart
class ThemeNotifier extends StateNotifier<ThemeMode> {
  ThemeNotifier({
    required this.prefs,
    required ThemeMode initialThemeMode,
  }) : super(initialThemeMode);

  /// 選択したテーマを保存するためのローカル保存領域
  final SharedPreferences prefs;

  /// テーマの変更と保存を行う
  Future<void> changeAndSave(ThemeMode theme) async {
    await prefs.setInt(_themePrefsKey, theme.index);
    state = theme;
  }
}

引数で SharedPreferences と 最初のテーマを受け取り、 super(initialThemeMode) でStateを初期化しています。
テーマの変更と保存を行う changeAndSave(theme) メソッドを持っています。

次はThemeNotifierのProviderを宣言します。

theme_provider.dart
final themeSelectorProvider = StateNotifierProvider<ThemeNotifier, ThemeMode>>((ref) {
  /// `SharedPreferences` を使用して、記憶しているテーマを取得
  final prefs = ref.watch(sharedPreferencesProvider);
  final index = prefs.getInt(_themePrefsKey) ?? ThemeMode.system.index;
  final themeMode = ThemeMode.values.firstWhere(
    (e) => e.index == index,
    orElse: () => ThemeMode.system,
  );
  return ThemeNotifier(
    prefs: prefs,
    initialThemeMode: themeMode,
  );
});

SharedPreferencesのProviderを取得し、テーマを取得します。
まだユーザーがテーマを選択したことがなければ、システムテーマが選ばれるようにしました。
ThemeNotifier に必要な prefs, initialThemeMode を引数で渡しています。

Widgetで使う

themeSelectorProvider を watch し、現在選択中のテーマを取得します。
MaterialApptheme , darkTheme に、ライト用・ダーク用のテーマを設定します。

class MyApp extends ConsumerWidget {
  
  Widget build(BuildContext context, ScopedReader watch) {
    return MaterialApp(
      title: 'My App',
      themeMode: watch(themeSelectorProvider), // 現在のテーマモード設定を監視
      theme: ThemeData.light() // `.copyWith()` メソッドでカスタムしましょう
      darkTheme: ThemeData.dark()
      home: HomePage(),
    );
  }
}

これで、ユーザーの選択によって、ThemeNotifier経由で、アプリのテーマが変化するようになりました🎉

ユーザーがテーマを選択できる画面を作る

ListViewRadioListTile Widgetを使って画面を作ります。

theme_selector_page.dart

Widget build(BuildContext context, ScopedReader watch) {
  final themeSelector = watch(themeSelectorProvider.notifier);
  final currentThemeMode = watch(themeSelectorProvider);
  return ListView.builder(
    itemCount: ThemeMode.values.length,
    itemBuilder: (_, index) {
      final themeMode = ThemeMode.values[index];
      return RadioListTile<ThemeMode>(
        value: themeMode,
        groupValue: currentThemeMode,
        onChanged: (newTheme) => themeSelector.changeAndSave(newTheme!),
        title: Text(describeEnum(themeMode)),
      );
    },
  );
}

ThemeDataを好みのものに編集する

デフォルトで用意されている ThemeData.light()ThemeData.dark() をそのまま使っても問題はないですが、
多くの場合、アプリ独自のカラーリングや文字装飾を定義することになるかと思います。

以下のようにThemeDataを定義して、 MaterialAppの themedarkTheme に指定しましょう。

my_light_theme_data.dart
final myLightThemeData = ThemeData.from(
  // 色の指定は `ColorScheme` を使用することが推奨されている。
  // `DarkTheme` の方は `ColorScheme.dark()` を使用を推奨します。
  colorScheme: const ColorScheme.light().copyWith(
    primary: const Color(0xFFF0BC1B),
    onPrimary: Colors.white,
    onSecondary: Colors.white,
  ),
  textTheme: const TextTheme(
    button: TextStyle(
      fontWeight: FontWeight.bold,
      color: Colors.blue,
    ),
  ),
).copyWith(
  scaffoldBackgroundColor: const Color(0xFFF6F3F0),
  appBarTheme: const AppBarTheme(color: Color(0xFFEEEAE1)),
  floatingActionButtonTheme: const FloatingActionButtonThemeData(
    elevation: 2,
  ),
);

final myDarkThemeData = ThemeData.from(
  ...
);

(任意) ThemeModeを拡張する

FlutterにはThemeModeという使用するテーマを判別するEnumが用意されています。

テーマを選択してもらう画面で、テーマに対する説明分やアイコンを表示させたいときはThemeModeを拡張すると便利です。

theme_mode_ext.dart
extension ThemeModeExt on ThemeMode {
  String get subtitle {
    switch (this) {
      case ThemeMode.system:
        return '端末のシステム設定に追従します';
      case ThemeMode.light:
        return '明るいテーマです';
      case ThemeMode.dark:
        return '暗いテーマです';
    }
  }

  IconData get iconData {
    switch (this) {
      case ThemeMode.system:
        return Icons.autorenew;
      case ThemeMode.light:
        return Icons.wb_sunny;
      case ThemeMode.dark:
        return Icons.nightlife;
    }
  }
} 

上記のように enumを拡張することで、 Text(themeMode.subtitle)Icon(themeMode.iconData) のように複数箇所から呼び出せるようになります。

(応用) 4種類以上のテーマに対応する

ThemeMode に用意された system, light, dark を切り替えられるようになりました。
アプリのテーマを2個持つことができるようになりましたが、
3種類以上のテーマを切り替えられる機能を持たせるにはどうするべきでしょうか?

→ 以下のように独自のThemeMode (enum) を作成して、ThemeNotifierのStateを ThemeMode から作成したenumに変更すれば実現できます。

my_theme_mode.dart
enum MyThemeMode {
  pearlWhite,
  pianoBlack,
  oceanBlue,
  hierophantGreen,
  hermitPurple,
  magiciansRed,
  goldExperience,
}
脚注
  1. https://pub.dev/packages/shared_preferences ↩︎