📚

簡単に複雑なフォームを作れるFlash Formについて紹介する

2024/09/14に公開

はじめに

ひっそりとFlutterでフォームを簡単に作成するためのパッケージであるFlashFormを開発しており、OSSとして公開していました。
ドキュメント等を書くのが億劫だったので、ずっと放置していたのですが、ChatGPTのo1がリリースされたので、o1にやらせたら楽に書けるんじゃないかということで、まずはFlash Formの紹介記事を書いてみようと思いました。

フォーム関連のパッケージとしては、Reactive Formsなどが有名で、私自身もそれらを使用していました。しかし、管理画面のような単純なフォームが多数ある場合、UI側のウィジェットを一つ一つ手動で書くのは面倒に感じていました。そこで、より素早く簡単にフォームを作成できるものを作りたいと考え、FlashFormというパッケージを作成しました。

できること

FlashFormは、以下のような特徴を持つフォーム作成パッケージです。

  • 自動的なフォーム生成: モデルの構造を定義するだけで、対応するフォームを自動生成します。これにより、手動でウィジェットを作成する手間を省くことができます。
  • カスタマイズ性: 各フィールドに対して、バリデーション、ラベル、デコレーター、入力形式などを柔軟に設定できます。
  • ネストされたフォームのサポート: 複雑な入れ子構造を持つモデルでも、それに対応したフォームを簡単に作成できます。
  • リストや動的フィールドのサポート: リスト形式のフィールドや、動的に追加・削除可能なフィールドをサポートしています。
  • データモデルとの連携: フォームの入力値をデータモデルに変換したり、既存のデータモデルからフォームを初期化することができます。

デモ

今回の記事で紹介する使い方に関するコードのデモを作成したみたので、こちらをチェックしてみてください。

使い方

FlashFormの使い方は次のとおりです。

  1. フォームのモデルであるModelSchemaを定義する。
  2. FlashModelFieldウィジェットにModelSchemaのインスタンスを渡す。

これだけでフォームを作成することができます。それぞれのステップについて、実際のコードを使って詳しく説明していきます。

1. ModelSchemaを定義する

まず、フォーム専用のモデルクラスであるModelSchemaを定義します。このModelSchemaにフォームに必要な設定を書き込むことで、フォームを作成できます。

Personクラスの定義

今回は、次のようなPersonというクラスに関するフォームを作成します。

class Person {
  String name;
  int age;

  Person({
    required this.name,
    required this.age,
  });
}

Personクラスは、名前と年齢を持つシンプルなデータモデルです。

PersonSchemaの定義

PersonクラスのModelSchemaとして、PersonSchemaを作成します。

class PersonSchema extends ModelSchema<Person> {
  PersonSchema() : super(parent: null);

  // フィールドの定義は後述
}

PersonSchemaModelSchemaを継承して作成します。ModelSchemaのジェネリックパラメータには、相互に変換したいデータモデルのクラスを指定します。今回はPersonクラスのフォームを作成したいので、Personを指定しています。

Note: ModelSchemaは木構造になっており、parentは親のSchemaを指定します。PersonSchemaはトップレベルのため、parentにはnullを渡します。

名前フィールドの追加

次に、名前入力用のフィールドを定義します。

class PersonSchema extends ModelSchema<Person> {
  PersonSchema() : super(parent: null);

  late final nameField = ValueSchema<String, String>(
    fieldFormat: TextFieldFormat(),
    decorators: [
      const DefaultValueDecorator(label: '名前'),
    ],
    validators: [
      RequiredValidator(),
    ],
    value: null,
    parent: this,
  );

  // 年齢フィールドの定義は後述
}

nameFieldValueSchemaとして定義します。ValueSchemaは単一の値を持つフィールドを表現するクラスです。

  • fieldFormat: フィールドの入力形式を指定します。ここではTextFieldFormat()を指定し、テキスト入力フィールドであることを示します。
  • decorators: フィールドのラベルや装飾を指定します。DefaultValueDecoratorを使って、ラベルを「名前」に設定しています。
  • validators: フィールドのバリデーションルールを指定します。RequiredValidator()を追加し、必須入力フィールドであることを示しています。
  • value: フィールドの初期値を指定します。ここではnullとしています。
  • parent: 親のSchemaを指定します。ここではthisPersonSchemaのインスタンス)を指定します。

年齢フィールドの追加

同様に、年齢入力用のフィールドを定義します。

late final ageField = ValueSchema<num, String>(
  fieldFormat: NumberFieldFormat(),
  decorators: [
    const DefaultValueDecorator(label: '年齢'),
  ],
  validators: [
    RequiredValidator(),
    RangeValidator(min: 0, max: 120),
  ],
  value: null,
  parent: this,
);
  • NumberFieldFormat()を指定し、数値入力フィールドであることを示します。
  • バリデーションとしてRangeValidatorを追加し、0から120の範囲に制限しています。

フィールドリストの定義

ModelSchemaでは、fieldsプロパティをオーバーライドして、含まれるフィールドのリストを返します。
このfieldsに設定されたものが、フォームとして表示されます。


List<FieldSchema> get fields => [
  nameField,
  ageField,
];

データモデルとの変換

ModelSchemaでは、フォームの値とデータモデルとの相互変換を行うために、fromModeltoModelメソッドを実装します。
この変換を定義しておくことで、Personモデルの更新をしたい場合などに、既存のデータからフォームを初期化することができます。


void fromModel(Person model) {
  nameField.updateValue(model.name);
  ageField.updateValue(model.age);
}


Person toModel() {
  return Person(
    name: nameField.value ?? '',
    age: ageField.value?.toInt() ?? 0,
  );
}
  • fromModel: 既存のPersonオブジェクトから、フォームのフィールドを初期化します。
  • toModel: フォームの入力値から、Personオブジェクトを生成します。

2. FlashModelFieldウィジェットにModelSchemaを渡す

次に、定義したPersonSchemaを使ってフォームを表示します。

メインのウィジェットでフォームを表示

重要なのはFlashModelField(form: form)で定義したフォームをFlashModelFieldに渡すだけでWidgetを構築することができます。

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

  
  State<MyHomePage> createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  final form = PersonSchema();

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Person Form'),
      ),
      body: SingleChildScrollView(
        padding: const EdgeInsets.all(16.0),
        child: Column(
          children: [
            FlashModelField(form: form),
            Padding(
              padding: const EdgeInsets.all(32.0),
              child: ElevatedButton(
                onPressed: () {
                  if (form.validate()) {
                    final person = form.toModel();
                    // personオブジェクトを使って何かを行う
                  }
                },
                child: const Text('送信'),
              ),
            ),
          ],
        ),
      ),
    );
  }
}
  • FlashModelField: ModelSchemaを受け取り、それに対応するフォームを自動的に生成するウィジェットです。
  • form.validate(): フォームのバリデーションを行います。
  • form.toModel(): フォームの入力値からデータモデルを生成します。

これで、名前と年齢を入力できるシンプルなフォームが完成しました。

応用編: 複雑なフィールドの追加

FlashFormは、リスト形式のフィールドやネストされたモデルもサポートしています。ここでは、いくつかの応用的なフィールドを追加してみます。

スキルの複数選択フィールドを追加

Personクラスにスキルのリストを追加し、それに対応するフォームフィールドを作成します。
スキルは得意な技術のことで、チェックボックスから選択する仕様です。

skillsフィールドの定義

チェックボックスから複数の値を選択して、リストを返す場合、MultiSelectFieldFormat.checkboxを使います。

late final skillsField = ValueSchema<List<String>, List<String>>(
  fieldFormat: MultiSelectFieldFormat.checkbox(
    options: ['Dart', 'Flutter', 'Java', 'Kotlin'],
    toDisplay: (value) => value,
  ),
  decorators: [
    const DefaultValueDecorator(label: 'スキル'),
  ],
  value: [],
  parent: this,
);
  • MultiSelectFieldFormat.checkbox: 複数選択可能なチェックボックス形式の入力フィールドを定義します。
  • options: 選択肢を指定します。

フィールドリストへの追加とデータモデルとの変換の箇所も更新する必要があります。

趣味のリストフィールドを追加

次にユーザーが複数の趣味を入力できるように、リスト形式のフィールドを追加します。
趣味は、自由入力形式でテキストフィールドを、自由に追加したり削除したりすることができるような仕様です。

hobbiesフィールドの定義

この場合、ValueSchemaではなくListSchemaを利用します。
ListSchemaはリスト形式のフィールドを追加したり削除したりするフォーム専用のSchemaになります。
特徴としてはchildFactoryという、リスト形式のフィールドの要素として、どんなフォームを使うかを指定するための関数の設定が必要ということです。

late final hobbiesField = ListSchema<String, String>(
  children: [],
  decorators: [
    const DefaultListDecorator(label: '趣味'),
  ],
  childFactory: (value, parent) {
    return ValueSchema<String, String>(
      fieldFormat: TextFieldFormat(),
      value: value,
      parent: parent,
    );
  },
  parent: this,
);
  • ListSchema: リスト形式のフィールドを定義します。
  • childFactory: リスト内の各アイテムのフィールドを生成するための関数を指定します。

まとめ

これで、FlashFormを使って複雑なフォームも簡単に作成できることがわかりました。ModelSchemaを定義し、それをFlashModelFieldウィジェットに渡すだけで、対応するフォームが自動的に生成されます。

さらに、リスト形式のフィールドやネストされたモデル、複数選択フィールドなど、さまざまな入力形式にも対応しています。まだまだ、入力形式が少ないのでこれから充実させていく予定です。もしも追加して欲しい、形式のフォームがあれば教えてください。

詳細や他の機能については、GitHubリポジトリを参照してください。これから、ドキュメント等を追加していこうとおもいます。

今後もFlashFormの機能拡張や改善を行っていく予定です。ご意見やフィードバックがありましたら、ぜひお寄せください。

サンプルの全体的なコード

最後に、今回使用したサンプルコードの全体を掲載します。

モデルクラス

class Person {
  String name;
  int age;
  List<String> skills;
  List<String> hobbies;

  Person({
    required this.name,
    required this.age,
    required this.skills,
    required this.hobbies,
  });
}

フォームスキーマ

class PersonSchema extends ModelSchema<Person> {
  PersonSchema() : super(parent: null);

  late final nameField = ValueSchema<String, String>(
    fieldFormat: TextFieldFormat(),
    decorators: [
      const DefaultValueDecorator(label: '名前'),
    ],
    validators: [
      RequiredValidator(),
    ],
    value: null,
    parent: this,
  );

  late final ageField = ValueSchema<num, String>(
    fieldFormat: NumberFieldFormat(),
    decorators: [
      const DefaultValueDecorator(label: '年齢'),
    ],
    validators: [
      RequiredValidator(),
      RangeValidator(min: 0, max: 120),
    ],
    value: null,
    parent: this,
  );

  late final skillsField = ValueSchema<List<String>, List<String>>(
    fieldFormat: MultiSelectFieldFormat.checkbox(
      options: ['Dart', 'Flutter', 'Java', 'Kotlin'],
      toDisplay: (value) => value,
    ),
    decorators: [
      const DefaultValueDecorator(label: 'スキル'),
    ],
    value: [],
    parent: this,
  );

  late final hobbiesField = ListSchema<String, String>(
    children: [],
    decorators: [
      const DefaultListDecorator(label: '趣味'),
    ],
    childFactory: (value, parent) {
      return ValueSchema<String, String>(
        fieldFormat: TextFieldFormat(),
        value: value,
        parent: parent,
      );
    },
    parent: this,
  );

  
  List<FieldSchema> get fields => [
        nameField,
        ageField,
        skillsField,
        hobbiesField,
      ];

  
  void fromModel(Person model) {
    nameField.updateValue(model.name);
    ageField.updateValue(model.age);
    skillsField.updateValue(model.skills);
    hobbiesField.updateValue(model.hobbies);
  }

  
  Person toModel() {
    return Person(
      name: nameField.value ?? '',
      age: ageField.value?.toInt() ?? 0,
      skills: skillsField.value ?? [],
      hobbies: hobbiesField.value ?? [],
    );
  }
}

メインファイル

import 'package:flutter/material.dart';
import 'package:flash_form/flash_form.dart';

void main() {
  runApp(const MyApp());
}

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

  // アプリケーションのルートウィジェット
  
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'FlashForm Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: const MyHomePage(),
    );
  }
}

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

  
  State<MyHomePage> createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  final form = PersonSchema();

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Person Form'),
      ),
      body: SingleChildScrollView(
        padding: const EdgeInsets.all(16.0),
        child: Column(
          children: [
            FlashModelField(form: form),
            Padding(
              padding: const EdgeInsets.all(32.0),
              child: ElevatedButton(
                onPressed: () {
                  if (form.validate()) {
                    final person = form.toModel();
                    // personオブジェクトを使って何かを行う
                    print('Name: ${person.name}');
                    print('Age: ${person.age}');
                    print('Skills: ${person.skills}');
                    print('Hobbies: ${person.hobbies}');
                  }
                },
                child: const Text('送信'),
              ),
            ),
          ],
        ),
      ),
    );
  }
}

以上で、FlashFormを使ったフォームの作成方法についての解説を終わります。

Discussion