2
0

【Flutter】RiverpodとSharedPreferencesを使ってダークモードに切り替える

Last updated at Posted at 2023-12-19

やりたいこと

ユーザーのテーマ設定(ライトモードとダークモード)を柔軟に切り替える機能の実装をしていきたいと思います。

具体的には、Riverpodを使用して状態管理を行い、SharedPreferencesを利用してテーマ設定を端末内に保存することで、アプリの再起動後も維持していきます。

環境

・Flutter 3.16.4
・パッケージは以下を使用

pubspec.yaml
  shared_preferences: ^2.2.2
  flutter_riverpod: ^2.4.9

完成イメージ

ユーザーがアプリのボトムナビゲーションバーにある「settings」オプションを通じて、ライトモードとダークモードを簡単に切り替えることができるようなものを想定します。

darkmode.gif

フォルダ構成

※構成はあくまで一例です。
スクリーンショット 2023-12-18 15.31.44.png

  • models:ビジネスロジックとアプリケーションの状態管理(テーマモードの管理)
  • service:データの保存や読み込み
  • pages:UI

実装手順

1.SharedPreferencesの利用

テーマ設定を保存し、読み込むためのPreferencesServiceクラスを実装します。

全コード
preferences_service.dart
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';

class PreferencesService {
  static const String themeModeKey = 'themeMode';
  // 選択されたテーマモードを文字列として端末保存する
  static Future<void> saveThemeMode(ThemeMode mode) async {
    final prefs = await SharedPreferences.getInstance();
    prefs.setString(themeModeKey, mode.toString());
  }

  // 保存されたテーマモードをローカルストレージから読み込む
  static Future<ThemeMode> getThemeMode() async {
    final prefs = await SharedPreferences.getInstance();
    // 保存されたテーマモードの文字列を取得する(見つからなければsystem)。
    final themeModeString =
        prefs.getString(themeModeKey) ?? ThemeMode.system.toString();
    // 保存された文字列に対応するThemeModeの値を取得する(一致しない場合はsystem)。
    return ThemeMode.values.firstWhere(
      (element) => element.toString() == themeModeString,
      orElse: () => ThemeMode.system,
    );
  }
}

(1)SharedPreferencesの制約

ThemeModeは System, Dark, Light といった値を持つenum型です。

しかしSharedPreferencesは、基本的なデータ型(文字列:String、整数:int、ブール値:bool、浮動小数点数:double、文字列のリスト:List<String>)のみを使用できます(参考)。

そのためThemeModeのようなenum型は、保存や読み込みを行う際に、文字列や整数などの基本的なデータ型に変換する必要があります。

(2)saveThemeModeメソッド

  static Future<void> saveThemeMode(ThemeMode mode) async {
    final prefs = await SharedPreferences.getInstance();
    prefs.setString(themeModeKey, mode.toString());
  }

今回はtoString()メソッドを使用してenumを文字列に変換し、setStringメソッドを使用してこの文字列を保存します。これにより、選択されたテーマモードがローカルストレージに永続化されます。

(3)getThemeModeメソッド

  static Future<ThemeMode> getThemeMode() async {
    final prefs = await SharedPreferences.getInstance();
    final themeModeString =
        prefs.getString(themeModeKey) ?? ThemeMode.system.toString();
    return ThemeMode.values.firstWhere(
      (element) => element.toString() == themeModeString,
      orElse: () => ThemeMode.system,
    );
  }

getStringメソッドを使用して、以前に保存されたテーマモードの文字列を取得します。
もし保存されたテーマモードが存在しない場合(初めてアプリを使用するユーザーなど)、デフォルト値としてThemeMode.systemが使用されます。

firstWhereメソッドを使用して、保存された文字列に対応するThemeModeの値を探しています。
ThemeModeの値を文字列に変換し、保存された文字列と比較します。そして一致する値が見つかれば、そのThemeMode値が返されます。
orElseは、一致する値がリスト内に見つからない場合に使用されるデフォルト値です。この場合、ThemeMode.systemが返されます。

2.テーマモードの管理

テーマモードを管理するThemeModeNotifierを実装します。

全コード
theme_mode.dart
import 'package:dark_mode_sample/service/preferences_service.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

// アプリ全体のテーマモードの状態を管理するプロパイダー
final themeModeProvider = StateNotifierProvider<ThemeModeNotifier, ThemeMode>(
    (ref) => ThemeModeNotifier(ThemeMode.system));

class ThemeModeNotifier extends StateNotifier<ThemeMode> {
  // テーマモードの初期設定
  ThemeModeNotifier(ThemeMode initialMode) : super(initialMode);
  // テーマモードを更新
  Future<void> updateThemeMode(ThemeMode mode) async {
    state = mode;
    await PreferencesService.saveThemeMode(mode);
  }
}

(1) 状態管理クラスの作成

ThemeModeNotifierは、アプリのテーマモード(例えば、ライトモードやダークモード)を管理するクラスです。このクラスは、アプリのテーマ設定をどのように変更するかを定義します。

①コンストラクタの説明
ThemeModeNotifier(ThemeMode initialMode) : super(initialMode);

コンストラクタ: ThemeModeNotifier(ThemeMode initialMode)は、このクラスのインスタンスを作成する際に呼び出されます。initialModeは、アプリが最初に使用するテーマモードを指定します。

コンストラクタ内でsuper(initialMode)を呼び出すことにより、ThemeModeNotifierの初期状態がinitialMode(指定されたテーマモード)に設定されます。これにより、アプリが起動するときに、ユーザーが以前に選択したテーマモードが適用されます。

②updateThemeModeメソッド
  Future<void> updateThemeMode(ThemeMode mode) async {
    state = mode;
    await PreferencesService.saveThemeMode(mode);
  }

state = mode;で現在のテーマモードを更新し、PreferencesServiceのsaveThemeMode(mode);を呼び出して新しいモードをローカルストレージに保存します。

ユーザーがテーマを変更するとき、updateThemeModeメソッドを呼び出して新しいテーマモードを設定し、保存します。

(2) プロバイダーの作成

final themeModeProvider = StateNotifierProvider<ThemeModeNotifier, ThemeMode>(
    (ref) => ThemeModeNotifier(ThemeMode.system));
①プロバイダーの定義

themeModeProviderは、アプリのテーマモード(ライトモードやダークモードなど)を管理するためのプロバイダーです。これにより、アプリ全体でテーマモードの状態を一元的に管理できます。

<ThemeModeNotifier, ThemeMode>は、ThemeModeNotifierを使ってThemeMode型のデータを管理することを意味しています。

②初期状態の設定

(ref) => ThemeModeNotifier(ThemeMode.system)で、ThemeMode.systemを初期状態としてThemeModeNotifierに渡します。

3.UIの構築

UIを構築します。
※ホーム画面やボトムナビゲーションバーのコードは割愛しています。

(1)設定画面の作成

こちらの画面を作成します。

全コード
settings_page.dart
import 'package:dark_mode_sample/models/theme_mode.dart';
import 'package:dark_mode_sample/pages/theme_mode_selection_page.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

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

  String _themeModeText(ThemeMode mode) {
    switch (mode) {
      case ThemeMode.light:
        return 'Light';
      case ThemeMode.dark:
        return 'Dark';
      case ThemeMode.system:
      default:
        return 'System';
    }
  }

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final themeModeState = ref.watch(themeModeProvider);
    return ListView(
      children: [
        ListTile(
          leading: const Icon(Icons.lightbulb),
          title: const Text('Dark/Light Mode'),
          trailing: Text(
            (_themeModeText(themeModeState)),
            style: const TextStyle(fontSize: 16),
          ),
          onTap: () => {
            Navigator.of(context).push(
              MaterialPageRoute(
                builder: (context) => const ThemeModeSelectionPage(),
              ),
            ),
          },
        )
      ],
    );
  }
}

①状態の監視
final themeModeState = ref.watch(themeModeProvider);

現在のテーマモードの状態を監視します。これにより、テーマモードが変更されると、ウィジェットが自動的に再構築されます。

②_themeModeTextメソッド
  String _themeModeText(ThemeMode mode) {
    switch (mode) {
      case ThemeMode.light:
        return 'Light';
      case ThemeMode.dark:
        return 'Dark';
      case ThemeMode.system:
      default:
        return 'System';
    }
  }

ThemeModeの値に基づいて、対応する文字列(Light、Dark、System)を返します。

③テーマモードの表示
  trailing: Text(
    (_themeModeText(themeModeState)),
  ),

この画面でref.watchを使用しているのは、ListTileのtrailing部分に現在のテーマモードをユーザーにわかりやすい形で表示するためです。

dark_sample02.gif

(2)選択画面の作成

このページでは、ユーザーがライトモード、ダークモード、またはシステムのデフォルト設定に基づくテーマモードを選択します。

全コード
theme_mode_selection_page.dart
import 'package:dark_mode_sample/models/theme_mode.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

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

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final themeModeState = ref.watch(themeModeProvider);
    final themeModeNotifier = ref.read(themeModeProvider.notifier);
    return Scaffold(
      appBar: AppBar(
        title: const Text('Dark/Light Mode'),
      ),
      body: SafeArea(
        child: Column(
          children: [
            RadioListTile<ThemeMode>(
              value: ThemeMode.system,
              groupValue: themeModeState,
              title: const Text('System'),
              onChanged: (value) {
                if (value != null) {
                  themeModeNotifier.updateThemeMode(value);
                }
              },
            ),
            RadioListTile<ThemeMode>(
              value: ThemeMode.light,
              groupValue: themeModeState,
              title: const Text('Light'),
              onChanged: (value) {
                if (value != null) {
                  themeModeNotifier.updateThemeMode(value);
                }
              },
            ),
            RadioListTile<ThemeMode>(
              value: ThemeMode.dark,
              groupValue: themeModeState,
              title: const Text('Dark'),
              onChanged: (value) {
                if (value != null) {
                  themeModeNotifier.updateThemeMode(value);
                }
              },
            ),
          ],
        ),
      ),
    );
  }
}

①状態の監視と読み取り
final themeModeState = ref.watch(themeModeProvider);
final themeModeNotifier = ref.read(themeModeProvider.notifier);

ref.watch(themeModeProvider)を使用して、現在のテーマモードの状態を監視します。

ref.read(themeModeProvider.notifier)を使用して、テーマモードを更新するためのThemeModeNotifierを取得します。

②テーマモードの変更
RadioListTile<ThemeMode>(
  value: ThemeMode.system,
  groupValue: themeModeState,
  title: const Text('System'),
  onChanged: (value) {
    if (value != null) {
      themeModeNotifier.updateThemeMode(value);
    }
  },
),
RadioListTile<ThemeMode>(
  value: ThemeMode.light,
  groupValue: themeModeState,
  title: const Text('Light'),
  onChanged: (value) {
    if (value != null) {
      themeModeNotifier.updateThemeMode(value);
    }
  },
),
RadioListTile<ThemeMode>(
  value: ThemeMode.dark,
  groupValue: themeModeState,
  title: const Text('Dark'),
  onChanged: (value) {
    if (value != null) {
      themeModeNotifier.updateThemeMode(value);
    }
  },
),

RadioListTileのonChangedコールバックで、選択されたテーマモードをthemeModeNotifier.updateThemeMode(value)を呼び出すことで更新します。

ユーザーが異なるテーマモードを選択すると、ThemeModeNotifierのupdateThemeModeメソッドが呼び出され、新しいテーマモードが設定されます。
この変更はSharedPreferencesを通じて保存され、アプリ全体に反映されます。

(3)main関数

全コード
import 'package:dark_mode_sample/models/theme_mode.dart';
import 'package:dark_mode_sample/pages/main_page.dart';
import 'package:dark_mode_sample/service/preferences_service.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

void main() async {
  // Flutterのウィジェットバインディングの初期化
  WidgetsFlutterBinding.ensureInitialized();
  // 保存されたテーマモードを読み込み初期値に設定
  final initialThemeMode = await PreferencesService.getThemeMode();
  runApp(
    ProviderScope(
      overrides: [
        themeModeProvider.overrideWith(
          (ref) => ThemeModeNotifier(initialThemeMode),
        )
      ],
      child: const MyApp(),
    ),
  );
}

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

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final themeModeState = ref.watch(themeModeProvider);
    return MaterialApp(
      theme: ThemeData.light(),
      darkTheme: ThemeData.dark(),
      themeMode: themeModeState,
      home: const MainPage(),
    );
  }
}
①ウィジェットバインディングの初期化
WidgetsFlutterBinding.ensureInitialized();

WidgetsFlutterBinding.ensureInitialized()は、アプリケーションの起動時にFlutterのバインディングシステムを初期化します。

これにより、ウィジェットツリーの構築やアプリケーションのライフサイクルの管理が行われ、アプリケーションが適切に初期化されます。

バインディングシステムの役割:
バインディングシステムはウィジェットのライフサイクル、イベント処理、画像の読み込みなど、アプリの基本的な機能を管理します。これには、SharedPreferencesのような非同期操作の処理も含まれます。

②SharedPreferencesの読み込み
final initialThemeMode = await PreferencesService.getThemeMode();

アプリが以前に保存したテーマモード(例えば、ライトモードやダークモード)を読み込みinitialThemeModeに定義します。

③プロバイダーの上書き
ProviderScope(
  overrides: [
    themeModeProvider.overrideWith(
      (ref) => ThemeModeNotifier(initialThemeMode),
    )
  ],
  child: const MyApp(),
),

Riverpodでは、アプリの起動時や特定の状況でプロバイダーの値を後から変更できるように、override機能が提供されています。これにより、アプリが以前のユーザー設定を反映させることができます。

ProviderScopeのoverridesプロパティ内でoverrideWithを使用することで、themeModeProviderの初期値をカスタマイズします。

このカスタマイズは、(ref) => ThemeModeNotifier(initialThemeMode)という関数を通じて行われ、アプリのテーマモードを管理するThemeModeNotifierの新しいインスタンスを作成し、その初期状態としてユーザーの以前の設定を設定します。

この方法により、アプリの起動時に特定の初期状態(この場合はテーマモード)を設定することができます。

なぜプロバイダーの再初期化が必要なのか?

例えば、上記のようにProviderScopeのoverridesを使用せず、新たにloadThemeModeメソッドを作成して、ThemeModeNotifierクラスを使用してテーマモードを初期化する場合を考えます。

class ThemeModeNotifier extends StateNotifier<ThemeMode> {
  ThemeModeNotifier(ThemeMode initialMode) : super(initialMode) {
    loadThemeMode();
  }

  Future<void> loadThemeMode() async {
    final mode = await PreferencesService.getThemeMode();
    state = mode;
  }

これだと、アプリの起動時に非同期でSharedPreferencesからテーマモードを読み込む必要があります。そのためアプリが起動してから実際にユーザーの設定が反映されるまでに時間がかかります。

NG例

darkmode_sample2.gif

このことから、ProviderScope内でthemeModeProvider.overrideWithを使用することで、アプリの起動時に直接ユーザーのテーマ設定を反映させることができます。
これにより、アプリが起動するとすぐにユーザーの選択したテーマモードが表示され、一時的なデフォルトテーマの表示を防ぐことができます。

④MyAppクラス
class MyApp extends ConsumerWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final themeModeState = ref.watch(themeModeProvider);
    return MaterialApp(
      theme: ThemeData.light(),
      darkTheme: ThemeData.dark(),
      themeMode: themeModeState,
      home: const MainPage(),
    );
  }
}

ref.watch(themeModeProvider)を使用して、現在のテーマモードの状態を監視します。
これにより、テーマモードが変更されると、アプリの外観が自動的に更新されます。

2
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
0