Chapter 07

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

sugit
sugit
2023.05.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 という形式で指定します。

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

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" した時、というのはどういうことでしょうか?

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