🐣

【Flutter】GetXの世界2 ~ログインフォームを作ってみよう編~

2021/10/20に公開3

GetXの世界② ~ログインフォームを作ってみよう編~

はじめに

この記事は以下の記事の続きです。
https://zenn.dev/inari_sushio/articles/c9864e5e89c599

今回はログインフォームを作りながらGetXを深掘りしていきたいと思います。状態に応じてUIを描画・画面遷移する手法、GetXによるRoute管理、StateMixinについて学びます。

ログインフォームの完成例
ログインフォームの完成例

GetX日本語ドキュメント
📕 Readme / 📚 ドキュメント

※ 誤訳訂正や表現のご提案はこちらにいただけると助かります。

前回の補足(+ Tips)

前回の記事ではカウンターアプリの作成を通じて、主に以下のAPIについて学びました。

  1. GetxController
    👉 Controllerクラスにライフサイクルメソッドをもたらす抽象クラス
  2. .obs
    👉 どんなオブジェクトもObservable(監視可能)にする拡張プロパティ
  3. Get.put() / Get.find()
    👉 GetxControllerなどのインスタンスを立ち上げ、そして探すためのメソッド
  4. Obx()
    👉 Observable(監視可能)なオブジェクトを監視するウィジェット
作成したカウンターアプリ
Controller クラス
class CounterController extends GetxController {
 final count = 0.obs;
 final countEven = 0.obs;
 late final Worker _worker;

 
 void onInit() {
   super.onInit();
   _worker = ever<int>(count, (value) {
     if (value.isEven) {
       countEven.value = value;
     }
   });
 }

 
 void onClose() {
   _worker.dispose();
   super.onClose();
 }
}
View クラス
class CounterPage extends StatelessWidget {
  GetPutPage({Key? key}) : super(key: key);
  
  final controller = Get.put(CounterController());

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Obx(() => Text('${controller.count}')),
            ElevatedButton(
              onPressed: () => controller.count.value++,
              child: const Text('increase'),
            ),
            Obx(() => Text('${controller.countEven}')),
          ],
        ),
      ),
    );
  }
}

まず前回いくつか触れていない点があったので、本題に入る前に補足しておきたいと思います。

Get.put() / Get.find() はViewクラス以外でも使用できる

Get.put() / Get.find() は必ずしもViewクラスの中だけの使用に留める必要はありません。GetxController内で他のControllerインスタンスを探したり、runApp()が実行される前にControllerを立ち上げたりといった用途でも使用できます。

以下はその使用例です。

使用例
runApp前にAnotherControllerのインスタンスを立ち上げる
void main() {
  Get.put(AnotherController());
  runApp(const MyApp());
}
CounterControllerを立ち上げて数字を表示する
class CounterPage extends StatelessWidget {
  final controller = Get.put(CounterController());

  
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(
        child: Obx(() => Text(controller.count.string)),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () => controller.count.value++,
      ),
    );
  }
}
AnotherControllerを探して奇数情報を伝える
class CounterController extends GetxController {
  final count = 0.obs;
  
  
  void onInit() {
    super.onInit();
    ever<int>(count, (value) {
      Get.find<AnotherController>().isOdd.value = value.isOdd;
    });
  }
}
奇数ならprint()する
class AnotherController extends GetxController {
  final isOdd = false.obs;

  
  void onInit() {
    super.onInit();
    ever<bool>(isOdd, (value) {
      if (value) print('Is Odd');
    });
  }
}

Get.put() はGetxController以外の注入にも利用できる

たとえば、SharedPreferencesのインスタンスにも利用することができます。

SharedPreferencesの場合は SharedPreferences.getInstance() の戻り値がFutureのため、Get.put() の非同期版である Get.putAsync() を使用します。

使用例
runApp前にSharedPreferencesのインスタンスを立ち上げる
void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await Get.putAsync(
    () async => await SharedPreferences.getInstance(),
  );
  runApp(const MyApp());
}
SharedPreferencesインスタンスを探して設定をトグル
class _SettingState extends State<Setting> {
  bool _switch = Get.find<SharedPreferences>().getBool('switch') ?? false;

  
  Widget build(BuildContext context) {
    return Scaffold(
      body: Switch.adaptive(
        value: _switch,
        onChanged: (value) async {
          await Get.find<SharedPreferences>().setBool('switch', value);
          setState(() => _switch = value);
        },
      ),
    );
  }
}

「.obs」以外にもRxオブジェクトを作る方法がある

前回、StringやListなどに「.obs」を付け足すことで、特殊なStreamであるRxオブジェクトに変換することができる点をご説明したかと思います。「.obs」自体はRxのゲッターに過ぎませんので、Rxクラスを直接用いてオブジェクト作ることもできます。

以下の例はすべて同じ意味合いを持ちます。

Rxオブジェクト
final message = 'あいうえお'.obs;
final message = RxString('あいうえお');
final message = Rx<String>('あいうえお');

ちなみにRxStringに限らず、Rx〜はすべて初期値が必須です。null許容型(nullable)にしたい場合はRxnクラスを使用します。

null許容型Rxオブジェクト
// この場合の初期valueはnull
final message = RxnString();
final message = Rxn<String>();

また「.obs」はnull許容型にできないため、nullableにする場合はRxn〜を使用する必要があります。

Rxオブジェクトはcall()することでvalueの更新ができる

Dartにはクラスインスタンスを関数のように使用することで、あらかじめ定義したcallメソッドを呼び出すことができる機能があるのはご存知でしょうか。

(参考)A tour of the Dart language: Callable classes

実はRxオブジェクトにもcallメソッドが定義されており、valueの更新に使用することができます。「.value」に代入するよりも簡潔でわかりやすいですね。

Rxオブジェクトのcallメソッド使用例
class MessageController extends GetxController {
  final message = 'はい'.obs;
  
  void update() {
    message('いいえ'); // message.value = 'いいえ'; と同じ
  }
}

Rx型はモデルクラスのプロパティにも使用できる

カスタムクラスのインスタンスに「.obs」を付けることでRx<モデル>型に変換できる点は前回ご説明しましたが、モデルクラスのプロパティ自体をRx型にすることで、クラスではなくそのプロパティ単体を監視することができます。

モデルクラスのプロパティをRx型にしてWidgetを更新する
class User {
  final RxString name;
  final RxInt age;

  User(this.name, this.age);
}

class UserController extends GetxController {
  final user = User('taro'.obs, 18.obs);
  // final user = User(RxString('taro'), RxInt(18)); に同じ

  void addLetter() {
    user.name(user.name + 'o'); // callメソッドを利用して更新
  }

  void getOld() {
    user.age.value++;
  }
}
View側のコード例はこちら
class UserPage extends StatelessWidget {
  final controller = Get.put(UserController());

  
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(
        child: ListTile(
          title: Obx(() {
            print('name widget rebuilt');
            return Text('${controller.user.name}');
          }),
          subtitle: Obx(() {
            print('age widget rebuilt');
            return Text('${controller.user.age}');
          }),
        ),
      ),
      floatingActionButton: ButtonBar(
        children: [
          ElevatedButton(
            onPressed: controller.addLetter,
            child: const Text('addLetter'),
          ),
          ElevatedButton(
            onPressed: controller.getOld,
            child: const Text('getOld'),
          ),
        ],
      ),
    );
  }
}

プロパティをそれぞれ監視しているため、nameのみが更新されたときはnameに関するウィジェットのみが、ageのみが更新されたときはageに関するウィジェットのみが、個別に再ビルドされるのがコンソールの出力結果から分かるかと思います。

ログインフォームを作ってみよう

それでは本題です。

ログインフォーム

Flutterで複数の入力フィールドによるフォームを作るには、TextFieldを使ってフォームライクに動作させる方法と、TextFormField(TextFieldをフォーム用途に特化させたウィジェット)を使う方法があります。

前者の方がGetXを学ぶ上で適していると思いますので、今回はそれで進行します。

まずはこのログインフォームを構成する要素をざっくり洗い出してみましょう。

  • Widget
    • TextField × 2
    • ログインアクションの起点になるElevatedButton
    • ユーザーへのメッセージを伝えるText
  • Controller
    • TextFieldの入力内容を取得するためのTextEditingController × 2
    • ログインフォーム全体を司るGetxController
  • 状態、プロパティなど
    • ユーザーへのメッセージ内容を管理するRxプロパティ
    • ログインステータス(エラー状態なのか、進行中なのかなど)を管理するRxプロパティ
    • ログインアクションを行うメソッド
    • 入力内容を検証するメソッド
    • ログインが成功したら画面遷移するステータスリスナー

図にするとこのようなイメージになるかと思います。

ログインフォーム全体像
ログインフォーム全体像

ユーザーインターフェイスを作る

それではユーザーインターフェイスから作ってみましょう。

UIのコード例
class LoginPage extends StatefulWidget {
  const LoginPage({Key? key}) : super(key: key);

  
  State<LoginPage> createState() => _LoginPageState();
}

class _LoginPageState extends State<LoginPage> {
  final emailController = TextEditingController(text: 'oreore@ore.com');
  final passwordController = TextEditingController();
  
  
  void initState() {
    super.initState();
    //(📌 仮)ログインステータスのリスナー → 「成功」で画面遷移
  }

  
  void dispose() {
    emailController.dispose();
    passwordController.dispose();
    super.dispose();
  }

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('LOGIN'),
      ),
      body: Container(
        padding: const EdgeInsets.all(16),
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            TextField(
              controller: emailController,
              decoration: const InputDecoration(labelText: 'E-mail'),
            ),
            TextField(
              controller: passwordController,
              decoration: const InputDecoration(labelText: 'Password'),
              obscureText: true,
            ),
            const SizedBox(height: 30),
            ElevatedButton(
              child: const Text('login'),
              onPressed: () {}, //(📌 仮)ログインアクション
            ),
            const SizedBox(height: 30),
	    const Text('Placeholder message.'), //(📌 仮)メッセージ
          ],
        ),
      ),
    );
  }
}

ここはTextEditingControllerの使用リソース解放のためにStatefulWidgetにしてdispose()するのを忘れないようにしましょう。

GetxControllerを作成する

続いてControllerクラスを作成します。まず、UI側に伝達するメッセージを入れるRxオブジェクトをmessage変数に代入します。

class LoginController extends GetxController {
  final message = 'Please fill out the form.'.obs;
  final status = ??;

  void login() {}

  void _validate() {}
}

次にstatus変数ですが、以下4つのステータスを表せるオブジェクトを入れたいと思います。

  • 初期状態
    いわゆるアイドル状態です。messageの初期値をUIに表示します。
  • ロード中の状態
    UI側でLoading...などと表示する予定です。
  • エラーが発生した状態
    今回はAPI認証は行わないので、入力内容が空だった場合などにエラーとし、メッセージの文字色を赤にします。
  • ログインが成功した状態
    ステータスリスナーが成功を検知すると次の画面に自動遷移します。

このような複数の「状態」を、同じ型のもとでグルーピングするには抽象クラスを継承するか、enumを使用するなどの方法があるかと思いますが、今回はenumを使用します。

enum FormStatus { idle, loading, error, success }

class LoginController extends GetxController {
  final message = 'Please fill out the form.'.obs;
  final status = (FormStatus.idle).obs; // 型は Rx<FormStatus>
  
  void login() {}

  void _validate() {}
}

メッセージやステータスを操作するメソッドを実装する

ここでは認証に関する部分は省略し、代わりにFuture.delayedで通信をモックします。message変数やstatus変数の更新の部分にフォーカスしたいと思います。

ログインの処理
  void login(String email, String password) async {
    // ■①statusの初期化
    status(FormStatus.idle); // status.value = FormStatus.idle に同じ
    
    // ■②入力値の検証
    message('Checking inputs...'); // message.value = に同じ
    await wait(seconds: 1);
    _validate(email.trim(), password.trim()); // 検証失敗で「エラー」
    
    // エラーが出なかった場合の処理
    if (status.value == FormStatus.idle) {
      // ■③ロード中
      status(FormStatus.loading);
      message('OK. Loading screen...');
      await wait(seconds: 1);
      
      // ■④ログインに成功
      status(FormStatus.success); // → 画面遷移
    }
  }
  
// 通信モック用(別途利用のためクラスの外に置いてます)
Future<void> wait({required int seconds}) async {
  await Future.delayed(Duration(seconds: seconds));
}

- 初期値がすでに FormStatus.idle なので必要ないように思われますが、エラー状態になったときにメッセージテキストを赤文字にしてそれ以外を黒文字にする予定なので、ボタンを押したら状態がリセットされる(黒文字に戻る)ようにしています。

- いわゆる「ロード中」的なメッセージを表示しつつ、入力されたメールアドレスとパスワードをチェックします。

- 上記チェックがエラーにならなかった場合(FormStatus.idleのまま)に、ステータスをloadingに変更しつつ認証を行います。

- ログインが完了すると状態もsuccessに更新されます。同時に、後述するステータスリスナーが画面遷移を行ってくれます。

次に _validate() の入力内容のチェックは、GetXパッケージに備わっている GetUtils というユーティリティクラスの助けを借りたいと思います。

入力テキストの検証処理
  void _validate(String email, String password) {
    final isNotEmail = !GetUtils.isEmail(email);
    final isNotPassword = password.length < 8;
    if (isNotEmail || isNotPassword) {
      status(FormStatus.error);
      message('Invalid email or password.');
    }
  }

GetUtils.isEmail は文字列が正しいEmail形式になっているか検証してtrue/falseを返します。その他にも便利なバリデーションメソッドがあるので、APIドキュメントをチェックしてみてください。

あとはUI側のクラスにLoginControllerのインスタンスを注入し、(仮) としていた箇所を埋めれば完成です。

Get.put() でLoginControllerを注入する

LoginPage
class _LoginPageState extends State<LoginPage> {
  final controller = Get.put(LoginController());
  //(中略)

Obx() でRxを監視してウィジェット更新する

LoginPage
    child: Column(
      mainAxisAlignment: MainAxisAlignment.center,
      children: [
	//(中略)
        Obx(
          () {
            final textColor = controller.status.value == FormStatus.error
                ? Colors.red : null; //エラーなら赤字にする
            return Text(
              controller.message.value,
              style: TextStyle(color: textColor),
            );
          },
        ), //(中略)

ステータスをlistenして画面遷移の処理をする

前回の記事でも少し触れましたが、Rxオブジェクトの実態はStreamなのでlistenすることができます。これを利用してLoginControllerのstatus変数を監視して、「成功」のステータスになったら画面遷移するリスナーを登録します。

LoginPage
  //(中略)
  
  void initState() {
    super.initState();
    controller.status.listen((status) {
      // ログインが成功したら今の画面を破棄して遷移する
      if (status == FormStatus.success) {
        Navigator.pushReplacementNamed(context, '/data');
      }
    });
  } //(中略)

ここは認証が終わった後にログイン画面が再表示されないよう、pushNamed()ではなく pushReplacementNamed()を使って前画面をRouteスタックから破棄します。

これまでのコードまとめ
Controllerクラス
enum FormStatus { idle, loading, error, success }

class LoginController extends GetxController {
  final message = 'Please fill out the form.'.obs;
  final status = (FormStatus.idle).obs;

  void login(String email, String password) async {
    status(FormStatus.idle);
    message('Checking inputs...');
    await wait(seconds: 1);
    _validate(email.trim(), password.trim());
    if (status.value == FormStatus.idle) {
      status(FormStatus.loading);
      message('OK. Loading screen...');
      await wait(seconds: 1);
      status(FormStatus.success);
    }
  }

  void _validate(String email, String password) {
    final isNotEmail = !GetUtils.isEmail(email);
    final isNotPassword = password.length < 8;
    if (isNotEmail || isNotPassword) {
      status(FormStatus.error);
      message('Invalid email or password.');
    }
  }
}

Future<void> wait({required int seconds}) async {
  await Future.delayed(Duration(seconds: seconds));
}
Viewクラス MyApp
class MyApp extends StatelessWidget {
  const MyApp({Key? key}) : super(key: key);

  
  Widget build(BuildContext context) {
    return MaterialApp(
      theme: ThemeData(
        primarySwatch: Colors.purple,
        textTheme: const TextTheme(
          subtitle1: TextStyle(fontSize: 45),
          bodyText2: TextStyle(fontSize: 30),
        ),
      ),
      initialRoute: '/login',
      onGenerateRoute: (settings) {
        switch (settings.name) {
          case '/login':
            return GetPageRoute(page: () => LoginPage());
          case '/data':
            return GetPageRoute(page: () => DataPage());
        }
      },
    );
  }
}
Viewクラス LoginPage
class LoginPage extends StatefulWidget {
  const LoginPage({Key? key}) : super(key: key);

  
  State<LoginPage> createState() => _LoginPageState();
}

class _LoginPageState extends State<LoginPage> {
  final controller = Get.put(LoginController());
  final emailController = TextEditingController(text: 'oreore@ore.com');
  final passwordController = TextEditingController();

  
  void initState() {
    super.initState();
    controller.status.listen((status) {
      if (status == FormStatus.success) {
        Navigator.pushReplacementNamed(context, '/data');
      }
    });
  }

  
  void dispose() {
    controller.dispose();
    emailController.dispose();
    passwordController.dispose();
    super.dispose();
  }

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('LOGIN'),
      ),
      body: Container(
        padding: const EdgeInsets.all(16),
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            TextField(
              controller: emailController,
              decoration: const InputDecoration(labelText: 'E-mail'),
            ),
            TextField(
              controller: passwordController,
              decoration: const InputDecoration(labelText: 'Password'),
              obscureText: true,
            ),
            const SizedBox(height: 30),
            ElevatedButton(
              child: const Text('login'),
              onPressed: () => controller.login(
                  emailController.text, passwordController.text),
            ),
            const SizedBox(height: 30),
            Obx(
              () {
            final textColor = controller.status.value == FormStatus.error
                    ? Colors.red
                    : null;
                return Text(
                  controller.message.value,
                  style: TextStyle(color: textColor),
                );
              },
            ),
          ],
        ),
      ),
    );
  }
}
Viewクラス DataPage
class DataPage extends StatelessWidget {
  const DataPage({Key? key}) : super(key: key);

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Data Page'),
      ),
      body: const Center(
        child: Text('data'),
      ),
    );
  }
}

コードを見直してみる

ところで前回、GetxControllerにはStatefulWidgetと同様のライフサイクルメソッドがあると触れたのを覚えていますでしょうか。

initState()やdispose()メソッドで行うようなことをController内でも行うことができるのであれば、StatefulWidgetからそれらのメソッドやstateの部分をGetxControllerに委譲して、UIの部分はUIに関するコードだけにして、なるべくStatelessWidget化していくといったこともできそうですね。

それをどこまでやるか、そもそもそれが正しい方向性なのか私には分からないのですが、GetXのドキュメントを読む限り、このライブラリが作られた背景にはそういう考えも元になっているようです。ちょっと検証してみましょう。

GetMaterialApp と 画面遷移の仕組み

まず_LoginPageStateクラスのbuildや依存注入(Get.put)に関するプロパティ以外を GetxController へそのまま移動します。

そして initState() を onInit()、dispose() を onClose() とそれぞれGetxControllerで該当するメソッド名に変更します。

class LoginController extends GetxController {
  final message = 'Please fill out the form.'.obs;
  final status = (FormStatus.idle).obs;

+ final emailController = TextEditingController(text: 'oreore@ore.com');
+ final passwordController = TextEditingController();

+ 
+ void onInit() { // initStateから変更
+   super.onInit(); // initStateから変更
+   status.listen((status) {
+     if (status == FormStatus.success) {
+       Navigator.pushReplacementNamed(context, '/data');
+     }
+   });
+ }

+ 
+ void onClose() { // disposeから変更
+   emailController.dispose();
+   passwordController.dispose();
+   super.onClose(); // disposeから変更
+ }
//(中略)

あれ、なんかIDEに怒られてしまった。。

エラー

Navigatorの ofメソッド に渡していたBuildContextが存在しないことが原因のようです。Widgetクラス下ではないので当然ですね。

🤔 そもそもなぜナビゲーションにcontextが必要なのでしょうか。ofメソッドの実装を見てみたいと思います。

Flutterフレームワーク > Widgetsセクション > navigator.dart
  static NavigatorState of(
    BuildContext context, {
    bool rootNavigator = false,
  }) {
    // Handles the case where the input context is a navigator element.
    NavigatorState? navigator;
    if (context is StatefulElement && context.state is NavigatorState) {
      navigator = context.state as NavigatorState;
    }
    if (rootNavigator) {
      navigator = context.findRootAncestorStateOfType<NavigatorState>() ?? navigator;
    } else {
      navigator = navigator ?? context.findAncestorStateOfType<NavigatorState>();
    }

これを見ると、どうやらツリーを辿ってアプリの根っこかその途中にある 「NavigatorState」 (StatefulWidgetであるNavigatorのStateクラス)を探すためだと分かります。そしてこの NavigatorState が画面遷移のためのメソッドを保持しています。

ということはそこへ直にアクセスする方法があれば、BuildContextを使わなくて済みそうですね!外からWidgetにアクセスする方法といえば Key 🔑 、、、

https://www.youtube.com/watch?v=kn0EOS-ZiIc

そしてMaterialAppには 🌍 GlobalKey<NavigatorState> を受け入れる navigatorKeyプロパティ があります。このGlobalKeyを使って画面遷移を操作できそうです。たとえばこんなふうに。

final key = GlobalKey<NavigatorState>();

MaterialApp(navigatorKey: key);

key.currentState?.pushReplacement(newRoute);

でもBuildContextを使わずに画面遷移したいがために、毎回このようなことをするのは面倒ですね。。

そこで、GetXには上記のようなことも含めて色々とプリセットしてくれる MaterialApp / CupertinoApp のラッパーウィジェット GetMaterialApp / GetCupertinoApp と、画面遷移のための独自のシンタックスがあります。(セットで使う必要があります)

早速使ってみましょう。

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

  
  Widget build(BuildContext context) {
+   return  GetMaterialApp(
      // (中略)
      initialRoute: '/login',
      onGenerateRoute: (settings) {
        switch (settings.name) {
          case '/login':
            return GetPageRoute(page: () => LoginPage());
          case '/data':
            return GetPageRoute(page: () => DataPage());
        }
      },
    );
  }
}
LoginController
class LoginController extends GetxController {
//(中略)
  
  void onInit() {
    super.onInit();
    status.listen((status) {
      if (status == FormStatus.success) {
+       Get.offNamed('data');
      }
    });
  }

短くなりましたね。名前付きRouteでなかった場合、元のシンタックスからの短縮率はもっと大きかったと思います。

このGetX独自の画面遷移シンタックス導入の背景にはおそらく、シンタックスを短くする目的以外に、BuildContextが使えないところで画面遷移する(画面遷移のコードをUIウィジェットから切り離す)という目的があったものと思われます。

NavigatorStateの主なナビゲーションメソッドとGetのそれの対応表は以下の通りです。

Navigator Get
Navigator.of(context).push(route) Get.to(page)
Navigator.of(context).pushNamed(route) Get.toNamed(page)
Navigator.of(context).pushReplacement(route) Get.off(page)
Navigator.of(context).pushReplacementNamed(route) Get.offNamed(page)
Navigator.of(context).pop(route) Get.back()
Navigator.of(context).popUntil(predicate) Get.until(predicate)

GetxControllerの自動disposeについて

前回の記事で、GetxControllerの破棄はWidgetやRouteの破棄とともにGetXが自動で行ってくれる、そしてそれには条件があるというようなことを書いていたと思います。

(GetXはコンソールにControllerがいつ作成され、初期化され、破棄されたかのログを出力してくれるのですが、普通にGet.putをしてNavigator.push / popを行うと自動破棄されないのが分かります。)

現在公式ドキュメントにその条件の詳細について記載がなく、GitHubのレポジトリでもやりとりが散見されるだけです。

そこで私の方で色々と検証した結果、どうやら

📌 GetxControllerを注入したクラスのインスタンスがGetPageRoute下で管理されていること」

が最低限の条件になっているような気がします。(詳しい方がいたら教えてください)

GetPageRouteはMaterialPageRouteと同様にPageRouteを継承するページモーダルです。

使い方はMaterialPageRouteと同様になりますが、初期画面からGetPageRouteでページを管理するためにも、GetXでは名前付きRouteを使用するのが推奨されるようです。(参考

なので、GetXを使う場合(特にObxを使う場合)は基本的に以下のいずれかの選択になるかと思います。

  • MaterialApp/CupertinoApp でGetXを使う場合

1️⃣ onGenerateRoute で GetPageRoute を使う

    return MaterialApp(
      initialRoute: '/login',
      onGenerateRoute: (settings) {
        switch (settings.name) {
          case '/login':
            return GetPageRoute(page: () => LoginPage());
          case '/data':
            return GetPageRoute(page: () => DataPage());
        }
      },
    );
  • GetMaterialApp/GetCupertinoApp を使う場合

2️⃣ onGenerateRoute で GetPageRoute を使う

    return GetMaterialApp(
      initialRoute: '/login',
      onGenerateRoute: (settings) {
        switch (settings.name) {
          case '/login':
            return GetPageRoute(page: () => LoginPage());
          case '/data':
            return GetPageRoute(page: () => DataPage());
        }
      },
    );

3️⃣ getPages で GetPage を使う

    return GetMaterialApp(
      initialRoute: '/login',
      getPages: [
        GetPage(name: '/login', page: () => LoginPage()),
        GetPage(name: '/data', page: () => DataPage()),
      ],
    );

※ ただし、別の記事で紹介する GetBuilder/GetX のinitプロパティでControllerを作成した場合は、上記の条件は無関係です。(この場合はWidgetが破棄されるとControllerも破棄されます)

StateMixin

長くなってしまったので今回はStateMixinを紹介して終わりにしたいと思います。

StateMixinはGetxControllerの機能を拡張して非同期処理をする際のUIの「状態」と「データ」の扱いを簡便にしてくれるミックスインです。

通信を行ってデータを取得し、そのデータ取得の経過と結果(ロード中、エラー、通信完了の状態)をUIに反映させたい場合にとても便利です。

せっかくなのでほぼ空っぽになっていたログイン後のページ、DataPageで活用してみたいと思います。

GetxControllerにStateMixinを実装する

実装する際に型を指定する必要があります。

class DataController extends GetxController with StateMixin<List<String>> {
}

この型は非同期処理を行なった結果、取得される(生成される)データの型を指定します。この「データ」は stateプロパティ で取得できます。一方の「状態」は statusプロパティ で取得します。

これらの「データ」と「状態」を更新するには changeメソッド を使用します。changeメソッドの第一引数には「データ(state)」、第二引数に「状態(status)」が入ります。「状態」は RxStatus型 です。

その状態に紐づくデータがない場合はnullを指定します。また、error状態の際にメッセージをUI側に渡したい場合は RxStatus.error('メッセージ') で文字列を指定します。

GetxControllerでのStateMixin 使用例
class DataController extends GetxController with StateMixin<List<String>> {
  
  void onInit() {
    super.onInit();
    init();
  }

  void init() async {
    final random = Random().nextInt(10);

    change(null, status: RxStatus.loading());
    await wait(seconds: 2); // APIとの通信をモック

    if (random < 2) {
      change(null, status: RxStatus.error('Something is wrong.'));
    } else if (random < 4) {
      change(null, status: RxStatus.empty());
    } else {
      final data = [
        for (var i = 0; i < 100; i++) 'ABCDEGHIJKLMN',
      ];
      change(data, status: RxStatus.success());
    }
  }
}

ここではAPIとの通信をFuture.delayedでモックし、その間を「loading」状態にし、何回かに一回の割合で「error」「empty(データが空)」「success(データ取得)」のいずれかになるようにしています。

GetxController with StateMixin をUIで「消費」する

StateMixinを実装したControllerのデータと状態を「消費」するには通常の Obx() ウィジェットではなく、controller.obx() メソッドを使用します。

UI側での「消費」
class DataPage extends StatelessWidget {
  DataPage({Key? key}) : super(key: key);

  final controller = Get.put(DataController()); // 注入

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Data Page'),
      ),
      body: Center(
        child: controller.obx(
          (data) {
            return ListView.builder(
              itemCount: data!.length,
              itemBuilder: (context, index) {
                return ListTile(title: Text(data[index]));
              },
            );
          },
          onError: (error) {
            return Text(error!, style: const TextStyle(color: Colors.red));
          },
          onEmpty: const Text('no data'),
          onLoading: const CircularProgressIndicator.adaptive(),
        ),
      ),
      floatingActionButton: FloatingActionButton(
        child: const Icon(Icons.refresh),
        onPressed: controller.init,
      ),
    );
  }
}

controller.obx() は第一引数にデータ取得が完了した場合のWidgetビルダーを指定します。続いて同様に onError(エラー発生時のWidget)、onEmpty(データが空の場合)、onLoading(ロード中)を指定します。

LoginPageのときのようにデータと状態のためのRxをわざわざ準備する必要がなく、一行で両者を更新できるので便利ですね。

最終的なコードの例
MyApp
void main() {
  runApp(const MyApp());
}

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

  
  Widget build(BuildContext context) {
    return GetMaterialApp(
      theme: ThemeData(
        primarySwatch: Colors.purple,
        textTheme: const TextTheme(
          subtitle1: TextStyle(fontSize: 45),
          bodyText2: TextStyle(fontSize: 30),
        ),
      ),
      initialRoute: '/login',
      getPages: [
        GetPage(name: '/login', page: () => LoginPage()),
        GetPage(name: '/data', page: () => DataPage()),
      ],
    );
  }
}
LoginController
enum FormStatus { idle, loading, error, success }

class LoginController extends GetxController {
  final message = 'Please fill out the form.'.obs;
  final status = (FormStatus.idle).obs;

  final emailController = TextEditingController(text: 'oreore@ore.com');
  final passwordController = TextEditingController();

  
  void onInit() {
    super.onInit();
    status.listen((status) {
      if (status == FormStatus.success) {
        Get.offNamed('/data');
      }
    });
  }

  
  void onClose() {
    emailController.dispose();
    passwordController.dispose();
    super.onClose();
  }

  void login() async {
    status(FormStatus.idle);
    message('Checking inputs...');
    await wait(seconds: 1);
    _validate();
    if (status.value == FormStatus.idle) {
      status(FormStatus.loading);
      message('OK. Loading screen...');
      await wait(seconds: 1);
      status(FormStatus.success);
    }
  }

  void _validate() {
    final isNotEmail = !GetUtils.isEmail(emailController.text.trim());
    final isNotPassword = passwordController.text.trim().length < 8;
    if (isNotEmail || isNotPassword) {
      status(FormStatus.error);
      message('Invalid email or password.');
    }
  }
}

Future<void> wait({required int seconds}) async {
  await Future.delayed(Duration(seconds: seconds));
}
LoginPage
class LoginPage extends StatelessWidget {
  LoginPage({Key? key}) : super(key: key);

  final controller = Get.put(LoginController());

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('LOGIN'),
      ),
      body: Container(
        padding: const EdgeInsets.all(16),
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            TextField(
              controller: controller.emailController,
              decoration: const InputDecoration(labelText: 'E-mail'),
            ),
            TextField(
              controller: controller.passwordController,
              decoration: const InputDecoration(labelText: 'Password'),
              obscureText: true,
            ),
            const SizedBox(height: 30),
            ElevatedButton(
              child: const Text('login'),
              onPressed: controller.login,
            ),
            const SizedBox(height: 30),
            Obx(
              () {
            final textColor = controller.status.value == FormStatus.error
                    ? Colors.red
                    : null;
                return Text(
                  controller.message.value,
                  style: TextStyle(color: textColor),
                );
              },
            ),
          ],
        ),
      ),
    );
  }
}
DataController
class DataController extends GetxController with StateMixin<List<String>> {
  
  void onInit() {
    super.onInit();
    init();
  }

  void init() async {
    final random = Random().nextInt(10);
    change(null, status: RxStatus.loading());
    await wait(seconds: 2);
    if (random < 2) {
      change(null, status: RxStatus.error('Something is wrong.'));
    } else if (random < 4) {
      change(null, status: RxStatus.empty());
    } else {
      final data = [
        for (var i = 0; i < 100; i++) 'ABCDEGHIJKLMN',
      ];
      change(data, status: RxStatus.success());
    }
  }
}
DataPage
class DataPage extends StatelessWidget {
  DataPage({Key? key}) : super(key: key);

  final controller = Get.put(DataController());

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Data Page'),
      ),
      body: Center(
        child: controller.obx(
          (data) {
            return ListView.builder(
              itemCount: data!.length,
              itemBuilder: (context, index) {
                return ListTile(title: Text(data[index]));
              },
            );
          },
          onError: (error) {
            return Text(error!, style: const TextStyle(color: Colors.red));
          },
          onEmpty: const Text('no data'),
          onLoading: const CircularProgressIndicator.adaptive(),
        ),
      ),
      floatingActionButton: FloatingActionButton(
        child: const Icon(Icons.refresh),
        onPressed: controller.init,
      ),
    );
  }
}

最終形態
最終形態

最後に

デモとはいえ、短い記述量でそれなりのことが実現でき、UIとロジックの部分をうまく分離できたのではないでしょうか?

今回は以上です。前回予告したのに触れられなかった点についてはまた次回!

Discussion

sh0sh0

分かりやすい記事ありがとうございます!
1つ疑問点があるのですが、GetXとStatefulWidgetの併用についてどう思われますか?
どういう状態管理になるのか気になりました。

この記事のコード例の場合、TextEditingControllerが使われていて単純に考えれば、StatefulWidgetのdispose()内でTextEditingControllerをdisposeすることになると思いますが、その場合どうするのだろう?と疑問に思いました。

T.KujiT.Kuji

読んでいただきありがとうございます!使い方にもよるかと思いますが、私はなるべくFlutterの基本に則り、StatefulWidget のdispose()を使うようにしています。「コードを見直してみる」の項目ではGetxController内で TextEditingControllerを立ち上げて、onClose()内で破棄(LoginControllerが使われていたGetPageRouteが破棄される呼び出されます)していますが、フレームワークの基本に寄せた方がコードの柔軟性を考えるといいのかなと思っています。

sh0sh0

ご回答ありがとうございます!
僕もフレームワークの基本に則って実装する方が、思わぬバグを生まなくて良いと思います。