🐓

【Flutter】GetXの世界3 ~Todoアプリを作ってみよう後編~

2021/12/07に公開

GetXの世界3 ~Todoアプリを作ってみよう後編~
後編

はじめに

この記事は以下の記事の続きです。
GetXの世界3 ~Todoアプリを作ってみよう前編~

Todoアプリの完成例
Todoアプリの完成例

GetXSharedPreferencesの二つのライブラリのみを使用して、「データを保存可能で、テーマや言語を切り替えることができ、動的URLに対応したTodoウェブアプリ」を一から作ってみるシリーズの後編です。(間が空いてしまい、すみません💦)

前編ではタスクの追加・更新・削除といった基本の機能を搭載しました。この後編では最終的な仕上げとしてSharedPreferencesへのデータ保存、テーマやロケールの変更管理機能等を搭載しつつ、これまで登場していないGetXの機能を紹介したいと思います。

(この記事で紹介するGetXの機能)

前回の振り返り

前回の記事ではデモアプリで実現したい機能として以下の項目を挙げました。

取り消し線は機能搭載済み。現段階でのコードはこちらで閲覧可能です。)

  • タスク一覧が表示できる
  • タスクが新規追加できる
  • タスクが更新できる
  • タスクが削除できる
  • 完了タスクを一括削除できる
  • タスク一覧をフィルタできる(完了タスクを隠す)
  • 未完了タスクの数をリアルタイムで確認できる
  • アプリ全体のカラーテーマを変更できる
  • アプリ全体のロケールを変更できる(日英の切り替え)
  • タスクデータ、ロケール、テーマ等の設定を保存/ロードできる
  • タスク詳細ページごとに動的リンク生成、ディープリンク可
  • 存在しないリンクは専用画面(404)に誘導できる

一つ一つ片付けていきましょう。

タスク一覧をフィルタして完了タスクを隠す

前回タスク一覧を表示するウィジェット「todo_list.dart」においてtodoController.todosからタスクデータを取得しました。

これはTodoControllerのゲッターで、RxListである_todosから全タスクを取得するものでした。

controllers > todo_controller.dart
class TodoController extends GetxController {
  final _todos = <Todo>[].obs;

  List<Todo> get todos => _todos;
 // 省略

このゲッターをフィルタの状態(完了タスクを隠す、全表示する)に合わせて、タスク内容を切り替えれば機能が実現できそうですね。ひとまず、未完了タスクのみを返す実装に変更してみます。

controllers > todo_controller.dart
 // 省略
  List<Todo> get todos {
+   return _todos.where((todo) => todo.done == false).toList();
  }
 // 省略

次に、「現在アプリは "完了タスクを隠すモード" なのか、"全表示するモード" なのか」を判別する設定情報を加えて返す値を場合分けしなくてはなりません。

この設定情報はもう一つ「FilterController」なるGetxControllerを作成して管理することにしましょう。設定情報の管理にはRxBool型(boolのRx版)を使用します。

controllers > filter_controller.dart
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でしたね。

controllers > todo_controller.dart
 // 省略
  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を使用して立ち上げておきます(立ち上げる場所は後で変更するので仮置き)。

pages > home_page.dart
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の状態に応じて変化するようにしておきましょう。

pages > home_page.dart
leading: IconButton(
+         icon: Obx(() => Icon(
+               filterController.hideDone
+                   ? Icons.filter_alt
+                   : Icons.filter_alt_outlined,
+             )),
          onPressed: filterController.toggleHide,
        ),

タスク一覧をフィルタして完了タスクを隠す
タスク一覧をフィルタして完了タスクを隠す

いい感じですが、、このGIF動画少し違和感がありませんか?「犬の散歩」タスクが隠れたその瞬間、次のタスクが 一瞬だけ「完了状態」 になってしまっています。

これはTodoControllerから新たにタスクリストのデータが渡されて画面が更新される途中で、Flutterが既存リストのトップにあるTodoTileエレメントを、次の画面のトップに来るウィジェットに紐付けてしまうため起こっています。

これを解消するにはTodoTileウィジェットにKeyを設定してFlutterがウィジェットとエレメントを正しく紐付けできるようにしてあげます(参考)。

widgets > todo_list.dart
              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を設定後
Keyを設定後

正しく描画されました🎉

未完了タスクの数をリアルタイムで確認できる

これについてはTodoControllerに「_todosの内容を元にタスク数を返すゲッター」を作成し、それをビュー側でObx()ウィジェットに「消費」させれば実現できそうです。

controllers > todo_controller.dart
class TodoController extends GetxController {
  final _todos = <Todo>[].obs;
 // 省略
+ int get countUndone {
+   return _todos.fold<int>(0, (acc, todo) {
+     if (!todo.done) {
+       acc++;
+     }
+     return acc;
+   });
+ }
 // 省略
pages > home_page.dart
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によって管理される必要性がないので素のクラスとなります。

controllers > theme_controller.dart
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にてあらかじめインスタンスを立ち上げておく必要があります。

main.dart
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にメソッドを仕込みます。

pages > home_page.dart
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ファイルの作成例
data > app_translations.dart
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で保存・読み込みを行う処理を書きたいので、前項と同様にコントローラーを作成しておきます。

controllers > locale_controller.dart
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で各種設定をします。

main.dart
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でインスタンスを指定します。

fallbackLocaleGetMaterialApp独自のプロパティです。その名の通り、アプリがデバイスの言語で表示できない場合に採用する言語です。

以上の一連の設定を噛み砕いた日本語にすると、「アプリのテキストを何語で表示するかの情報をSharedPreferencesから引っ張ってきてね。データがなければデバイスの言語にしてね。その言語にアプリが対応してなければとりあえず日本語で表示してね」 🐓となります。

あとはHomePageactionsボタンにLocaleController.changeLocaleメソッドを仕込めば完成です。

pages > home_page.dart
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を利用してデータやりとりを行うサービスクラスの例
services > storage_service.dart
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()の手前で実行します。

main.dart
+void main() async {
+ WidgetsFlutterBinding.ensureInitialized();
+ await StorageService.init();
  runApp(MyApp());
}

それではこれらのload() save()メソッドを使って実際にSharePreferencesを通じて保存/読み込みを行う機能を使ってみましょう。

テーマ設定の保存/読み込み

まずはテーマ設定から。少し前にThemeControllerにテーマを取得するためのgetThemeMode()、テーマを変更するためのchangeTheme()メソッドを作成しましたが、これらにload() save()を組み込みます。

controllers > theme_controller.dart
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.
  }
}
  1. ThemeStorageのインスタンスを立ち上げて、
  2. getThemeMode()が呼ばれたタイミングでSharedPreferencesから保存されているデータをロードします。
  3. データが存在する場合はそのデータの値(true/false)に合わせたThemeModeを返し、
  4. 初回起動時などのためデータがない場合はThemeMode.systemを返します。
  5. そしてchangeTheme()が呼ばれて変更が終わったタイミングで、ダークモードか否かの現在値をSharedPreferencesに保存します。

ロケール設定の保存/読み込み

次に、ロケール設定です。

controllers > locale_controller.dart
+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.
  }
}
  1. LocaleStorageのインスタンスを立ち上げて、

  2. getLocale()が呼ばれたタイミングでSharedPreferencesから保存されているデータをロードします。

  3. データが存在する場合はそのデータをLocaleに変換して返し、

  4. 初回起動時などのためデータがない場合はGet.deviceLocaleを返します。(デバイスのロケールが日本語でも英語でもなければGetMaterialAppで設定した通り、日本語で強制表示)

  5. そしてchangeLocale()が呼ばれて変更が終わったタイミングで、現在の表示ロケール(Get.locale = Localeオブジェクト)をtoString()で文字列にし、SharedPreferencesに保存します。

  6. で一度文字列に変換している理由は、SharedPreferencesではLocaleオブジェクトをそのまま保存データとして扱えないためです。Stringに変換して保存し、そして読み込み時にその文字列を再びLocaleオブジェクトに戻す必要があります。

取得した文字列からLocaleオブジェクトを復元しているのが 3. です。言語コードの文字列localeCode(日本語ならja_JPの形式)をtoLocaleという拡張メソッドを使ってLocaleオブジェクトに戻しています。この拡張メソッドのコードは上記の通りで、_(アンダーバー)で文字列を分割し、それらをLocaleインスタンスの要素としています。

フィルタ設定の保存/読み込み

次にFilterControllerに保存/読み込み機能を実装します。FilterControllerGetxControllerなので実装方法が少し異なります。以前登場したonInit()やRxオブジェクトを監視してくれるWorkerをうまく活用しましょう。

controllers > filter_controller.dart
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();
+ }
+}
  1. サービスクラスであるFilterStorageをインスタンス化し、load()``save()メソッドが使えるようにしておきます。
  2. Worker用の変数を宣言。
  3. FilterControllerが立ち上がったらonInit()が実行されるので、この中でSharedPreferencesからフィルタ設定をロードし、_hideDone.valueに代入します(データがない場合はfalseを代入)。
  4. Workerのever()メソッドを使って、Rxオブジェクト_hideDoneを監視し、値に変化がある度に「SharedPreferencesを通じて現在値を保存する」コールバックを登録します。
  5. FilterControllerがメモリから破棄されると同時に、ここで登録したWorkerも破棄されるように設定。

タスクリストの保存/読み込み

最後に、TaskControllerにも機能を実装します。FilterControllerの場合と流れは同じです。

controllers > todo_controller.dart
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();
+ }
 // 省略
  1. サービスクラスであるTodoStorageをインスタンス化し、load()``save()メソッドが使えるようにしておきます。

  2. Worker用の変数を宣言。

  3. TodoControllerが立ち上がったらonInit()が実行されるので、その時にSharedPreferencesからList\<String>であるタスクリストをロードし、それぞれのStringTodoオブジェクトに変換。

  4. そしてそれをinitialTodosに代入(データがない場合はサンプルタスクを代入)。

  5. Workerのever()メソッドを使って、Rxオブジェクト_todosを監視し、内容に変化がある度に「SharedPreferencesを通じて現在の状態を保存する」コールバックを登録します。

  6. TodoControllerがメモリから破棄されると同時に、ここで登録したWorkerも破棄されるように設定。

  7. でタスクリストの状態を保存する際に、TodoオブジェクトをJSON文字列に変換しています。これはSharedPreferencesではTodoはそのまま扱えず、代替としてStringList\<String>にする必要があるためです。JSON文字列への変換には以前Todoモデルで作成したtoJson()メソッドを利用しています。

  8. でJSON文字列をTodoに戻す際にも、TodoモデルfromJson()メソッドを利用しています。

これでSharedPreferencesを使ってデータの読み書きを行う機能を搭載することができました🎉

ブラウザでデバッグして設定を変更し、「更新」ボタンを押しても設定が初期状態に戻らないなら成功です。

タスクデータ、ロケール、テーマ等の設定を保存/ロード
タスクデータ、ロケール、テーマ等の設定を保存/ロードできる

タスク詳細ページごとに動的リンク生成、ディープリンク可

タスクごとの「動的リンク」については、すでに前編で目的を達成しているので、この項では「ディープリンク」に関する機能を追加します。

現時点でのコードでタスク詳細ページに飛ぶと、このような画面が出て、URLにもタスクIDが反映されるような状態かと思います。
一つ目のタスク詳細ページ
一つ目のタスク詳細ページ

しかし、ここで cmd/ctrl + R などでブラウザを「更新」すると、、
TodoControllerが見つからないエラー
TodoControllerが見つからないエラー

とこのように「TodoControllerが見つかりません」というエラーが出てしまいます。

これは「更新」によってアプリが再起動され、現在のURL「todo/0」に紐づくページAddTodoPageをアプリが表示しようとするのですが、ページ内で使われているGet.find<TodoController>()のコントローラーがGet.put()でインスタンス化された形跡がないため発生したエラーです。

このTodoControllerは現在のコードだとHomePageクラスでGet.put()されているため、HomePage経由でAddTodoPageへ行かないと正しく描画できないのです。この状況を図で表すと下記の通りになります。(赤い矢印が今回たどったルート)

TodoControllerインスタンスがないのでエラー
TodoControllerインスタンスがないのでエラー

これを解消するには、図中央のGetMaterialAppにコントローラーのインスタンスを紐づけて、どのルートをたどってもTodoControllerが使えるようにする必要があります。

TodoControllerインスタンスがアプリ自体に紐づいている
TodoControllerインスタンスがアプリ自体に紐づいている

このように、ある依存オブジェクトをアプリ自体やRouteに紐づける仕組みをGetXではBindings(結合、結束)を使って実現することができます。

Bindings と Get.lazyPut

使い方は、以下の通りです。

app_binding.dart
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()を使う、とざっくり捉えてもいいかと思います。

最後に、このAppBindingGetMaterialAppinitialBindingプロパティにセットすれば依存注入の完了です。

main.dart
 // 省略
    return GetMaterialApp(
      // 省略
      initialRoute: '/home',
+     initialBinding: AppBinding(),
      getPages: [
        GetPage(
          name: '/home',
          page: () => HomePage(),
        ),
 // 省略

(ちなみに、GetPageクラスのbindingプロパティにBindingsをセットすることでRouteに紐づかせることもできます。)

この時点でHomePageGet.put()はする必要がなくなるので、Get.find()に差し替えます。(Get.put()のままにしてしまったとしても元々シングルトンで登録されているため、支障はありませんが)

pages > home_page.dart
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プロパティを使ってRouteSettingsGetPageRoute(MaterialPageRoute)などに渡す。

いずれにしても現状の Navigator 1.0 + getPages の組み合わせでは対応できません。

もちろん、getPagesを使ったまま無理やりGet.offNamed("/home")Navigation.pushReplacementNamed()に相当)を実行するボタンをAppBar.leadingに設けてしまう方法もあるといえばありますが。。

存在しないリンクは専用画面(404)に誘導

冒頭にリストアップした「実現したい機能一覧」の最後になりました。本デモアプリはウェブアプリなので、ユーザーが自由にURLを入力することができてしまいます。その対策として、存在しないリンクを踏むとホームに誘導するような404画面が必要です。

GetMaterialAppgetPagesを使う場合は同unknownRouteプロパティに404画面をラップしたGetPageを設定することで、ブラウザのURL入力欄にせよ、アプリ内からにせよ、getPagesで指定したRoute名以外はその画面に誘導することができます。

onGenerateRouteを使う場合は、onGenerateRoute内かonUnknownRouteを使って404画面を返す必要があります)

main.dart
class MyApp extends StatelessWidget {
 // 省略
      getPages: [
        // 省略
        GetPage(
          name: '/todo/:todoId',
          page: () => AddTodoPage(todoId: Get.parameters['todoId']),
        ),
      ],
+     unknownRoute: GetPage(
+       name: '/404',
+       page: () => const NotFoundPage(),
+     ),
404画面のコード例
pages > not_found_page.dart
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画面に誘導
404画面に誘導

できました🎉

しかし、一つだけ注意点があります。このunknownRouteは現在initialRouteが「/」の場合は有効にならないというバグが報告されています。そのため現時点(2021年12月初旬)ではこの機能を使う場合はinitialRouteを「/home」などとする必要があります。

https://github.com/jonataslaw/getx/issues/1739

その他

後は前編の最終コード例で「TODO:」としていた細かいものをいくつか対応したいと思います。

URLの「#」をなくす

Flutterのウェブアプリにおける「#」はURLの ドメイン部分 とアプリが使用する Route情報 を 分ける境目です。「#」以降の部分は通常のサブディレクトリとは異なるため、できれば残したいのですが個人的に見た目があまり好きではありません😅

なので本デモアプリの最終コード例では以下のリンクに従い、「#」をなくしています。
https://docs.flutter.dev/development/ui/navigation/url-strategies

(アプリをWeb以外に対応させる場合は、上記リンク先の通り「条件付きインポート」が必要になる点ご注意ください)

該当するタスクIDがない場合はホームにリダイレクト

前述の通り、ユーザーが自由にURLを入力できてしまうため、「todo/9999」などと存在しないタスクIDを入力した場合の動作も考慮しなくてはなりません。これに対応する方法は主に2つの方法があるかと思います。

  1. AddTodoPageinitState内でRouteから渡されたタスクID情報を元にTodoを探し、なければホームに画面遷移。
  2. GetMiddleWareを使用してホームにリダイレクトする。

1. initState内で画面遷移する

この場合のサンプルコードです。

pages > add_todo_page.dart
 // 省略
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が走り、その途中で画面遷移が実行中だとエラーが出てしまいますので、WidgetsBindingaddPostFrameCallback内で画面遷移の処理を書いて最初のフレームが描画されるのを待つ必要があります。

2. GetMiddlewareを使用する

便利なことにGetX(GetPage)にはRouteの中間処理を行ってくれるミドルウェアGetMiddlewareがあります。

このGetMiddlewareを利用すれば、たとえば、「/home」に遷移した際にユーザーのログイン状況を見て、ログインしていなければログイン画面「/login」にリダイレクトする、ということもできてしまいます。

今回のデモアプリでの使用例「該当するタスクIDがない場合は「/home」にリダイレクト」はこちらの通りです。

app_middleware.dart
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()が活用できますね!

ミドルウェアはgetPagesGetPageのプロパティmiddlewaresに設定します。

main.dart
class MyApp extends StatelessWidget {
 // 省略
      getPages: [
        // 省略
        GetPage(
          name: '/todo/:todoId',
          page: () => AddTodoPage(todoId: Get.parameters['todoId']),
+         middlewares: [
+           AppMiddleware(),
+         ],
        ),
      ],

存在しないタスクIDの場合リダイレクト
存在しないタスクIDの場合リダイレクト

存在しないタスクIDをブラウザに入力したら、ちゃんとホーム画面に誘導されました🎉

本デモアプリではこちらの手法を採用しました。

最終コード例

https://github.com/toshi-kuji/zenn_getx_todo_demo/tree/master/lib

最後に

GetXとSharedPreferencesパッケージだけでこれだけのことが簡潔にできるのは結構驚きではないでしょうか。この点がGetXを使うメリットの一つになっていますが、私は逆に一つのライブラリでやれることが多すぎて、どの機能も埋没している感があるのがGetXのウィークポイントだとも感じます。

次回は最終章となります。外部サービスと接続して映画情報を検索するアプリ(海外では「Movie App」と呼ばれ、習作として定番のようです)を作って、まだ出てきていないGetXの機能を紹介したいと思います。

Discussion