🙅‍♀️

RiverpodでFormのValidationをやりたい!

2023/01/25に公開

EnumとGlobalKeyを使う

前回、StatefulWidgetの機能を使って、FormのValidationをやってたのですが、これはよくないよね〜ということで、頑張ってコード書いていい感じのコードが書けましたので記事にしようと思いました。

  • 今回やったこと!
    • Riverpod2.0を使用.
    • Dart2.17以降のEnumを使用.
    • 外部ファイルに分けたりして動作検証してみた。

こんなアプリができました!

Provider後で、別ファイルに分けたので動画と異なる部分があります🙇‍♂️
https://youtu.be/KMdMDPWqNwE

Demoアプリのソース

serviceディレクトリを作成して、enumのファイルと、Providerのファイルを作成してください。

あんまり使ったことないのですけど、Enumを今回エラーのメッセージを呼び出すのに使いました!
以前は、extensionを書いて、長いコードを書かないと使えなかったみたいです!
やってることは、単純でString型の定数が2個入ってるだけ。

service/error_message.dart
enum ErrorMessage {
  formOne('EnumのError1です〜!!!!'),
  formTwo('EnumのError2です〜!!!!'),
  ;//最後にセミコロンつけないとエラーが出る!

  const ErrorMessage(this.ErrorMsg);
  final String ErrorMsg;
}

次に、GlobalKeyとTextEditingControllerを使うための、Providerを定義します。

service/provider.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

// GlobalKeyを状態としてもつProvider.
final formKeyProvider = Provider((ref) => GlobalKey<FormState>());
// FormOneのProvider.
final textOneProvider = StateProvider.autoDispose((ref) {
  return TextEditingController(text: '');
});
// FormTwoのProvider.
final textTwoProvider = StateProvider.autoDispose((ref) {
  return TextEditingController(text: '');
});

GlobalKeyとは?

https://api.flutter.dev/flutter/widgets/GlobalKey-class.html
翻訳すると...

アプリ全体で一意となるキー。

グローバルキーは、要素を一意に識別します。グローバル・キーは、BuildContextなど、それらの要素に関連する他のオブジェクトへのアクセスを提供します。StatefulWidgetsの場合、グローバルキーは、Stateへのアクセスも提供します。

グローバルキーを持つウィジェットは、ツリー内のある位置から別の位置に移動したときにサブツリーを再ペアレントします。サブツリーを再ペアリングするために、ウィジェットはツリー内の古い場所から削除されたのと同じアニメーションフレームでツリー内の新しい場所に到着する必要があります。

グローバルキーを使用した要素の再保持は、比較的コストがかかります。この操作は、関連するStateとそのすべての子孫に対してState.deactivateの呼び出しをトリガーし、InheritedWidgetに依存するすべてのWidgetを強制的に再構築させるからです。

上記の機能が不要な場合は、代わりにKey、ValueKey、ObjectKey、UniqueKeyを使用することを検討してください。

同じグローバルキーを持つ2つのウィジェットを同時にツリーに含めることはできません。これを実行しようとすると、実行時にアサートされます。

落とし穴

GlobalKeyは、ビルドのたびに再作成されるべきではない。それらは通常、例えばStateオブジェクトによって所有される長寿命なオブジェクトであるべきです。

ビルドごとに新しい GlobalKey を作成すると、古いキーに関連付けられたサブツリーの状態が破棄され、新しいキーのために新しい新鮮なサブツリーが作成されます。これはパフォーマンスに悪影響を与えるだけでなく、サブツリー内のウィジェットで予期せぬ動作を引き起こす可能性があります。たとえば、サブツリー内の GestureDetector は、ビルドごとに再作成されるため、進行中のジェスチャーを追跡することができなくなります。

その代わりに、StateオブジェクトにGlobalKeyを所有させ、State.initStateのようなビルドメソッドの外でインスタンス化するのが良い方法です。

こちらもご覧ください。

ウィジェットがキーを使用する方法の詳細については、Widget.keyでの議論を参照してください。

アプリを実行するコード

main.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:ui_sample/service/error_message.dart';
import 'package:ui_sample/service/provider.dart';

void main() {
  runApp(
    const ProviderScope(child: MyApp()),
  );
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  static const String _title = 'Flutter Code Sample';

  
  Widget build(BuildContext context) {
    return const MaterialApp(
      restorationScopeId: 'app',
      title: _title,
      home: FormExample(),
    );
  }
}

class FormExample extends ConsumerWidget {
  const FormExample({
    Key? key,
  }) : super(key: key);

  
  Widget build(BuildContext context, WidgetRef ref) {
    // GlobalKeyをを呼び出す.
    final globalKey = ref.watch(formKeyProvider);
    // TextEditingControllerを呼び出す.
    final oneC = ref.watch(textOneProvider.notifier).state;
    final twoC = ref.watch(textTwoProvider.notifier).state;
    // Enumを外部ファイルから呼び出す.
    final enumErrorOne = ErrorMessage.formOne.ErrorMsg;
    final enumErrorTwo = ErrorMessage.formTwo.ErrorMsg;

    return Scaffold(
      appBar: AppBar(
        title: Text('FormTest'),
      ),
      body: Form(
        key: globalKey,
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.center,
          children: [
            TextFormField(
              controller: oneC,
              validator: (value) {
                if (value == null || value.isEmpty) {
                  return enumErrorOne; // エラーメッセージOne.
                }
              },
            ),
            TextFormField(
              controller: twoC,
              validator: (value) {
                if (value == null || value.isEmpty) {
                  return enumErrorTwo; // エラーメッセージTwo.
                }
              },
            ),
            Padding(
              padding: const EdgeInsets.symmetric(vertical: 16.0),
              child: ElevatedButton(
                onPressed: () {
                  if (globalKey.currentState!.validate()) {
                    print(oneC.text);
                    print(twoC.text);
                    ScaffoldMessenger.of(context).showSnackBar(
                      const SnackBar(content: Text('送信完了')),
                    );
                  }
                },
                child: const Text('送信'),
              ),
            ),
          ],
        ),
      ),
    );
  }
}

まとめ

外部ファイルに書いたときは、なんでもRiverpodのProviderで呼び出そうとしてしまうのですが、それをやらなくてもEnumもgo_routerも呼び出せます!
仕事じゃなければ、なんでもRiverpodじゃなくてもいいと思われます。
個人の意見ですがね💁

追加した記事

知り合いの人から、enumにエラーメッセージを書く必要はないことを言われましたので、ロジック書いたクラスをView Modelで状態管理して、View側にエラーが出たら赤いエラーメッセージを表示するロジックにしました。

enumを使ってますが、これは状態を切り替えるだけのために使ってます。

model/form_state.dart
// 入力フォームの状態用のenum
enum FormStatus {
  initial,
  valid,
  invalid,
}

こちらは正規表現を使うロジックを書いたクラスです。View Modelを使ってsetStateの代わりに画面を更新と状態の管理に使います。

model/form_logic.dart
// フォームのバリデーションロジックを定義するクラス
class FormLogic {
  // 電話番号のバリデーション
  String? phoneNumberValidator(String? value) {
    // 正規表現を使って、数字のみかどうかをチェック
  final regex = RegExp(r'^\d+$');
  // 正規表現にマッチしない場合は、エラーメッセージを返す
  if (!regex.hasMatch(value!)) {
    return '電話番号は数字のみで入力してください';
  }
  // 電話番号は12桁まで制限をかける
  if (value.length > 12) {
    return '電話番号は最大12桁までです';
  }
  // エラーがない場合はnullを返す
  return null;
}
  // パスワードのバリデーション
  String? passwordValidator(String? value) {
    // 正規表現を使って、英語の大文字、小文字、数字を含む8文字以上かどうかをチェック
    final regex = RegExp(r'^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)[A-Za-z\d]{8,}$');
    // 正規表現にマッチしない場合は、エラーメッセージを返す
    if (!regex.hasMatch(value!)) {
      return 'パスワードは英語の大文字、小文字、数字を含む8文字以上である必要があります';
    }
    // エラーがない場合はnullを返す
    return null;
  }
}

こちらが、Viewにエラーの状態を通知するViewModelです。わざわざView Modelに書くべきなのかと悩んだりしましたね。

model/form_controller.dart
import 'package:flutter/material.dart';
import 'package:ore_chans_app/widget_cookbook/ui/form_validation/model/form_logic.dart';
import 'package:ore_chans_app/widget_cookbook/ui/form_validation/model/form_state.dart';
import 'package:riverpod/riverpod.dart';

final formVieModelProvider =
    NotifierProvider<FormViewModelNotifier, FormStatus>(
        FormViewModelNotifier.new);

// FormStatusのenumを型に指定する
class FormViewModelNotifier extends Notifier<FormStatus> {
  
  build() {
    // 初期値を設定
    return FormStatus.initial;
  }

  // FormLogicクラスのインスタンスを生成。関数を呼び出すために必要
  final FormLogic _formLogic = FormLogic();
  // formKeyは、Formの状態を管理するためのキー
  final GlobalKey<FormState> formKey = GlobalKey<FormState>();

  // この関数はボタンが押された気に、入力フォームのバリデーションを行う
  void validateForm() {
    if (formKey.currentState?.validate() == true) {
      state = FormStatus.valid;
    } else {
      state = FormStatus.invalid;
    }
  }

  // このゲッターは、FormValidationクラスのTextFormFieldのvalidatorに渡す
  String? phoneNumberValidator(String? value) =>
      _formLogic.phoneNumberValidator(value);
  String? passwordValidator(String? value) =>
      _formLogic.passwordValidator(value);
}

View側に書くと、buildメソッドの中に書くので、パフォーマンスが落ちてしまうようです。

build メソッドの中に関数を定義するのが推奨されない主な理由は以下の通りです。

  1. パフォーマンス:
    build メソッドはフレームごと、またはウィジェットの状態が変更されるたびに頻繁に呼び出されます。そのため、build の中に関数を書くと、その関数が新しく定義されるたびに追加のメモリとプロセッサの時間が消費される可能性があります。

  2. 再利用性:
    build メソッドの中に関数を定義すると、その関数はローカルスコープに制限されるため、他の場所での再利用が難しくなります。関数を外部で定義することで、必要に応じて他のウィジェットやクラスからもアクセスすることができます。

  3. 可読性:
    もしbuild メソッドが長くなると、コードの可読性が低下します。関数を外部で定義することで、それぞれの関数が何をするのかを明確にし、build メソッドの可読性を保つことができます。

  4. テスタビリティ:
    build メソッドの外に関数を持ってくることで、ユニットテストを書きやすくなります。特に、何らかのロジックや計算を行う関数の場合、それを別の関数として分離しておくことで、単独でテストすることが容易になります。

  5. 状態の不変性:
    関数内で状態を変更する可能性がある場合、それがbuild メソッドの中にあると、ウィジェットの再構築中に状態が変更されることになり、これは一般的に推奨されない動作です。

以上のような理由から、build メソッドの中で関数を定義することは避けるべきです。代わりに、関数はbuild メソッドの外、同じクラス内や他のユーティリティクラス内に定義するのがベストプラクティスです。

これがView側のコードです。main.dartでimportすると使用できます。

view/form_validation.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:ore_chans_app/widget_cookbook/ui/form_validation/state/form_controller.dart';

/// [入力フォームのバリデーション]
class FormValidation extends ConsumerWidget {
  const FormValidation({Key? key}) : super(key: key);

  
  Widget build(BuildContext context, WidgetRef ref) {
    final controller = ref.read(formVieModelProvider.notifier);

    return Scaffold(
      appBar: AppBar(
          backgroundColor: Colors.blueGrey,
          title: const Text('Form Validation')),
      body: Padding(
        padding: const EdgeInsets.all(16.0),
        child: Form(
          key: controller.formKey,
          child: Column(
            children: [
              TextFormField(
                validator: controller.phoneNumberValidator,
                decoration: const InputDecoration(labelText: 'Phone Number'),
                keyboardType: TextInputType.phone,
              ),
              TextFormField(
                validator: controller.passwordValidator,
                decoration: const InputDecoration(labelText: 'Password'),
                obscureText: true,
              ),
              ElevatedButton(
                onPressed: controller.validateForm,
                child: const Text('送信'),
              ),
            ],
          ),
        ),
      ),
    );
  }
}

こんな感じの動作で動きます

Discussion