2022-11-13

【Flutter】Katana Form

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

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

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

なので、

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

を作りました。

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

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

katana_form

Package to provide FormController to define the use of forms and FormStyle to unify the look and feel of forms.
https://pub.devhttps://pub.dev
title

はじめに

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

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

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

FlutterではFormTextFormFieldなどのFormField系のWidgetが用意されています。

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

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

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

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

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


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を使うとにより安全にコードを書くことができます。

インストール

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

flutter pub add katana_form

実装

コントローラー作成

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

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

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

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

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

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

FormControllerはChangeNotifierを継承しているので、riverpodChangeNotifierProviderなどと合わせて利用することも可能です。

フォーム実装

FormWidgetの設置は必要ありません。

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

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

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

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

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

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

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

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

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


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

サンプルコード

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

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

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を使うとにより安全にコードを書くことができます。

@freezed
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.
            },
          ),
        ],
      ),
    );
  }
}

スタイルの変更

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

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

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を利用可能。

おわりに

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

また、こちらにソースを公開しているのでissueやPullRequestをお待ちしてます!

また仕事の依頼等ございましたら、私のTwitterWebサイトで直接ご連絡をお願いいたします!

FlutterやUnityを使ったアプリ開発やアプリの紹介。制作した楽曲・映像の紹介。画像や映像素材の配布。仕事の受注なども行っています。
https://mathru.nethttps://mathru.net
title

GitHub Sponsors

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

Developed the katana/masamune framework, which has dramatically improved the overall efficiency of Flutter-based application development.
https://github.comhttps://github.com
title