【Flutter】GetXの世界3 ~Todoアプリを作ってみよう後編~
後編
はじめに
この記事は以下の記事の続きです。
GetXの世界3 ~Todoアプリを作ってみよう前編~
Todoアプリの完成例
GetXとSharedPreferencesの二つのライブラリのみを使用して、「データを保存可能で、テーマや言語を切り替えることができ、動的URLに対応したTodoウェブアプリ」を一から作ってみるシリーズの後編です。(間が空いてしまい、すみません💦)
前編ではタスクの追加・更新・削除といった基本の機能を搭載しました。この後編では最終的な仕上げとしてSharedPreferencesへのデータ保存、テーマやロケールの変更管理機能等を搭載しつつ、これまで登場していないGetXの機能を紹介したいと思います。
(この記事で紹介するGetXの機能)
- GetMaterialAppによるロケールやテーマの管理方法(Get.updateLocale、Get.changeThemeMode)
- Translationsクラスを使った翻訳データの作成方法
- Bindingsで依存オブジェクトを束ねてアプリ全体、あるいはRouteに注入する方法
- Get.lazyPutで依存オブジェクトを遅延ロードする方法
- Workerを使用してSharePreferencesにデータを自動保存する方法
- GetMaterialApp.unknownRouteを使って404画面を出す方法
- GetMiddlewareを使って存在しないIDを含むURLを表示した場合にホーム画面にリダイレクトする方法
前回の振り返り
前回の記事ではデモアプリで実現したい機能として以下の項目を挙げました。
(取り消し線は機能搭載済み。現段階でのコードはこちらで閲覧可能です。)
タスク一覧が表示できるタスクが新規追加できるタスクが更新できるタスクが削除できる完了タスクを一括削除できる- タスク一覧をフィルタできる(完了タスクを隠す)
- 未完了タスクの数をリアルタイムで確認できる
- アプリ全体のカラーテーマを変更できる
- アプリ全体のロケールを変更できる(日英の切り替え)
- タスクデータ、ロケール、テーマ等の設定を保存/ロードできる
- タスク詳細ページごとに動的リンク生成、ディープリンク可
- 存在しないリンクは専用画面(404)に誘導できる
一つ一つ片付けていきましょう。
タスク一覧をフィルタして完了タスクを隠す
前回タスク一覧を表示するウィジェット「todo_list.dart」においてtodoController.todos
からタスクデータを取得しました。
これはTodoController
のゲッターで、RxListである_todos
から全タスクを取得するものでした。
class TodoController extends GetxController {
final _todos = <Todo>[].obs;
List<Todo> get todos => _todos;
// 省略
このゲッターをフィルタの状態(完了タスクを隠す、全表示する)に合わせて、タスク内容を切り替えれば機能が実現できそうですね。ひとまず、未完了タスクのみを返す実装に変更してみます。
// 省略
List<Todo> get todos {
+ return _todos.where((todo) => todo.done == false).toList();
}
// 省略
次に、「現在アプリは "完了タスクを隠すモード" なのか、"全表示するモード" なのか」を判別する設定情報を加えて返す値を場合分けしなくてはなりません。
この設定情報はもう一つ「FilterController」なるGetxController
を作成して管理することにしましょう。設定情報の管理にはRxBool
型(boolのRx版)を使用します。
class FilterController extends GetxController {
final RxBool _hideDone = false.obs; // 初期値
bool get hideDone => _hideDone.value;
void toggleHide() {
_hideDone.toggle();
}
}
ここでユーザーが設定情報の変更イベントを送信したとき(ボタンクリック)のアクションtoggleHide
も準備しておきます。このメソッドでフィルタの状態(_hideDone
)をトグルします。
RxBool.toggleはboolにはないRx型特有のメソッドで、名前の通り true/false をトグルしてくれます。
それではTodoController
に戻ってFilterController
が持つ情報を元にタスク内容を変更してみましょう。他のコントローラーのインスタンスを探すにはGet.find
でしたね。
// 省略
List<Todo> get todos {
+ final hideDone = Get.find<FilterController>().hideDone;
+ if (hideDone) {
return _todos.where((todo) => todo.done == false).toList();
+ } else {
+ return _todos;
+ }
}
// 省略
ここでGet.find
を使ってFilterController
のインスタンスを探していますが、その前にインスタンスが立ち上がっている必要があるので、HomePage
クラスでGet.put
を使用して立ち上げておきます(立ち上げる場所は後で変更するので仮置き)。
class HomePage extends StatelessWidget {
HomePage({Key? key}) : super(key: key);
final todoController = Get.put(TodoController());
+ final filterController = Get.put(FilterController());
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
centerTitle: true,
title: const Text('タイトル(仮)'), // TODO:未完了タスクの数を表示
leading: IconButton(
icon: const Icon(Icons.filter_alt_outlined), // TODO:フィルタ有無の状態をアイコンに反映
+ onPressed: filterController.toggleHide,
),
これでボタンを押すたびにタスクリストの内容が入れ替わります(完了タスクがあれば)。ついでに、ユーザーにフィルタの状態を知らせるためにIconButton
の見た目もFilterController.hideDone
の状態に応じて変化するようにしておきましょう。
leading: IconButton(
+ icon: Obx(() => Icon(
+ filterController.hideDone
+ ? Icons.filter_alt
+ : Icons.filter_alt_outlined,
+ )),
onPressed: filterController.toggleHide,
),
タスク一覧をフィルタして完了タスクを隠す
いい感じですが、、このGIF動画少し違和感がありませんか?「犬の散歩」タスクが隠れたその瞬間、次のタスクが 一瞬だけ「完了状態」 になってしまっています。
これはTodoController
から新たにタスクリストのデータが渡されて画面が更新される途中で、Flutterが既存リストのトップにあるTodoTile
のエレメントを、次の画面のトップに来るウィジェットに紐付けてしまうため起こっています。
これを解消するにはTodoTile
ウィジェットにKeyを設定してFlutterがウィジェットとエレメントを正しく紐付けできるようにしてあげます(参考)。
return ListView.builder(
itemCount: todos.length,
itemBuilder: (context, index) {
final todo = todos[index];
+ return TodoTile(key: Key(todo.id), todo: todo);
},
);
(Key() はValueKey<String>を生成するためのfactoryコンストラクタです)
Keyを設定後
正しく描画されました🎉
未完了タスクの数をリアルタイムで確認できる
これについてはTodoController
に「_todos
の内容を元にタスク数を返すゲッター」を作成し、それをビュー側でObx()
ウィジェットに「消費」させれば実現できそうです。
class TodoController extends GetxController {
final _todos = <Todo>[].obs;
// 省略
+ int get countUndone {
+ return _todos.fold<int>(0, (acc, todo) {
+ if (!todo.done) {
+ acc++;
+ }
+ return acc;
+ });
+ }
// 省略
class HomePage extends StatelessWidget {
// 省略
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
centerTitle: true,
+ title: Obx(
+ () => Text('タイトル' ' (${todoController.countUndone})'),
+ ),
// 省略
未完了タスクの数をリアルタイムで確認
アプリ全体のカラーテーマを変更できる
アプリのテーマ(ThemeData)はGetMaterialApp
が管理するルート(root)のコントローラーであるGetMaterialControllerによって「状態」が管理されています。
またあらかじめ状態を操作・取得するためのメソッドが準備されており、アプリ実行中に現在のテーマを変更するには Get.changeThemeMode()、ダークモードが否かを取得するには Get.isDarkMode を使用するだけです。
UIクラスの中でこれらを直接利用してもいいのですが、今回はテーマをSharePreferencesに保存・読み出しする必要があるためFilterController
と同様にコントローラーを立てたいと思います。
必要なのは 「現在のテーマがダークモードか否かをSharedPreferencesから取得するメソッド」 と 「ダーク/ライトテーマをトグルしつつSharedPreferencesに設定を保存するメソッド」 です。
このコントローラーでは状態は管理しないのでRx
オブジェクトを作る必要はなく、GetxControllerの機能を使わない&GetXによって管理される必要性がないので素のクラスとなります。
class ThemeController {
ThemeMode getThemeMode() {
// TODO:SharedPreferencesの保存データを読み込んで返す、なければシステムのテーマを返す
return ThemeMode.system;
}
void changeTheme() {
if (Get.isDarkMode) {
Get.changeThemeMode(ThemeMode.light);
} else {
Get.changeThemeMode(ThemeMode.dark);
}
// TODO:SharedPreferencesに設定を保存する
}
}
SharedPreferencesの部分は後の課題として「TODO:」にしておきます。getThemeMode
ではとりあえずデバイスのシステム設定を返しています。
またテーマのThemeMode情報はGetMaterialApp(MaterialApp)が起動する前に必要になるので、MyApp
にてあらかじめインスタンスを立ち上げておく必要があります。
class MyApp extends StatelessWidget {
MyApp({Key? key}) : super(key: key);
+ final themeController = Get.put(ThemeController());
Widget build(BuildContext context) {
return GetMaterialApp(
debugShowCheckedModeBanner: false, // 今回右上が被るので
theme: AppTheme.light,
darkTheme: AppTheme.dark,
+ themeMode: themeController.getThemeMode(),
MyApp
で立ち上げたインスタンスをHomePage
で利用してIconButton
にメソッドを仕込みます。
class HomePage extends StatelessWidget {
HomePage({Key? key}) : super(key: key);
final todoController = Get.put(TodoController());
final filterController = Get.put(FilterController());
+ final themeController = Get.find<ThemeController>();
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
// 省略
actions: [
IconButton(
icon: const Icon(Icons.language),
onPressed: () {}, // TODO:ロケール変更の処理を呼び出す
),
IconButton(
icon: const Icon(Icons.color_lens),
+ onPressed: themeController.changeTheme,
),
],
),
// 省略
アプリ全体のカラーテーマを変更
できました🎉 ちなみに、アニメーションが自動でかかるのはMaterialApp
によってウィジェットツリーにAnimatedThemeが挿入されるためです。
アプリ全体のロケールを変更できる
ロケールについてもテーマと同様の流れですが、テーマと異なり翻訳データをまだ準備していなかったためここで作成します。
実はGetXには翻訳データを管理する仕組みも備わっており、GetX独自のTranslationsクラスを継承して翻訳データを設定するだけで簡単に多言語に対応させることができます(もちろん、Flutter公式に記載のやり方を採用しても問題ないです)。
Translationsファイルの作成例
class AppTranslations extends Translations {
static const jaJP = Locale('ja', 'JP');
static const enUS = Locale('en', 'US');
Map<String, Map<String, String>> get keys {
return {
jaJP.toString(): {
// ←中間言語:翻訳→
'title': 'ゲットエックスやることアプリデモ',
'delete_done': '完了削除',
'add_new': '新規追加',
'cancel': 'キャンセル',
'add': '追加',
'update': '更新',
'confirm_delete': 'ほんとに消しますか?',
'yes': 'はい',
'no': 'いいえ',
'page_not_found': 'ページが見つかりません',
'to_home': 'ホームへ',
'new_todo': '新規',
'your_plan': 'あなたが今やりたいことは?',
},
enUS.toString(): {
'title': 'GetX Todo App Demo',
'delete_done': 'Remove Done',
'add_new': 'Add New',
'cancel': 'Cancel',
'add': 'Add',
'update': 'Update',
'confirm_delete': 'Are you sure?',
'yes': 'Yes',
'no': 'NO',
'page_not_found': 'Page Not Found',
'to_home': 'To Home',
'new_todo': 'New',
'your_plan': 'What is your plan today?',
},
};
}
}
Translations
を継承したら、keys
メソッドをoverrideして<1.String, 2.Map>からなるMapデータを設定します。1.には 「言語_国」の文字列、2.には 「中間言語:翻訳」のMap が入ります(上記参照)。
ちなみにLocale
クラスはtoString()
すると「言語_国」の形式になるので 1.でそのまま使うことができます。
「中間言語」はUI側のクラスに設定するString情報になります。このStringに.tr
とエクステンションを付けることで、対応する言語のテキストに置き換わる仕組みです。たとえば、、
Text('title'.tr),
とすれば、Text
が日本語設定のときは「ゲットエックスやることアプリデモ」となり、英語設定のときは「GetX Todo App Demo」になります。
ロケール情報はテーマと同様にルートのGetMaterialController
によって管理されています。アプリのロケールを変更するには Get.updateLocale()、デバイスのロケール設定を取得するには Get.deviceLocale です。
後にSharedPreferencesで保存・読み込みを行う処理を書きたいので、前項と同様にコントローラーを作成しておきます。
class LocaleController {
Locale? getLocale() {
// TODO:SharedPreferencesの保存データを読み込んで返す、なければデバイスの言語を返す
return Get.deviceLocale;
}
void changeLocale() {
if (Get.locale == AppTranslations.jaJP) {
Get.updateLocale(AppTranslations.enUS);
} else {
Get.updateLocale(AppTranslations.jaJP);
}
// TODO:SharedPreferencesに設定を保存する
}
}
同様にMyAppでインスタンスを立ち上げて、GetMaterialApp
で各種設定をします。
class MyApp extends StatelessWidget {
MyApp({Key? key}) : super(key: key);
final themeController = Get.put(ThemeController());
+ final localeController = Get.put(LocaleController());
Widget build(BuildContext context) {
return GetMaterialApp(
debugShowCheckedModeBanner: false, // 今回右上が被るので
+ translations: AppTranslations(),
+ locale: localeController.getLocale(),
+ fallbackLocale: AppTranslations.jaJP,
// 省略
先ほど作成した翻訳データのAppTranslations
クラスはGetMaterialApp
独自のプロパティであるtranslations
でインスタンスを指定します。
fallbackLocale
もGetMaterialApp
独自のプロパティです。その名の通り、アプリがデバイスの言語で表示できない場合に採用する言語です。
以上の一連の設定を噛み砕いた日本語にすると、「アプリのテキストを何語で表示するかの情報をSharedPreferencesから引っ張ってきてね。データがなければデバイスの言語にしてね。その言語にアプリが対応してなければとりあえず日本語で表示してね」 🐓となります。
あとはHomePage
のactions
ボタンにLocaleController.changeLocale
メソッドを仕込めば完成です。
class HomePage extends StatelessWidget {
HomePage({Key? key}) : super(key: key);
final todoController = Get.put(TodoController());
final filterController = Get.put(FilterController());
+ final localeController = Get.find<LocaleController>();
final themeController = Get.find<ThemeController>();
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
// 省略
actions: [
IconButton(
icon: const Icon(Icons.language),
+ onPressed: localeController.changeLocale,
),
// 省略
アプリ全体のロケールを変更
タスクデータ、ロケール、テーマ等の設定を保存/ロードできる
次にこのアプリの肝である、タスクリストや設定の状態をSharedPreferencesを通じて保存/ロードする機能を実装していきます。
いろいろなやり方があるかと思いますが、ここではSharedPreferencesを操作するクラスを設定の種類別に分けて(TodoStorage
FilterStorage
など)、storage_service.dart
というファイルにまとめています。
SharedPreferencesを利用してデータやりとりを行うサービスクラスの例
import 'package:shared_preferences/shared_preferences.dart';
abstract class StorageService { // インスタンス化しないのでabstract
static late final SharedPreferences instance;
static Future<void> init() async {
instance = await SharedPreferences.getInstance();
}
}
class TodoStorage {
final String _key = 'todo';
List<String>? load() {
return StorageService.instance.getStringList(_key);
}
void save(List<String> data) {
StorageService.instance.setStringList(_key, data);
}
}
class FilterStorage {
final String _key = 'filter';
bool? load() {
return StorageService.instance.getBool(_key);
}
void save(bool data) {
StorageService.instance.setBool(_key, data);
}
}
class LocaleStorage {
final String _key = 'language';
String? load() {
return StorageService.instance.getString(_key);
}
void save(String data) {
StorageService.instance.setString(_key, data);
}
}
class ThemeStorage {
final String _key = 'theme';
bool? load() {
return StorageService.instance.getBool(_key);
}
void save(bool data) {
StorageService.instance.setBool(_key, data);
}
}
頭のStorageService
クラスは、続く各クラスでSharedPreferencesを操作するために、そのインスタンスを取得しておくためのクラスです。アプリが立ち上がる前(データの保存/読み込みを行う前)にinit()
しておきたいので、runApp()
の手前で実行します。
+void main() async {
+ WidgetsFlutterBinding.ensureInitialized();
+ await StorageService.init();
runApp(MyApp());
}
それではこれらのload()
save()
メソッドを使って実際にSharePreferencesを通じて保存/読み込みを行う機能を使ってみましょう。
テーマ設定の保存/読み込み
まずはテーマ設定から。少し前にThemeController
にテーマを取得するためのgetThemeMode()
、テーマを変更するためのchangeTheme()
メソッドを作成しましたが、これらにload()
save()
を組み込みます。
class ThemeController {
+ final _storage = ThemeStorage(); // 1.
ThemeMode getThemeMode() {
+ final isDark = _storage.load(); // 2.
+ if (isDark != null) { // 3.
+ return isDark ? ThemeMode.dark : ThemeMode.light;
+ } else { // 4.
return ThemeMode.system;
+ }
}
void changeTheme() {
if (Get.isDarkMode) {
Get.changeThemeMode(ThemeMode.light);
} else {
Get.changeThemeMode(ThemeMode.dark);
}
+ _storage.save(!Get.isDarkMode); // 5.
}
}
-
ThemeStorage
のインスタンスを立ち上げて、 -
getThemeMode()
が呼ばれたタイミングでSharedPreferencesから保存されているデータをロードします。 - データが存在する場合はそのデータの値(true/false)に合わせた
ThemeMode
を返し、 - 初回起動時などのためデータがない場合は
ThemeMode.system
を返します。 - そして
changeTheme()
が呼ばれて変更が終わったタイミングで、ダークモードか否かの現在値をSharedPreferencesに保存します。
ロケール設定の保存/読み込み
次に、ロケール設定です。
+extension on String { // 3.
+ Locale get toLocale {
+ final split = this.split('_');
+ return Locale(split.first, split.last);
+ }
+}
class LocaleController {
+ final _storage = LocaleStorage(); // 1.
Locale? getLocale() {
+ final localeCode = _storage.load(); // 2.
+ if (localeCode != null) { // 3.
+ return localeCode.toLocale;
+ } else { // 4.
return Get.deviceLocale;
+ }
}
void changeLocale() {
if (Get.locale == AppTranslations.jaJP) {
Get.updateLocale(AppTranslations.enUS);
} else {
Get.updateLocale(AppTranslations.jaJP);
}
+ _storage.save(Get.locale.toString()); // 5.
}
}
-
LocaleStorage
のインスタンスを立ち上げて、 -
getLocale()
が呼ばれたタイミングでSharedPreferencesから保存されているデータをロードします。 -
データが存在する場合はそのデータをLocaleに変換して返し、
-
初回起動時などのためデータがない場合は
Get.deviceLocale
を返します。(デバイスのロケールが日本語でも英語でもなければGetMaterialApp
で設定した通り、日本語で強制表示) -
そして
changeLocale()
が呼ばれて変更が終わったタイミングで、現在の表示ロケール(Get.locale
=Locale
オブジェクト)をtoString()
で文字列にし、SharedPreferencesに保存します。 -
で一度文字列に変換している理由は、SharedPreferencesでは
Locale
オブジェクトをそのまま保存データとして扱えないためです。String
に変換して保存し、そして読み込み時にその文字列を再びLocale
オブジェクトに戻す必要があります。
取得した文字列からLocale
オブジェクトを復元しているのが 3. です。言語コードの文字列localeCode
(日本語ならja_JP
の形式)をtoLocale
という拡張メソッドを使ってLocaleオブジェクトに戻しています。この拡張メソッドのコードは上記の通りで、_
(アンダーバー)で文字列を分割し、それらをLocaleインスタンスの要素としています。
フィルタ設定の保存/読み込み
次にFilterController
に保存/読み込み機能を実装します。FilterController
はGetxController
なので実装方法が少し異なります。以前登場したonInit()
やRxオブジェクトを監視してくれるWorker
をうまく活用しましょう。
class FilterController extends GetxController {
final _hideDone = false.obs;
bool get hideDone => _hideDone.value;
+ final _storage = FilterStorage(); // 1.
+ late final Worker _worker; // 2.
void toggleHide() {
_hideDone.toggle();
}
+
+ void onInit() {
+ super.onInit();
+ _hideDone.value = _storage.load() ?? false; // 3.
+ _worker = ever<bool>(_hideDone, (value) { // 4.
+ _storage.save(value);
+ });
+ }
+ // 5.
+ void onClose() {
+ _worker.dispose();
+ super.onClose();
+ }
+}
- サービスクラスである
FilterStorage
をインスタンス化し、load()``save()
メソッドが使えるようにしておきます。 - Worker用の変数を宣言。
-
FilterController
が立ち上がったらonInit()
が実行されるので、この中でSharedPreferencesからフィルタ設定をロードし、_hideDone.value
に代入します(データがない場合はfalse
を代入)。 - Workerの
ever()
メソッドを使って、Rxオブジェクト_hideDone
を監視し、値に変化がある度に「SharedPreferencesを通じて現在値を保存する」コールバックを登録します。 -
FilterController
がメモリから破棄されると同時に、ここで登録したWorkerも破棄されるように設定。
タスクリストの保存/読み込み
最後に、TaskController
にも機能を実装します。FilterController
の場合と流れは同じです。
class TodoController extends GetxController {
final _todos = <Todo>[].obs;
+ final _storage = TodoStorage(); // 1.
+ late final Worker _worker; // 2.
void onInit() {
super.onInit();
+ final storageTodos = // 3.
+ _storage.load()?.map((json) => Todo.fromJson(json)).toList();
+ final initialTodos = storageTodos ?? Todo.initialTodos; // 4.
+ _todos.addAll(initialTodos);
// _todosに変化がある度にストレージに保存
+ _worker = ever<List<Todo>>(_todos, (todos) { // 5.
+ final data = todos.map((e) => e.toJson()).toList();
+ _storage.save(data);
+ });
}
+
+ void onClose() {
+ _worker.dispose();
+ super.onClose();
+ }
// 省略
-
サービスクラスである
TodoStorage
をインスタンス化し、load()``save()
メソッドが使えるようにしておきます。 -
Worker用の変数を宣言。
-
TodoController
が立ち上がったらonInit()
が実行されるので、その時にSharedPreferencesからList\<String>
であるタスクリストをロードし、それぞれのString
をTodo
オブジェクトに変換。 -
そしてそれを
initialTodos
に代入(データがない場合はサンプルタスクを代入)。 -
Workerの
ever()
メソッドを使って、Rxオブジェクト_todos
を監視し、内容に変化がある度に「SharedPreferencesを通じて現在の状態を保存する」コールバックを登録します。 -
TodoController
がメモリから破棄されると同時に、ここで登録したWorkerも破棄されるように設定。 -
でタスクリストの状態を保存する際に、
Todo
オブジェクトをJSON文字列に変換しています。これはSharedPreferencesではTodo
はそのまま扱えず、代替としてString
やList\<String>
にする必要があるためです。JSON文字列への変換には以前Todo
モデルで作成したtoJson()
メソッドを利用しています。 -
でJSON文字列を
Todo
に戻す際にも、Todoモデル
のfromJson()
メソッドを利用しています。
これでSharedPreferencesを使ってデータの読み書きを行う機能を搭載することができました🎉
ブラウザでデバッグして設定を変更し、「更新」ボタンを押しても設定が初期状態に戻らないなら成功です。
タスクデータ、ロケール、テーマ等の設定を保存/ロードできる
タスク詳細ページごとに動的リンク生成、ディープリンク可
タスクごとの「動的リンク」については、すでに前編で目的を達成しているので、この項では「ディープリンク」に関する機能を追加します。
現時点でのコードでタスク詳細ページに飛ぶと、このような画面が出て、URLにもタスクIDが反映されるような状態かと思います。
一つ目のタスク詳細ページ
しかし、ここで cmd/ctrl + R などでブラウザを「更新」すると、、
TodoControllerが見つからないエラー
とこのように「TodoControllerが見つかりません」というエラーが出てしまいます。
これは「更新」によってアプリが再起動され、現在のURL「todo/0」に紐づくページAddTodoPage
をアプリが表示しようとするのですが、ページ内で使われているGet.find<TodoController>()
のコントローラーがGet.put()
でインスタンス化された形跡がないため発生したエラーです。
このTodoController
は現在のコードだとHomePage
クラスでGet.put()
されているため、HomePage
経由でAddTodoPage
へ行かないと正しく描画できないのです。この状況を図で表すと下記の通りになります。(赤い矢印が今回たどったルート)
TodoControllerインスタンスがないのでエラー
これを解消するには、図中央のGetMaterialApp
にコントローラーのインスタンスを紐づけて、どのルートをたどってもTodoController
が使えるようにする必要があります。
TodoControllerインスタンスがアプリ自体に紐づいている
このように、ある依存オブジェクトをアプリ自体やRouteに紐づける仕組みをGetXではBindings(結合、結束)を使って実現することができます。
Bindings と Get.lazyPut
使い方は、以下の通りです。
class AppBinding extends Bindings {
void dependencies() {
Get.lazyPut(() => TodoController());
Get.lazyPut(() => FilterController());
}
}
Bindings
クラスを継承したクラスを作成して、dependencies()
メソッドをoverrideします。その中で通常のクラスで行うような依存注入(Get.put()
)のコードを書きます。
ここではGet.lazyPut()
としていますが、これは通常のGet.put()
と異なり、対象のオブジェクトが最初に使われるときになって初めて、それをインスタンス化してくれるものです。いわゆる「遅延ロード」です。
アプリ自体に依存注入する場合は、そのオブジェクトがどのタイミングで使用されるかが分からないため、メモリ管理の観点から遅延ロードとしています。
通常のクラスに依存注入する場合はGet.put()
、Bindingsで依存注入する場合はGet.lazyPut()
を使う、とざっくり捉えてもいいかと思います。
最後に、このAppBinding
をGetMaterialApp
のinitialBinding
プロパティにセットすれば依存注入の完了です。
// 省略
return GetMaterialApp(
// 省略
initialRoute: '/home',
+ initialBinding: AppBinding(),
getPages: [
GetPage(
name: '/home',
page: () => HomePage(),
),
// 省略
(ちなみに、GetPage
クラスのbinding
プロパティにBindings
をセットすることでRouteに紐づかせることもできます。)
この時点でHomePage
でGet.put()
はする必要がなくなるので、Get.find()
に差し替えます。(Get.put()
のままにしてしまったとしても元々シングルトンで登録されているため、支障はありませんが)
class HomePage extends StatelessWidget {
HomePage({Key? key}) : super(key: key);
+ final todoController = Get.find<TodoController>();
+ final filterController = Get.find<FilterController>();
final localeController = Get.find<LocaleController>();
final themeController = Get.find<ThemeController>();
// 省略
ディープリンク的な
一応機能を実現することができましたが、本来AppBar
に自動で出るはずの「戻る」ボタンが出ずにHomeに戻れませんね。。
この先を掘り下げるとテーマから外れてしまうため詳細は省略させていただきますが、このようなケースで「戻る」ボタンを出すには大きく2通り方法があると思います。
- Router(Navigator 2.0)を使って
Page
のスタックをコントロールする。(GetMaterialApp
でも.router
コンストラクタを使ってRouterを扱うことができます) - 現状の Navigator 1.0 のまま、
GetMaterialApp
(MaterialApp
)のonGenerateRoute
プロパティを使ってRouteSettings
をGetPageRoute
(MaterialPageRoute
)などに渡す。
いずれにしても現状の Navigator 1.0 + getPages
の組み合わせでは対応できません。
もちろん、getPages
を使ったまま無理やりGet.offNamed("/home")
(Navigation.pushReplacementNamed()
に相当)を実行するボタンをAppBar.leading
に設けてしまう方法もあるといえばありますが。。
存在しないリンクは専用画面(404)に誘導
冒頭にリストアップした「実現したい機能一覧」の最後になりました。本デモアプリはウェブアプリなので、ユーザーが自由にURLを入力することができてしまいます。その対策として、存在しないリンクを踏むとホームに誘導するような404画面が必要です。
GetMaterialApp
でgetPages
を使う場合は同unknownRoute
プロパティに404画面をラップしたGetPage
を設定することで、ブラウザのURL入力欄にせよ、アプリ内からにせよ、getPages
で指定したRoute名以外はその画面に誘導することができます。
(onGenerateRoute
を使う場合は、onGenerateRoute
内かonUnknownRoute
を使って404画面を返す必要があります)
class MyApp extends StatelessWidget {
// 省略
getPages: [
// 省略
GetPage(
name: '/todo/:todoId',
page: () => AddTodoPage(todoId: Get.parameters['todoId']),
),
],
+ unknownRoute: GetPage(
+ name: '/404',
+ page: () => const NotFoundPage(),
+ ),
404画面のコード例
class NotFoundPage extends StatelessWidget {
const NotFoundPage({Key? key}) : super(key: key);
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('page_not_found'.tr),
),
body: Center(
child: ElevatedButton(
onPressed: () {
Get.offAllNamed('/home');
},
child: Text('to_home'.tr),
),
),
);
}
404画面に誘導
できました🎉
しかし、一つだけ注意点があります。このunknownRoute
は現在initialRoute
が「/」の場合は有効にならないというバグが報告されています。そのため現時点(2021年12月初旬)ではこの機能を使う場合はinitialRoute
を「/home」などとする必要があります。
その他
後は前編の最終コード例で「TODO:」としていた細かいものをいくつか対応したいと思います。
URLの「#」をなくす
Flutterのウェブアプリにおける「#」はURLの ドメイン部分 とアプリが使用する Route情報 を 分ける境目です。「#」以降の部分は通常のサブディレクトリとは異なるため、できれば残したいのですが個人的に見た目があまり好きではありません😅
なので本デモアプリの最終コード例では以下のリンクに従い、「#」をなくしています。
(アプリをWeb以外に対応させる場合は、上記リンク先の通り「条件付きインポート」が必要になる点ご注意ください)
該当するタスクIDがない場合はホームにリダイレクト
前述の通り、ユーザーが自由にURLを入力できてしまうため、「todo/9999」などと存在しないタスクIDを入力した場合の動作も考慮しなくてはなりません。これに対応する方法は主に2つの方法があるかと思います。
-
AddTodoPage
のinitState
内でRouteから渡されたタスクID情報を元にTodo
を探し、なければホームに画面遷移。 -
GetMiddleWare
を使用してホームにリダイレクトする。
1. initState内で画面遷移する
この場合のサンプルコードです。
// 省略
class _AddTodoPageState extends State<AddTodoPage> {
final todoController = Get.find<TodoController>();
final textController = TextEditingController();
Todo? todo;
void initState() {
super.initState();
if (widget.todoId != null) {
todo = todoController.getTodoById(widget.todoId!);
if (todo != null) {
textController.text = todo!.description;
} else {
+ // 遷移中にbuildが走るとエラーが出るので最初のフレームが描画されてから
+ WidgetsBinding.instance!.addPostFrameCallback((_) {
+ Get.offNamed('/home');
+ });
}
}
}
TodoController
からタスクを探して、なければホームに画面遷移する処理になっています。initState
の後はbuild
が走り、その途中で画面遷移が実行中だとエラーが出てしまいますので、WidgetsBinding
のaddPostFrameCallback
内で画面遷移の処理を書いて最初のフレームが描画されるのを待つ必要があります。
2. GetMiddlewareを使用する
便利なことにGetX(GetPage
)にはRouteの中間処理を行ってくれるミドルウェアGetMiddleware
があります。
このGetMiddleware
を利用すれば、たとえば、「/home」に遷移した際にユーザーのログイン状況を見て、ログインしていなければログイン画面「/login」にリダイレクトする、ということもできてしまいます。
今回のデモアプリでの使用例「該当するタスクIDがない場合は「/home」にリダイレクト」はこちらの通りです。
class AppMiddleware extends GetMiddleware {
RouteSettings? redirect(String? route) {
final controller = Get.find<TodoController>();
final todoId = Get.parameters['todoId']!; // route!.substring(6)
final todo = controller.getTodoById(todoId);
if (todo == null) {
return const RouteSettings(name: '/home');
}
}
}
使い方はGetMiddleware
を継承したクラスを作成し、redirect
メソッドをoverreideするだけです。このredirect
メソッドの中に渡されるRoute名に沿った処理を書きます。ここでは渡されたタスクIDをTodoController
で検索し、該当タスクがなければ「/home」に誘導するRouteSettings
を返すという処理をしています。ここでもGet.find()
が活用できますね!
ミドルウェアはgetPages
のGetPage
のプロパティmiddlewares
に設定します。
class MyApp extends StatelessWidget {
// 省略
getPages: [
// 省略
GetPage(
name: '/todo/:todoId',
page: () => AddTodoPage(todoId: Get.parameters['todoId']),
+ middlewares: [
+ AppMiddleware(),
+ ],
),
],
存在しないタスクIDの場合リダイレクト
存在しないタスクIDをブラウザに入力したら、ちゃんとホーム画面に誘導されました🎉
本デモアプリではこちらの手法を採用しました。
最終コード例
最後に
GetXとSharedPreferencesパッケージだけでこれだけのことが簡潔にできるのは結構驚きではないでしょうか。この点がGetXを使うメリットの一つになっていますが、私は逆に一つのライブラリでやれることが多すぎて、どの機能も埋没している感があるのがGetXのウィークポイントだとも感じます。
次回は最終章となります。外部サービスと接続して映画情報を検索するアプリ(海外では「Movie App」と呼ばれ、習作として定番のようです)を作って、まだ出てきていないGetXの機能を紹介したいと思います。
Discussion