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 に従いましょう。
今回作りたいのは "System / Dark / Light" の 3 択です。3 択の場合の UI についてはどう作るのが良いのでしょうか?
- 別のページに遷移して、選択して戻ってくる
- モーダルダイアログで選択する
- ドロップダウン
ざっくりこういった候補があります。
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" した時、というのはどういうことでしょうか?
次回はこれについて説明します。