Flutter Riverpod + SharedPreferences でライトモードとダークモードを切り替える
この記事で出来ること
はじめに
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
に保持します。
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への永続化
を行う StateNotifier
、StateNotifierProvider
を実装します。
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#name
、ThemeMode#values#byName(String)
を使って、文字列として保存・読み込みします。
main関数の実装
main関数の中で、 SharedPReferencesInstance.initialize()
を呼び出して SharePreferences の初期化を行います。
また、その前の行に WidgetsFlutterBinding.ensureInitialized()
を呼ぶことで、 SharePreferences の初期化が終わるまでは Flutterコードが実行されないようにします。
あとは、MaterialApp
をビルドしているクラスを ConsumerWidget
として実装し、 MaterialApp
の themeMode
引数 に状態管理している ThemeMode
を渡すだけです。
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()
の呼び出しをしています。
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,
);
}
}
}
完全なソースコード
Discussion