[Flutter] formz+riverpodを使ったフォームの実装例

## はじめに
[formz](https://pub.dev/packages/formz) はFlutterにおけるフォーム入力+バリデーションの管理を簡略化する為のライブラリです。


この記事では、formzとriverpodを使って簡単なフォームを実装してみます。
まずはフォーム用の画面を用意します。

```dart
void main() {
  runApp(const MainApp());
}

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        body: Padding(
          padding: const EdgeInsets.all(32.0),
          child: Center(
            child: Column(
              children: <Widget>[
                TextField(
                  decoration: const InputDecoration(labelText: 'ユーザー名'),
                  keyboardType: TextInputType.emailAddress,
                  onChanged: (value) {
                  },
                ),
                TextField(
                  decoration: const InputDecoration(labelText: 'パスワード'),
                  onChanged: (value) {
                  },
                ),
                const SizedBox(
                  height: 20,
                ),
                ElevatedButton(
                  onPressed: () {
                  },
                  child: const Text('Submit'),
                ),
              ],
            ),
          ),
        ),
      ),
    );
  }
}
```


<img width="350" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/597113/97f310c3-b0f0-9d13-78ef-2de58a2192de.png">


## FormzInputの作成
formzを使ったフォームの実装を行っていきます。

formzではFormzInputというクラスを継承してフォームを管理するモデルを作成します。
まずは[README](https://github.com/VeryGoodOpenSource/formz#create-a-formzinput][README)に従って、FormzInputを用意します。

``` dart
// ユーザー名入力用
class UserNameInput extends FormzInput<String, UserNameInputError> {
  const UserNameInput.pure() : super.pure('');
  const UserNameInput.dirty({String value = ''}) : super.dirty(value);

  @override
  UserNameInputError? validator(String value) {
    return value.isEmpty ? UserNameInputError.empty : null;
  }
}

// ユーザー名入力エラー用
enum UserNameInputError {
  empty(errorText: '未入力です');

  const UserNameInputError({required this.errorText});

  final String errorText;
}


// パスワード入力用
class PasswordInput extends FormzInput<String, PasswordInputError> {
  const PasswordInput.pure() : super.pure('');
  const PasswordInput.dirty({String value = ''}) : super.dirty(value);

  @override
  PasswordInputError? validator(String value) {
    if (value.length < 8) return PasswordInputError.tooShort;
    if (!value.contains(RegExp(r'[0-9]'))) {
      return PasswordInputError.noDigits;
    }
    if (!value.contains(RegExp(r'[A-Z]'))) {
      return PasswordInputError.noUppercase;
    }
    if (!value.contains(RegExp(r'[a-z]'))) {
      return PasswordInputError.noLowercase;
    }
    return null;
  }
}

// パスワード入力エラー用
enum PasswordInputError {
  tooShort(errorText: '8文字以上入力して下さい'),
  noDigits(errorText: "数字が含まれていません"),
  noUppercase(errorText: "アルファベットの大文字が含まれていません"),
  noLowercase(errorText: "アルファベットの小文字が含まれていません");

  const PasswordInputError({required this.errorText});

  final String errorText;
}
```

FormzInput継承時の型指定で、そのフォームの入力値の型とエラーの型が決まります。

>`FormzInput<String, NameInputError>` 

一番目の型引数がフォーム入力値の型です。二番目がエラーの型で、これをバリデーションのエラー時に返却します。

コンストラクタには `pure` と `dirty` という2つのfactoryコンストラクタを用意します。
`pure` はフォームが未入力状態を表すコンストラクタです。 `super.pure('')` と親クラスのコンストラクタに値を渡すことでフォームの初期値を設定します。
`dirty` はフォームの入力後を表すコンストラクタになります。

バリデーションのロジックは `validator` メソッドをoverrideして記述します。
このメソッドの返却値の型は、継承時に指定したエラーの型をNull可にしたものになります。入力値が正常の場合にはnullを返却します。

## RiverpodのNotifierProviderの作成
Riverpodの[NotiferProvider](https://docs-v2.riverpod.dev/docs/providers/notifier_provider)を使ってフォームの状態管理を行います。

※Riverpodはv2のコード生成を利用しています。またフォーム全体をまとめるモデルクラスはfreezedを使っています

```dart
@freezed
class SampleForm with _$SampleForm {
  factory SampleForm({
    required UserNameInput userName,
    required PasswordInput password,
    required bool isValid,
  }) = _SampleForm;
}

@riverpod
class SampleFormController extends _$SampleFormController {
  @override
  SampleForm build() {
    return SampleForm(
        userName: const UserNameInput.pure(),
        password: const PasswordInput.pure(),
        isValid: false);
  }

  void onChangeUserName(String value) {
    final userName = UserNameInput.dirty(value: value);
    state = state.copyWith(
        userName: userName,
        isValid: Formz.validate([
          userName,
          state.password,
        ]));
  }

  void onChangePassword(String value) {
    final password = PasswordInput.dirty(value: value);
    state = state.copyWith(
        password: password,
        isValid: Formz.validate([
          password,
          state.userName,
        ]));
  }

  Future<void> submit() async {
    if (!state.isValid) return;

    // submit処理...
  }
```

NotiferProviderで、stateの初期状態を定義する `build` メソッドでは、先程用意したFormzInputの `pure`
を使って、初期状態を未入力として返却しています。

```dart
return SampleForm(
  userName: const UserNameInput.pure(),
  password: const PasswordInput.pure(),
  isValid: false);
```

また、入力を受け付けるメソッド、 `onChangeUserName` と `onChangePassword` では、 `dirty` で入力済みのFormzInputを生成し、その後バリデーションを実行して結果を `isValid` に代入しています。

```dart
final userName = UserNameInput.dirty(value: value);
state = state.copyWith(
  userName: userName,
  isValid: Formz.validate([
      userName,
      state.password,
]));
```

## UIにバリデーションの結果を反映する
最後にフォームから入力を監視して、バリデーションの結果をUIに反映したいと思います。

MainAppは以下のようになりました。

``` dart
class MainApp extends ConsumerWidget {
  const MainApp({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final sampleForm = ref.watch(sampleFormControllerProvider);

    return MaterialApp(
      home: Scaffold(
        body: Padding(
          padding: const EdgeInsets.all(32.0),
          child: Center(
            child: Column(
              children: <Widget>[
                TextField(
                  decoration: const InputDecoration(labelText: 'ユーザー名'),
                  keyboardType: TextInputType.emailAddress,
                  onChanged: ref
                      .read(sampleFormControllerProvider.notifier)
                      .onChangeUserName,
                ),
                if (sampleForm.userName.displayError != null)
                  Text(sampleForm.userName.displayError!.errorText,
                      style: TextStyle(
                        color: Theme.of(context).colorScheme.error,
                      )),
                TextField(
                  decoration: const InputDecoration(labelText: 'パスワード'),
                  onChanged: ref
                      .read(sampleFormControllerProvider.notifier)
                      .onChangePassword,
                ),
                if (sampleForm.password.displayError != null)
                  Text(sampleForm.password.displayError?.errorText ?? '',
                      style: TextStyle(
                        color: Theme.of(context).colorScheme.error,
                      )),
                const SizedBox(
                  height: 20,
                ),
                ElevatedButton(
                  onPressed: sampleForm.isValid
                      ? ref.read(sampleFormControllerProvider.notifier).submit
                      : null,
                  child: const Text('送信'),
                ),
              ],
            ),
          ),
        ),
      ),
    );
  }
}
```

<img width="350" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/597113/6b6137d0-05b9-3644-0469-0169064c3e2b.gif">


1. riverpodで入力を監視できるよう、`StatelessWidget` -> `ConsumerWidget` に変更しました。
2. バリデーションエラーがあった場合だけエラーテキストを表示するようにしています。
   ``` dart
   if (sampleForm.userName.displayError != null)
     Text(sampleForm.userName.displayError?.errorText ?? '',
       style: TextStyle(
         color: Theme.of(context).colorScheme.error,
     )),
   ```
  ※displayErrorは `dirty`の場合だけエラーを返却するので初期状態でエラーにならない
3. ボタンの活性/非活性状態を、 `isValid` で判定するようにしています。


## おわり

formz+riverpodを使った簡単なフォーム実装についてまとめてみました！