【Flutter】フォームの実装を便利にするパッケージを作った

こんにちは。広瀬マサルです。

Formの実装のベストプラクティスがわかりません。

新しくデータを作るときやデータを更新するときなど効率的で簡単なフォームの実装方法に戸惑っていました。

なので、

**フォームの実装を簡易的に定義するパッケージ**

を作りました。

フォームのデータの取り扱いだけでなく、デザインも合わせて取り扱うことが可能です。

使い方をまとめたので興味ある方はぜひ使ってみてください！

# katana_form

https://pub.dev/packages/katana_form

# はじめに

Formの実装はアプリにとってとても重要な要素です。

アプリにユーザーの情報を入力させるインターフェースとして欠かせないものになっています。

Formの実装をシンプルにすることはアプリの実装速度や安全性を高めることに非常に役立ちます。

Flutterでは`Form`や`TextFormField`などの`FormField`系のWidgetが用意されています。

しかし、データの取扱には触れておらず、データの取得や保存を行うには各々の状態管理システムに対応した実装を行う必要があります。

また、`InputDecoration`でのデザインを変更できるようになっていますが、設定項目が多いため`ButtonStyle`のように簡略化して利用したいです。

そのため下記のようなパッケージを作成しました。

- `FormController`にフォームに利用するための値を保存しそれを渡すことによりフォームを利用した入出力を可能にする
- すべてのフォームWidgetで`FormStyle`を利用可能にし、デザインの指定を統一。デザインを簡単に統一することが可能。

下記のように簡単に書けます。

```dart

final form = FormController(<String, dynamic>{});

return Scaffold(
  appBar: AppBar(title: const Text("App Demo")),
  body: ListView(
    padding: const EdgeInsets.symmetric(vertical: 32),
    children: [
      const FormLabel("Name"),
      FormTextField(
        form: form,
        initialValue: form.value["name"],
        onSaved: (value) => {...form.value, "name": value},
      ),
      const FormLabel("Description"),
      FormTextField(
        form: form,
        minLines: 5,
        initialValue: form.value["description"],
        onSaved: (value) => {...form.value, "description": value},
      ),
      FormButton(
        "Submit",
        icon: Icon(Icons.check),
        onPressed: () {
          final value = form.validate(); // Validate and get form values
          if (value == null) {
            return;
          }
          print(value);
          // Save value as is.
        },
      ),
    ]
  )
);
```

またこのパッケージは[freezed](https://pub.dev/packages/freezed)を使うとにより安全にコードを書くことができます。

# インストール

下記のパッケージをインポートします。

```bash
flutter pub add katana_form
```

# 実装

## コントローラー作成

まず`FormController`に初期値を入れて定義します。

新規データ作成の場合は空のオブジェクトを渡し、既存データの場合はデータベースから読み込んだ値を入れてください。

この例では`Map<String, dynamic>`でデータベース用のデータの取扱をしている場合のものです。

```dart
// 新規データ
final form = FormController(<String, dynamic>{});

// 既存データ
final Map<String, dynamic> data = getRepositoryData();
final form = FormController(data);
```

これを`StatefulWidget`などの状態管理の仕組みで保持します。

FormControllerは`ChangeNotifier`を継承しているので、[riverpod](https://pub.dev/packages/riverpod)の`ChangeNotifierProvider`などと合わせて利用することも可能です。

## フォーム実装

`Form`Widgetの設置は必要ありません。

作成した`FormController`を渡すだけです。`FormController`を渡す場合は`onSaved`も一緒に渡す必要があります。（onChangedのみを利用したい場合は`FormController`を渡す必要はありません）

`initialValue`に初期値を渡してください。初期値を渡す場合`FormController.value`から取得した値をそのまま渡してください。

`onSaved`は現在入力されている値がコールバックとして渡されるので変更した`FormController.value`の値をそのまま返すようにしてください。

```dart
FormTextField(
  form: form,
  initialValue: form.value["description"],
  onSaved: (value) => {...form.value, "description": value},
),
```

## フォームのバリデーションと保存

`FormController.validate`を実行することでフォームのバリデーションと保存を行うことができます。

まずバリデーションを行ない、失敗した場合は`null`が返されます。

成功した場合は各`Form`Widgetの`onSaved`で変更された値が返ってきます。

その値を元にデータベースの更新を行ってください。

```dart
final value = form.validate(); // Validate and get form values
if (value == null) {
  return;
}
print(value);
// Save value as is.
```

## サンプルコード

上記の一連の流れをまとめて書くと下記のようになります。

フォームのページを破棄する時`FormController`も`dispose`で一緒に破棄してください。

```dart
class FormPage extends StatefulWidget {
  const FormPage({super.key});

  @override
  State<StatefulWidget> createState() => FormPageState();
}

class FormPageState extends State<FormPage> {
  final form = FormController(<String, dynamic>{});

  @override
  void dispose() {
    super.dispose();
    form.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text("App Demo")),
      body: ListView(
        padding: const EdgeInsets.symmetric(vertical: 32),
        children: [
          const FormLabel("Name"),
          FormTextField(
            form: form,
            initialValue: form.value["name"],
            onSaved: (value) => {...form.value, "name": value},
          ),
          const FormLabel("Description"),
          FormTextField(
            form: form,
            minLines: 5,
            initialValue: form.value["description"],
            onSaved: (value) => {...form.value, "description": value},
          ),
          const SizedBox(height: 16),
          FormButton(
            "Submit",
            icon: Icon(Icons.add),
            onPressed: () {
              final value = form.validate(); // Validate and get form values
              if (value == null) {
                return;
              }
              print(value);
              // Save value as is.
            },
          ),
        ],
      ),
    );
  }
}
```

[freezed](https://pub.dev/packages/freezed)を使うとにより安全にコードを書くことができます。

```dart
@freezed
abstract class FormValue with _$FormValue {
  const factory FormValue({
    String? name,
    String? description,
  }) = _FormValue;
}

class FormPage extends StatefulWidget {
  const FormPage({super.key});

  @override
  State<StatefulWidget> createState() => FormPageState();
}

class FormPageState extends State<FormPage> {
  final form = FormController(FormValue());

  @override
  void dispose() {
    super.dispose();
    form.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text("App Demo")),
      body: ListView(
        padding: const EdgeInsets.symmetric(vertical: 32),
        children: [
          const FormLabel("Name"),
          FormTextField(
            form: form,
            initialValue: form.value.name,
            onSaved: (value) => form.value.copyWith(name: value),
          ),
          const FormLabel("Description"),
          FormTextField(
            form: form,
            minLines: 5,
            initialValue: form.value.description,
            onSaved: (value) => form.value.copyWith(description: value),
          ),
          const SizedBox(height: 16),
          FormButton(
            "Submit",
            icon: Icon(Icons.add),
            onPressed: () {
              final value = form.validate(); // Validate and get form values
              if (value == null) {
                return;
              }
              print(value);
              // Save value as is.
            },
          ),
        ],
      ),
    );
  }
}
```

# スタイルの変更

各`Form`Widgetのスタイルを`FormStyle`でまとめて変更することが可能です。

デフォルトだとプレーンなスタイルになっていますが、下記のように指定するとMaterialデザインの枠付きのスタイルに変更されます。

```dart
FormTextField(
  form: form,
  initialValue: form.value["name"],
  onSaved: (value) => {...form.value, "name": value},
  style: FormStyle(
      border: OutlineInputBorder(),
      padding: const EdgeInsets.symmetric(horizontal: 16),
      contentPadding: const EdgeInsets.all(16)),
),
```

# FormWidgetの種類

現在利用可能なFormWidgetは以下です。

随時追加中。

- `FormTextField`
    - テキストを入力するためのフィールド
- `FormDateTimeField`
    - 日付と時間をFlutterのダイアログで選択・入力するフィールド
- `FormDateField`
    - 日付（月日）を選択肢から選択するフィールド
- `FormNumField`
    - 数値を選択肢から選択するフィールド。
- `FormEnumField`
    - Enumの定義から選択するフィールド。
- `FormMapField`
    - Mapを渡してその選択肢から選択するフィールド。

またFormを補助するWidgetは以下です。

- `FormLabel`
    - フォームのラベル部分を別途表示します。Dividerとしての役目もあります。
- `FormButton`
    - フォーム用の確定やキャンセルボタンに利用します。
    - FormStyleを利用可能。

# おわりに

自分で使う用途で作ったものですが実装の思想的に合ってそうならぜひぜひ使ってみてください！

また、[こちら](https://github.com/mathrunet/flutter_masamune)にソースを公開しているのでissueやPullRequestをお待ちしてます！

また仕事の依頼等ございましたら、私の[Twitter](https://twitter.com/mathru)や[Webサイト](https://mathru.net)で直接ご連絡をお願いいたします！

https://mathru.net/ja/contact

# GitHub Sponsors

スポンサーを随時募集してます。ご支援お待ちしております！

https://github.com/sponsors/mathrunet