🌗

Flutter Riverpod + SharedPreferences でライトモードとダークモードを切り替える

2023/03/21に公開

この記事で出来ること

はじめに

Flutterが提供している MaterialApp では、 ThemeMode というenum値によって

  • ThemeMode.light ライトモード
  • ThemeMode.dark ダークモード
  • ThemeMode.system システム設定モード
    を切り替えることができます。
class ThemeModeExampleApp extends StatelessWidget {
  const ThemeModeExampleApp({super.key});

  
  Widget build(BuildContext context, WidgetRef ref) {
    return MaterialApp(
      title: 'Theme Mode Example',
      themeMode: /* ThemeModeを設定する場所 */,
      home: const HomeScreen(),
    );
  }
}

この記事では、Riverpodを使って ThemeMode を切り替えて、SharedPreferencesを使って ThemeMode を保存する(永続化する)方法を解説します。

パッケージのインストール

flutter pub add shared_preferences flutter_riverpod

実装

SharePreferences のインスタンスを保持する

まずはSharedPreferenceのインスタンスを保持するシングルトン・クラスを作っていきます。
このクラスは SharedPreferencesInstance#initialize() というメソッドを公開していて、アプリ起動時に一度だけ呼び出すことでSharedPreferencesのインスタンスを、static変数 _prefs に保持します。

share_preferences_instance.dart
import 'package:shared_preferences/shared_preferences.dart';

class SharedPreferencesInstance {
  static late final SharedPreferences _prefs;
  SharedPreferences get prefs => _prefs;

  static final SharedPreferencesInstance _instance = SharedPreferencesInstance._internal();

  SharedPreferencesInstance._internal();

  factory SharedPreferencesInstance() => _instance;

  static initialize() async {
    _prefs = await SharedPreferences.getInstance();
  }
}

ThemeMode を状態として管理する

ここでは、ThemeMode を状態として管理しながら、

  • ThemeMode の変更
  • ThemeMode の状態の公開
  • ThemeMode のSharedPreferencesへの永続化

を行う StateNotifierStateNotifierProviderを実装します。

theme_mode_provider.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:theme_mode_example/share_preferences_instance.dart';

class ThemeModeNotifier extends StateNotifier<ThemeMode> {
  static const String keyThemeMode = 'theme_mode';

  final _prefs = SharedPreferencesInstance().prefs;

  ThemeModeNotifier() : super(ThemeMode.system) {
    state = _loadThemeMode() ?? ThemeMode.system;
  }

  Future<void> toggle() async {
    ThemeMode themeMode;
    switch (state) {
      case ThemeMode.light:
        themeMode = ThemeMode.dark;
        break;
      case ThemeMode.dark:
        themeMode = ThemeMode.system;
        break;
      case ThemeMode.system:
        themeMode = ThemeMode.light;
        break;
    }
    await _saveThemeMode(themeMode).then((value) {
      if (value == true) {
        state = themeMode;
      }
    });
  }

  ThemeMode? _loadThemeMode() {
    final loaded = _prefs.getString(keyThemeMode);
    if (loaded == null) {
      return null;
    }
    return ThemeMode.values.byName(loaded);
  }

  Future<bool> _saveThemeMode(ThemeMode themeMode) => _prefs.setString(keyThemeMode, themeMode.name);
}

final themeModeProvider = StateNotifierProvider<ThemeModeNotifier, ThemeMode>((ref) => ThemeModeNotifier());

いくつかメソッドがありますが、ThemeModeNotifier が外部に公開しているのは ThemeModeNotifier#toggle() だけです。

toggle() は、現在の状態から light -> dark -> system -> light ... と順繰りに ThemeMode を切り替えて SharedPreferences に永続化するメソッドです。

また、コンストラクタ内では _loadThemeMode() という privateメソッドを呼び出して永続化された ThemeMode を読み込むようにしています。

ThemeMode のような enum値は SharedPreferences に直接保存することができないため、 ThemeMode#nameThemeMode#values#byName(String) を使って、文字列として保存・読み込みします。

main関数の実装

main関数の中で、 SharedPReferencesInstance.initialize() を呼び出して SharePreferences の初期化を行います。
また、その前の行に WidgetsFlutterBinding.ensureInitialized() を呼ぶことで、 SharePreferences の初期化が終わるまでは Flutterコードが実行されないようにします。

あとは、MaterialApp をビルドしているクラスを ConsumerWidget として実装し、 MaterialAppthemeMode引数 に状態管理している ThemeMode を渡すだけです。

main.dart
Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await SharedPreferencesInstance.initialize();

  runApp(
    const ProviderScope(
      child: ThemeModeExampleApp(),
    ),
  );
}

class ThemeModeExampleApp extends ConsumerWidget {
  const ThemeModeExampleApp({super.key});

  
  Widget build(BuildContext context, WidgetRef ref) {
    return MaterialApp(
      title: 'Theme Mode Example',
      theme: _buildTheme(Brightness.light),
      darkTheme: _buildTheme(Brightness.dark),
      themeMode: ref.watch(themeModeProvider),
      home: const HomeScreen(),
    );
  }
}

ThemeData _buildTheme(Brightness brightness) {
  return ThemeData(
    useMaterial3: true,
    brightness: brightness,
  );
}

ThemeMode を切り替えるUI

今回は、ホーム画面のドロワーの中に ThemeMode を切り替える UI を実装していきます。
色々と書いていますが、 ThemeModeTile が現在の ThemeMode の表示し、タップされたたときに ThemeModeNotifier#toggle() の呼び出しをしています。

home_screen.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:theme_mode_example/theme_mode_provider.dart';

class HomeScreen extends StatelessWidget {
  const HomeScreen({super.key});

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Home'),
        centerTitle: true,
      ),
      drawer: const HomeDrawer(),
      body: /* 省略 */,
    );
  }
}

class HomeDrawer extends StatelessWidget {
  const HomeDrawer({super.key});

  
  Widget build(BuildContext context) {
    return Drawer(
      child: ListView(
        children: [
          ListTile(
            leading: const Icon(Icons.info_outline_rounded),
            title: const Text('Abount'),
            onTap: () => showLicensePage(context: context),
          ),
          const Divider(),
          const ThemeModeTile(),
        ],
      ),
    );
  }
}

class ThemeModeTile extends ConsumerWidget {
  const ThemeModeTile({super.key});

  
  Widget build(BuildContext context, WidgetRef ref) {
    final toggle = ref.read(themeModeProvider.notifier).toggle;
    switch (ref.watch(themeModeProvider)) {
      case ThemeMode.light:
        return ListTile(
          leading: const Icon(Icons.light_mode_rounded),
          title: const Text('Light'),
          onTap: toggle,
        );
      case ThemeMode.dark:
        return ListTile(
          leading: const Icon(Icons.dark_mode_rounded),
          title: const Text('Dark'),
          onTap: toggle,
        );
      case ThemeMode.system:
        return ListTile(
          leading: const Icon(Icons.smartphone_rounded),
          title: const Text('System'),
          onTap: toggle,
        );
    }
  }
}

完全なソースコード

https://github.com/intoffset/flutter_examples/tree/main/theme_mode_example

Discussion