Chapter 07

Step6: 設定を保存してみよう

すぎっと@フルペラットエンジニア
すぎっと@フルペラットエンジニア
2021.12.07に更新

Step6 の概要

Step6 では、SharedPreferences というパッケージを使用して、データを端末に保存します。

本章で学べること

  • ListViewとListTileの使い方
  • SharedPreferences でデータを保存する方法
  • SharedPreferences でデータを取り出す方法

設定画面の作成

設定の切り替えを作ります。
設定は ListView で作っておくと便利です。というより、ListTile にはいくつかの特化したタイプのものがあって、それらが設定画面にはとても便利です。
シンプルな ListTile に加えて 3 種類の ListTile を並べてみました。

class Settings extends StatelessWidget {
  const Settings({Key? key}) : super(key: key);
  
  Widget build(BuildContext context) {
    return ListView(
      children: [
        const ListTile(
          leading: Icon(Icons.lightbulb),
          title: Text('Dark/Light Mode'),
        ),
        SwitchListTile(
          title: const Text('Switch'),
          value: true,
          onChanged: (yes) => {},
        ),
        CheckboxListTile(
          title: const Text('Checkbox'),
          value: true,
          onChanged: (yes) => {},
        ),
        RadioListTile(
          title: const Text('Radio'),
          value: true,
          groupValue: true,
          onChanged: (yes) => {},
        ),
      ],
    );
  }
}

とても便利そうですね。
Radio は 1 つで作っても意味がないので、複数個ならべてグループを作りますが、それ以外の Switch と Checkbox は基本的に Yes/No の設定です。お作法は Material design に従いましょう。

https://material.io/components/switches#usage

今回作りたいのは "System / Dark / Light" の 3 択です。3 択の場合の UI についてはどう作るのが良いのでしょうか?

  1. 別のページに遷移して、選択して戻ってくる
  2. モーダルダイアログで選択する
  3. ドロップダウン

ざっくりこういった候補があります。

Material Design に従おうと思いますが、明確にこうしろという記述が見つけられませんでした。手元の Pixel4 で Android OS の設定画面を触ってみると、およそ 1 か 2 の方法で実装されていましたので、今回は 1 の方法で実装してみましょう。

遷移はとりあえず Navigator.push で実装します。

ListTile(
  leading: const Icon(Icons.lightbulb),
  title: const Text('Dark/Light Mode'),
  onTap: () => {
    Navigator.of(context).push(MaterialPageRoute(
      builder: (context) => const ThemeModeSelectionPage(),
    )),
  },
),

遷移先のページを作ります。以下のように作成しました。

class ThemeModeSelectionPage extends StatelessWidget {
  const ThemeModeSelectionPage({Key? key}) : super(key: key);

  
  Widget build(BuildContext context) {
    return Scaffold(
      body: SafeArea(
        child: Column(
          children: [
            ListTile(
              leading: IconButton(
                icon: const Icon(Icons.arrow_back),
                onPressed: () => Navigator.pop(context),
              ),
            ),
            RadioListTile<ThemeMode>(
              value: ThemeMode.system,
              groupValue: ThemeMode.system,
              title: const Text('System'),
              onChanged: (val) => {},
            ),
            RadioListTile<ThemeMode>(
              value: ThemeMode.dark,
              groupValue: ThemeMode.system,
              title: const Text('Dark'),
              onChanged: (val) => {},
            ),
            RadioListTile<ThemeMode>(
              value: ThemeMode.light,
              groupValue: ThemeMode.system,
              title: const Text('Light'),
              onChanged: (val) => {},
            ),
          ],
        ),
      ),
    );
  }
}

見た目の都合上、AppBarではなくListTileの1つめを戻るボタンの実装のために使っていますが、3つのRadioListTileが本体です。
RadioListTile の groupValue を保持する必要があるので、StatefulWidget にします。

class ThemeModeSelectionPage extends StatefulWidget {
  const ThemeModeSelectionPage({Key? key}) : super(key: key);

  
  _ThemeModeSelectionPageState createState() => _ThemeModeSelectionPageState();
}

class _ThemeModeSelectionPageState extends State<ThemeModeSelectionPage> {
  ThemeMode _current = ThemeMode.system;
  
  Widget build(BuildContext context) {
    return Scaffold(
      // ~~~~ 同じ
    );
  }
}

あとは、setStateを各RatioListTileで使用するだけです。

RadioListTile<ThemeMode>(
  value: ThemeMode.system,
  groupValue: _current,
  title: const Text('System'),
  onChanged: (val) => {setState(() => _current = val!)},
)

これでラジオボタンの状態が管理できます。

設定の状態を共有しよう

ただこのままでは以下のように元のページをみても今どの設定なのかわからないですよね。

Navigator.push & pop で値を受け取ってみましょう。

// Push側, 受け取り
onTap: () async {
            final ret = await Navigator.of(context).push<ThemeMode>(
              MaterialPageRoute(
                builder: (context) => ThemeModeSelectionPage(init: _themeMode),
              ),
            );
          },
// Pop側, 受け渡し
ListTile(
  leading: IconButton(
    icon: const Icon(Icons.arrow_back),
    onPressed: () => Navigator.pop<ThemeMode>(context, ThemeMode.light), // 暫定でlight
  ),
),

push/pop それぞれのデータ型を ThemeMode にすることで ThemeMode の受け渡しができます。pop 側は第二引数に値を、push 側は非同期で受け取ります。

非同期で受け取るには、async, await のセットで値を受け取ります。Future で受け取っておいてあとで解決しても良いですが、async-await のほうがシンプルで良いです。。

受け取った値は Settings 側で表示したいので、こちらも 状態 として管理します。StatefulWidget ですね。

class Settings extends StatefulWidget {
  const Settings({Key? key}) : super(key: key);
  
  _SettingsState createState() => _SettingsState();
}

class _SettingsState extends State<Settings> {
  ThemeMode _themeMode = ThemeMode.system;
  
  Widget build(BuildContext context) {
    return ListView(
      children: [
        ListTile(
          leading: const Icon(Icons.lightbulb),
          title: const Text('Dark/Light Mode'),
          trailing: Text((_themeMode == ThemeMode.system)
              ? 'System'
              : (_themeMode == ThemeMode.dark ? 'Dark' : 'Light')),
          onTap: () async {
            var ret = await Navigator.of(context).push<ThemeMode>(
              MaterialPageRoute(
                builder: (context) => ThemeModeSelectionPage(init: _themeMode),
              ),
            );
            setState(() => _themeMode = ret!);
          },
        ),
      ],
    );
  }
}

_themeMode という風にアンダースコアをつけると、この変数は Private ですよ!ここでしか参照しないですよ!という意味になります。結構大切です。

trailing にモードに合わせてテキストを表示しています。これは関数に取り出しても良いのですが、ちょっとサボりました。これで以下のように表示されるようになります。

あとは、ラジオボタン側で行った変更をきっちり返すようにすれば OK です。

つまり、ラジオボタン側も StatefulWidget にして、ラジオボタンの状態を保持し、画面遷移でその値を戻すようにします。また、メニューの初期表示は Settings から受け取ります。

class ThemeModeSelectionPage extends StatefulWidget {
  const ThemeModeSelectionPage({
    Key? key,
    required this.mode,
  }) : super(key: key);
  final ThemeMode mode;

  
  _ThemeModeSelectionPageState createState() => _ThemeModeSelectionPageState();
}

class _ThemeModeSelectionPageState extends State<ThemeModeSelectionPage> {
  late ThemeMode _current;
  
  void initState() {
    super.initState();
    _current = widget.mode;
  }

  
  Widget build(BuildContext context) {
    return Scaffold(
      body: SafeArea(
        child: Column(
          children: [
            ListTile(
              leading: IconButton(
                icon: const Icon(Icons.arrow_back),
                onPressed: () => Navigator.pop<ThemeMode>(context, _current),
              ),
            ),
            RadioListTile<ThemeMode>(
              value: ThemeMode.system,
              groupValue: _current,
              title: const Text('System'),
              onChanged: (val) => {setState(() => _current = val!)},
            ),
          // Radioいくつかつづきます・・・・
          ],
        ),
      ),
    );
  }
}

受け取るところの実装は以下のようになっています。

  const ThemeModeSelectionPage({
    Key? key,
    required this.mode,
  }) : super(key: key);
  final ThemeMode mode;

ThemeModeSelectionPage({ ... }) と、なみかっこ{}の中に入っています。この場合、Optional なパラメーターという扱いになり、Widget を使用する側は名前付きで値を渡す必要があります。一方で {} をつけずに行うと、必須のパラメーターになり、名前は不要で順番が大切になります。実装のしやすさとしては名前付きかな、と思いますので、積極的に名前付きで実装します。名前付きにするには Optional にせざるを得ないので、名前付きの必須のパラメーターの場合は required オプションを追加します。

requiredをつけない場合は以下のような実装になりますが、これは問題があります。

  const ThemeModeSelectionPage({
    Key? key,
    this.mode,
  }) : super(key: key);
  final ThemeMode mode;

この場合はmodeのデフォルト値がnullになってしまうので、null safetyを有効にしている場合は ThemeMode mode は非nullなので、そのような警告がでます。

この場合は、ThemeMode? mode として null を許容してあげつつ、 mode == null だった場合の値を別途定義してあげることで対応できます。

Null Safety については別冊で少し説明をしようと思います。

  const ThemeModeSelectionPage({
    Key? key,
    this.mode,
  }) : super(key: key);
  final ThemeMode? mode;

Optionalなパラメーターにしない場合は以下のように {} の外側に指定します。

const ThemeModeSelectionPage(
  this.mode, {
  Key? key,
}) : super(key: key);
final ThemeMode mode;

の場合、呼び出す側は ThemeModeSelectionPage(mode) のように、名前なしで与えることになります。
パラメータが増えたときは順番で解決されるので、実装時の楽さを考えると、Optional + required が個人的にはお勧めです。

次に初期化処理です。

class _ThemeModeSelectionPageState extends State<ThemeModeSelectionPage> {
  late ThemeMode _current;
  
  void initState() {
    super.initState();
    _current = widget.mode;
  }
  // ~~~~~
}

この部分ですね。

StatefulWidgetで受け取った値を State側で使う場合は widget.xxxx という形式で指定します。

ただし、この値の参照を1行目で実行するとエラーになります。

class _ThemeModeSelectionPageState extends State<ThemeModeSelectionPage> {
  ThemeMode _current = widget.mode // これはエラー
  
  //....

widgetを使いたい場合は initState をオーバーライドして使用します。このとき、 ThemeModeの宣言よりあとで初期化されることになりますので、lateをつけて "このあとですぐに初期化します!!" とお約束します。nullableにはしたくないので。

このinitStateですが、要は createStateが呼び出されたときに実行されます。従って、StatefulWidget ⇒ State の順で値がセットされていることになります。initState外で指定した場合は createStateに関係なく実行されるので、「widget?? 無いよそんなの。」となるわけです。

これでメニューの実装ができました。

SharedPreferencesで永続化しよう

次は永続化です。

設定はアプリの次回起動時も同じままにしないといけません。

この設定は端末固有でOKとするのであれば、サクッと端末内に保存しておきましょう。(アカウントを作成し、サーバー側で管理する方法もあります。Webサービスのような形式です。)

端末内にシンプルな値を保存するには SharedPreferencesを使用します。

shared_preferences | Flutter Package

SharedPreferencesはKey-Value-Store (KVS) と呼ばれる形式でデータを保持する手段です。データは端末内にアプリ固有の値として保存されます。

書き込みも読み込みもどちらも "key" を唯一の手がかりにしますので、Keyの管理が大切です。typoひとつで動作しなくなります。

Shared Preferences の使い方はシンプルで、

final pref = await SharedPreferences.getInstance();
pref.setInt('key', value);

こんな感じで書き込んで、

final pref = await SharedPreferences.getInstance();
final val = ref.getInt('key') ?? 0;

こんな感じで取り出します。

getInt, getString, getBool, getDoubleなどが使えます。

ThemeModeはSystem, Dark, Lightといったenumで構成されていますので、このまま保存することはできませんので、よしなに変換をかけながら保存、読み込みする必要があります。

  • intに変換して相互コンバーターを実装
  • enum - string 変換ライブラリを使う(または自作する)
  • extensionでgetter, setter として実装

いろいろやり方があります。より良い書き方、より安全な書き方というのはあるのですが、よくわからないままに実装するくらいなら自分の分かる範疇でコードは書きましょう。もちろん、少しづつチャレンジはした方が良いです。

では、超愚直に書いてみます。

import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';

int modeToVal(ThemeMode mode) {
  switch (mode) {
    case ThemeMode.system:
      return 1;
    case ThemeMode.dark:
      return 2;
    case ThemeMode.light:
      return 3;
    default:
      return 0;
  }
}

ThemeMode valToMode(int val) {
  switch (val) {
    case 1:
      return ThemeMode.system;
    case 2:
      return ThemeMode.dark;
    case 3:
      return ThemeMode.light;
    default:
      return ThemeMode.system;
  }
}

Future<void> saveThemeMode(ThemeMode mode) async {
  final pref = await SharedPreferences.getInstance();
  pref.setInt('theme_mode', modeToVal(mode));
}

Future<ThemeMode> loadThemeMode() async {
  final pref = await SharedPreferences.getInstance();
  final ret = valToMode(pref.getInt('theme_mode') ?? 0);
  return ret;
}

int変換を愚直にswitchで書いて、読み込みと書き込みをメソッド化しておきます。このメソッドをつかう限りはまぁなんやかんや動きます。最初はこれくらいざっくりでもいいのではないかと思います。

  • Keyの文字列打ち間違えたらアウトやん
  • Intの変換ざっつい

というポイントを改善するとこんな風にも書けます。

Future<void> saveThemeMode(ThemeMode mode) async {
  final pref = await SharedPreferences.getInstance();
  pref.setString(mode.key, mode.name);
}

Future<ThemeMode> loadThemeMode() async {
  final pref = await SharedPreferences.getInstance();
  return toMode(pref.getString(defaultTheme.key) ?? defaultTheme.name);
}

ThemeMode toMode(String str) {
  return ThemeMode.values.where((val) => val.name == str).first;
}

extension ThemeModeEx on ThemeMode {
  String get key => toString().split('.').first;
  String get name => toString().split('.').last;
}

defaultThemeのところがやっつけ感ありますが、まぁEnumそのまま書き込むならそこそこスッキリしたでしょう。もっといい書き方はきっとありますので模索してみてください。

では、本題に戻って save と load を使います。

まずは Settings の ListView の初期化時に設定をロードします。Navigatorの返す値をそのまま保存すれば save & load の完成です。

class _SettingsState extends State<Settings> {
  ThemeMode _themeMode = ThemeMode.system;

  
  void initState() {
    super.initState();
    loadThemeMode().then((val) => setState(() => _themeMode = val));
  }

  
  Widget build(BuildContext context) {
    return ListView(
      children: [
        ListTile(
          leading: const Icon(Icons.lightbulb),
          title: const Text('Dark/Light Mode'),
          trailing: Text((_themeMode == ThemeMode.system)
              ? 'System'
              : (_themeMode == ThemeMode.dark ? 'Dark' : 'Light')),
          onTap: () async {
            var ret = await Navigator.of(context).push<ThemeMode>(
              MaterialPageRoute(
                builder: (context) => ThemeModeSelectionPage(init: _themeMode),
              ),
            );
            setState(() => _themeMode = ret!);
            await saveThemeMode(_themeMode);
          },
        ),
      ],
    );     
  }
}

設定はできましたが、アプリの見た目は変わりません。帰るためにはMaterialAppのThemeに反映する必要があります。

以下のようにこちらでも初期化時にロードしてみました。

class MyApp extends StatefulWidget {
  const MyApp({Key? key}) : super(key: key);
  
  _MyAppState createState() => _MyAppState();
}

class _MyAppState extends State<MyApp> {
  ThemeMode themeMode = ThemeMode.system;

  
  void initState() {
    super.initState();
    loadThemeMode().then((val) => setState(() => themeMode = val));
  }

  
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Pokemon Flutter',
      theme: ThemeData.light(),
      darkTheme: ThemeData.dark(),
      themeMode: themeMode,
      home: const TopPage(),
    );
  }
}

これによって、ThemeModeの設定が HotRestart した時に反映されるようになります。

"HotRestart" した時、というのはどういうことでしょうか?

次回はこれについて説明します。

FlutterのWidgetはその内容を更新する必要がなければ再ビルドは実行されません。今回は Settings Widget で行われた変更は MyApp Widget からすれば全く関係のないことですので、MyApp が更新されることはなく、テーマが更新されることはありません。あくまで、Shared Preferencesという形でアプリの外の永続化されたデータが更新されただけです。

Settingsでの変更を即座にMyAppに適用するには以下の方法があります。

  • 共通の State にする
  • 変更を検知する仕組みを導入する (Notifier)

共通の State にするには StatefulWidget の State をバケツリレーすることになります。値をセットするなら、setStateをラップしたSetterも一緒にバケツリレーすることになります。

2~3階層を超えるとちょっと耐えられない実装になってくるのでこれはやめたいですね。

この煩わしさに出会った時がStatefulWidget以外の手法を学ぶべきタイミングです。

StatefulWidgetの次なので、InheritedWidget あたりを触りたい気持ちもあるのですが、InheritedWidgetを直接触ることはもう2度とないのではないかと思っているので、大人しく Provider を使用します。Provider は Flutter official 推奨ですので、とりあえずこれでいいかな、という感じです。(もちろん他にもいいライブラリはたくさんあります)

provider | Flutter Package

  • provider
  • changenotifier

のセットで対応します。

状態管理の選定についても別冊で簡単に触れようと思います。よければ参照して見てください。