【Flutter】riverpodを使ったテーマモード切り替え方法
個人開発者のきりしまです。
今回はriverpodを使ってテーマモードの切り替えを実装してみたいと思います。
SharedPreferencesを使ってデータの保存もします。
ひとつ問題が・・・
この方法で実装するとダークモードに設定してアプリを起動すると
一瞬ライトモードになる問題があります。今回はこの問題も解決してみたいと思います。
作ってみる
今回使うパッケージ
hooks_riverpod
shared_preferences
SharedPreferences
まずはSharedPreferencesを便利に使うための準備をします。
enum PreferenceKey {
isDark,
}
extension PreferenceKeyExt on PrefenreceKey {
String get keyString {
switch (this) {
case PreferenceKey.isDark:
return 'is_dark';
}
}
Future<bool> setBool(bool val) async {
final pref = await SharedPreferences.getInstance();
return pref.setBool(keyString, val);
}
Future<bool> getBool({required bool defaultVal}) async {
final pref = await SharedPreferences.getInstance();
if (pref.containsKey(keyString)) {
return pref.getBool(keyString) ?? defaultVal;
} else {
return defaultVal;
}
}
}
Controller
テーマを切り替える機能と読み込む機能を作ります
Providerも宣言します
final isDarkProvider = StateNotifierProvider<ThemeModeController, bool>((_) => ThemeModeController());
class ThemeModeController extends StateNotifier<bool> {
ThemeModeController() : super(false);
void toggleThemeMode(bool val) {
state = val;
PreferenceKey.isDark.setBool(val);
}
Future<void> loadTheme() async {
final isDark = await PreferenceKey.isDark.getBool(defaultVal: false);
state = isDark;
}
}
FutureProvider
FutureProviderは非同期操作が可能なProviderです。
final themeModeProvider = FutureProvider<ThemeData>((ref) async {
await ref.watch(isDarkProvider.notifier).loadTheme();
return ref.watch(isDarkProvider) ? darkTheme : lightTheme;
});
ThemeData
テーマデータを定義します
final ThemeData lightTheme = ThemeData.light().copyWith(
// hogehoge...
);
// 同じようにダークテーマも定義する
LoadingView
テーマが読み込まれるまでの待機画面を作ります
class LoadingView extends StatelessWidget {
const LoadingView({Key? key}) : super(key: key);
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
body: Container(
color: Colors.black87,
child: const Center(
child: Text(
'Loading...',
style: TextStyle(color: Colors.white70, fontSize: 23, fontStyle: FontStyle.italic),
),
),
),
),
);
}
}
HomeView
今回はSwitchウィジェットを使ってテーマを切り替えます
class HomeScreen extends StatelessWidget {
const HomeScreen({Key? key}) : super(key: key);
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: Consumer(builder: (context, ref, child) {
return Switch(
value: ref.watch(isDarkProvider),
onChanged: (val) => ref.watch(isDarkProvider.notifier).toggleThemeMode(val),
);
}),
),
);
}
}
App
先程作った、FutureProviderをref.watch
で監視してThemeDataが変わったことを検知します。
FutureProviderを監視した際の戻り値はAsyncValue
なのでErrorやLoadingといったステートをウィジェットに変換することができます。
class App extends ConsumerWidget {
const App({Key? key}) : super(key: key);
Widget build(BuildContext context, WidgetRef ref) {
final AsyncValue<ThemeData> themeData = ref.watch(themeModeProvider);
return themeData.when(
data: (themeData) {
return MaterialApp(
theme: themeData,
home: const HomeScreen(),
);
},
error: (error, stack) => Text('$error'),
loading: () => const LoadingView(),
);
}
}
これでテーマを切り替えることができるようになりました。
LoadingViewを表示することでダークテーマに設定していても、起動時にライトテーマがチラつくこともありません。
ただ、この方法だとテーマを切り替えた際にもLoadingViewが表示されてしまいます。
当たり前ですが…
FutureBuilderを使ってみる
ThemeController
戻り値にThemeDataを指定してStateによってテーマを返却します。
MaterialAppのtheme:
には結局ref.watch(isDarkProvider) ? ...
を使うのでvoid
でもいいのですが、FutureBuilderのsnapshot.hasDataがいつまで経ってもtrue
にならないのでThemeDataを返却します。
※voidなら何も返却されないので、データなんてあるわけありませんね( ᷇࿀ ᷆ )
Future<ThemeData> initialTheme() async {
final isDark = await PreferenceKey.isDark.getBool(defaultVal: false);
state = isDark;
return state ? darkTheme : lightTheme;
}
toggleTheme()
は変わりません。
Main
FutureBuilderを使ってテーマの初期化を待ってHomeScreenを表示します。
snapshot.hasData
がfalseの場合はLoadingViewが表示される仕組みです。
void main() {
runApp(ProviderScope(
child: Consumer(
builder: (BuildContext context, ref, child) {
return FutureBuilder(
future: ref.watch(isDarkProvider.notifier).initialTheme(),
builder: (context, AsyncSnapshot<ThemeData> snapshot) {
return snapshot.hasData ? const App() : const LoadingView();
},
);
},
),
));
}
App
AppはMaterialAppを返却するだけです。先程よりもシンプルですね。
theme:
にsnapshot.data
を使うとSwitchで切り替えてもテーマが切り替わらないのでref.watch
でstateを監視しています。
snapshot.data
を使っても切り替える方法がありそうな気はしますが、自分の理解度が足りないので今はこの方法です。
class App extends ConsumerWidget {
const App({Key? key}) : super(key: key);
Widget build(BuildContext context, WidgetRef ref) {
return MaterialApp(
theme: ref.watch(isDarkProvider) ? darkTheme : lightTheme,
home: const HomeScreen(),
);
}
}
これでFutureBuilderを使ってテーマ切り替え機能を実装できました。
お疲れ様でした。
追記
コメントでご指摘いただいた通りWidgetsFlutterBinding.ensureInitialized()
を使って初期化をする方法もありますので追加します。
ThemeController
final isDarkProvider = StateNotifierProvider<ThemeModeController, bool>((_) => ThemeModeController());
class ThemeModeController extends StateNotifier<bool> {
ThemeModeController() : super(false);
// 名前付きコンストラクタを定義
ThemeModeController.initialTheme({required bool defaultValue}) : super(defaultValue);
void toggleDarkMode(bool val) async {
state = val;
await PreferenceKey.isDark.setBool(val);
}
Future<bool> loadTheme() async {
final isDark = await PreferenceKey.isDark.getBool(false) ?? false;
state = isDark;
return state;
}
main
void main() async {
WidgetsFlutterBinding.ensureInitialized();
final isDark = await ThemeModeController().loadTheme();
runApp(ProviderScope(
overrides: [
isDarkProvider.overrideWithValue(ThemeModeController.initialTheme(defaultValue: isDark)),
],
child: const App(),
));
}
まとめ
FutureProviderを使った方法は別のビューでテーマを切り替えてもhome:
に指定しているビューになります。
本来は外部から設定データなどを読み込んだりする用途に向いているProviderなので今回のケースには向いていないかもしれません。
FutureBuilderを使った方法は、使いもしないThemeDataを返却しなければいけないので、ちょっと気持ち悪いですね。
でも、個人的にはこちらの方法が好きです。
参考文献
Discussion
WidgetsFlutterBinding.ensureInitialized
をつかって、runAppより前に初期化するのも手ですね!
なるほど!
こうしてあげればいいわけですね。
勉強になりました!ありがとうございます。