🌗

【Flutter】riverpodを使ったテーマモード切り替え方法

2022/05/04に公開
2

個人開発者のきりしまです。
今回はriverpodを使ってテーマモードの切り替えを実装してみたいと思います。
SharedPreferencesを使ってデータの保存もします。

ひとつ問題が・・・

この方法で実装するとダークモードに設定してアプリを起動すると
一瞬ライトモードになる問題があります。今回はこの問題も解決してみたいと思います。

作ってみる

今回使うパッケージ

hooks_riverpod
shared_preferences
https://pub.dev/packages/hooks_riverpod
https://pub.dev/packages/shared_preferences

SharedPreferences

まずはSharedPreferencesを便利に使うための準備をします。

preference_key.dart
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も宣言します

thememode_controller.dart
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です。

thememode_controller.dart
final themeModeProvider = FutureProvider<ThemeData>((ref) async {
  await ref.watch(isDarkProvider.notifier).loadTheme();
  return ref.watch(isDarkProvider) ? darkTheme : lightTheme;
});

ThemeData

テーマデータを定義します

light.dart
final ThemeData lightTheme = ThemeData.light().copyWith(
    // hogehoge...
);
// 同じようにダークテーマも定義する

LoadingView

テーマが読み込まれるまでの待機画面を作ります

loading.dart
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ウィジェットを使ってテーマを切り替えます

home.dart
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といったステートをウィジェットに変換することができます。

app.dart
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なら何も返却されないので、データなんてあるわけありませんね( ᷇࿀ ᷆ )

theme_controller.dart
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が表示される仕組みです。

main.dart
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を使っても切り替える方法がありそうな気はしますが、自分の理解度が足りないので今はこの方法です。

app.dart
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

theme_controller.dart
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

main.dart
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を返却しなければいけないので、ちょっと気持ち悪いですね。
でも、個人的にはこちらの方法が好きです。

参考文献

https://riverpod.dev/ja/docs/providers/future_provider
https://stackoverflow.com/questions/44379849/display-app-theme-immediately-in-flutter-app

Discussion

sugitsugit

WidgetsFlutterBinding.ensureInitialized
をつかって、runAppより前に初期化するのも手ですね!

きりしまきりしま

なるほど!

WidgetsFlutterBinding.ensureInitialized();
await ThemeModeController().initialTheme();

こうしてあげればいいわけですね。
勉強になりました!ありがとうございます。