✍️

【Flutter】入力フォームの実装方法を複数試してみる

2024/12/16に公開

初めに

今回は入力フォームの実装について、複数のパターンで実装して比較、検討してみたいと思います。
大前提として、すべてのケースにおいて適用できる正解の実装はないかなと思うので、参考程度にしていただいて、各プロジェクトでどのような実装にするかを個別に考えていただければと思います。

記事の対象者

  • Flutter 学習者
  • 入力フォームを実装したい方

目的

今回の目的は先述の通り、複数のパターンで入力フォームの実装を行い、その実装を比較、検討することです。今回は特にフォームのバリデーション、特にフロントエンドバリデーションに着目してみていきたいと思います。

全項目入力完了時にバリデーション、エラー表示

まずはすべての項目の入力が完了した段階でバリデーションを実行し、エラーがあれば表示するというケースについて考えてみます。コードは以下の通りです。

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

class FormSample extends HookWidget {
  const FormSample({super.key});

  
  Widget build(BuildContext context) {
    final emailController = useTextEditingController();
    final passwordController = useTextEditingController();

    final formKey = useMemoized(() => GlobalKey<FormState>());

    // 送信ボタンの処理
    void submit() {
      if (formKey.currentState!.validate()) {
        formKey.currentState!.save();
        ScaffoldMessenger.of(context).showSnackBar(
          const SnackBar(content: Text('ログイン成功!')),
        );
      } else {
        ScaffoldMessenger.of(context).showSnackBar(
          const SnackBar(content: Text('入力内容を確認してください。')),
        );
      }
    }

    return Scaffold(
      appBar: AppBar(
        title: const Text('ログインフォーム'),
      ),
      body: Padding(
        padding: const EdgeInsets.all(16.0),
        child: Form(
          key: formKey,
          child: Column(
            children: [
              TextFormField(
                controller: emailController,
                decoration: const InputDecoration(labelText: 'メールアドレス'),
                keyboardType: TextInputType.emailAddress,
                validator: (value) {
                  if (value == null || value.isEmpty) {
                    return 'メールアドレスを入力してください。';
                  }
                  if (!RegExp(r'^[^@]+@[^@]+\.[^@]+').hasMatch(value)) {
                    return '有効なメールアドレスを入力してください。';
                  }
                  return null;
                },
              ),
              const SizedBox(height: 16.0),
              TextFormField(
                controller: passwordController,
                decoration: const InputDecoration(labelText: 'パスワード'),
                obscureText: true,
                validator: (value) {
                  if (value == null || value.isEmpty) {
                    return 'パスワードを入力してください。';
                  }
                  if (value.length < 6) {
                    return 'パスワードは6文字以上にしてください。';
                  }
                  return null;
                },
              ),
              const SizedBox(height: 32.0),
              ElevatedButton(
                onPressed: submit,
                child: const Text('ログイン'),
              ),
            ],
          ),
        ),
      ),
    );
  }
}

コードを詳しくみていきます。

以下では useTextEditingController でメールとパスワードのテキストの状態を保持しています。

final emailController = useTextEditingController();
final passwordController = useTextEditingController();

以下では Form に渡すための formKey を作成しています。
特に変更する必要もないため、useMemoized で作成しています。

// フォームのグローバルキーを一度だけ生成
final formKey = useMemoized(() => GlobalKey<FormState>());

以下ではユーザーがすべての入力項目を入力して、「送信」ボタンを押した際の処理を実装しています。
formKey.currentState!.validate() ではそれぞれのテキストフィールドの内容が適当かどうかを判定しています。
そしてバリデーションの結果によって表示させる SnackBar を変更しています。

// 送信ボタンの処理
void submit() {
  if (formKey.currentState!.validate()) {
    formKey.currentState!.save();
    ScaffoldMessenger.of(context).showSnackBar(
      const SnackBar(content: Text('ログイン成功!')),
    );
  } else {
    ScaffoldMessenger.of(context).showSnackBar(
      const SnackBar(content: Text('入力内容を確認してください。')),
    );
  }
}

以下では Form に対して作成した formKey を渡しており、この Form の子要素にある TextFormField のバリデーションを一括して行うことができるようになります。

child: Form(
  key: formKey,

以下では TextFormFieldvalidator に対してメールアドレスのバリデーションを定義しています。

validator: (value) {
  if (value == null || value.isEmpty) {
    return 'メールアドレスを入力してください。';
  }
  if (!RegExp(r'^[^@]+@[^@]+\.[^@]+').hasMatch(value)) {
    return '有効なメールアドレスを入力してください。';
  }
  return null;
},

以下では ElevatedButtononPressed に先ほど作成した submit を渡しています。
これで、ボタンを押した段階でバリデーションが実行され、必要に応じてエラーが表示されます。

ElevatedButton(
  onPressed: submit,
  child: const Text('ログイン'),
),

上記のコードを実行すると以下のようになります。

https://youtube.com/shorts/tuTvuf703jY

このような実装のメリット・デメリットを考えてみます。

メリット

  1. シンプルな実装
    バリデーションが「送信ボタン」の onPressed イベントで一括して行われるため、コードがシンプルになるかと思います

  2. パフォーマンス効率
    入力変更時にバリデーションを実行しないため、リアルタイムバリデーションに比べてパフォーマンスへの負荷が低くなります

  3. 入力時の負荷軽減
    ユーザーが入力中にエラーメッセージが表示されることがないため、入力時の負荷を下げることができます

デメリット

  1. 入力中のフィードバック不足
    先ほどメリットとして挙げた「入力時の負荷軽減」の裏返しになりますが、入力している段階でユーザーは入力内容に誤りがないかどうかを判断できません。

最もわかりやすいケースが以下のように入力項目が多く、一度に多くのエラーが出る場面です。
すべての項目でエラーになるのは極端な場合ですが、複数の項目でエラーが一度に出て、修正してボタンを押してもまたエラーが出て ... となるとユーザー体験としては良くないかと思います。

https://youtube.com/shorts/bGecbR79Vdc

入力変更時にバリデーション、エラー表示

次に入力が変更された時にバリデーション、エラー表示を行う場合について考えてみます。
以下のようなコードで実装してみます。
実は 全項目入力完了時にバリデーション、エラー表示 で行なった実装に少し変更を加えるだけで実現できます。
具体的には、TextFormFieldautovalidateModeAutovalidateMode.onUserInteraction を渡すだけでユーザーの入力に対応するバリデーションができるようになります。

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

class FormSample extends HookWidget {
  const FormSample({super.key});

  
  Widget build(BuildContext context) {
    final emailController = useTextEditingController();
    final passwordController = useTextEditingController();

    final formKey = useMemoized(() => GlobalKey<FormState>());

    // 送信ボタンの処理
    void submit() {
      if (formKey.currentState!.validate()) {
        formKey.currentState!.save();
        ScaffoldMessenger.of(context).showSnackBar(
          const SnackBar(content: Text('ログイン成功!')),
        );
      } else {
        ScaffoldMessenger.of(context).showSnackBar(
          const SnackBar(content: Text('入力内容を確認してください。')),
        );
      }
    }

    return Scaffold(
      appBar: AppBar(
        title: const Text('ログインフォーム'),
      ),
      body: Padding(
        padding: const EdgeInsets.all(16.0),
        child: Form(
          key: formKey,
          child: Column(
            children: [
              TextFormField(
                controller: emailController,
                decoration: const InputDecoration(labelText: 'メールアドレス'),
                keyboardType: TextInputType.emailAddress,
+               autovalidateMode: AutovalidateMode.onUserInteraction,
                validator: (value) {
                  if (value == null || value.isEmpty) {
                    return 'メールアドレスを入力してください。';
                  }
                  if (!RegExp(r'^[^@]+@[^@]+\.[^@]+').hasMatch(value)) {
                    return '有効なメールアドレスを入力してください。';
                  }
                  return null;
                },
              ),
              const SizedBox(height: 16.0),
              TextFormField(
                controller: passwordController,
                decoration: const InputDecoration(labelText: 'パスワード'),
                obscureText: true,
+               autovalidateMode: AutovalidateMode.onUserInteraction,
                validator: (value) {
                  if (value == null || value.isEmpty) {
                    return 'パスワードを入力してください。';
                  }
                  if (value.length < 6) {
                    return 'パスワードは6文字以上にしてください。';
                  }
                  return null;
                },
              ),
              const SizedBox(height: 32.0),
              ElevatedButton(
                onPressed: submit,
                child: const Text('ログイン'),
              ),
            ],
          ),
        ),
      ),
    );
  }
}

上記のコードで実行すると以下のようになります。

https://youtube.com/shorts/aJXPAWR7aWc

このような実装のメリット・デメリットを考えてみます。

メリット

  1. 即時のフィードバック
    入力中にエラーや成功メッセージが表示されることで、ユーザーはリアルタイムで自分の入力内容を確認・修正できます。
  2. ストレスの軽減
    フォーム送信後に多数のエラーが表示されるのを避けられます。
  3. 入力ミスの減少
    ユーザーが誤った入力をした瞬間に指摘されるため、後から大きな修正を行う必要がなくなります。

デメリット

  1. 過剰なフィードバック
    多数のフィールドで同時にフィードバックが表示されると、ユーザーがどれに注意すべきか分かりづらくなる可能性があります。
  2. パフォーマンスへの影響
    特に複雑なバリデーションやサーバーサイドバリデーションが必要な場合、リアルタイム処理がパフォーマンスに悪影響を与える可能性があります。
  3. ユーザーの入力時の負荷
    ユーザーが入力中にエラーメッセージが表示されると、作業が中断され、フラストレーションを感じることがあります。

テキストフィールドの一文字目を入力した瞬間にバリデーションが走ってエラーが表示されると、ユーザーとしては「まだ打ってる途中なのに...」と思うこともあります。

入力完了時にバリデーション、エラー表示

次に、入力完了時にバリデーション、エラー表示を行う場合について考えてみます。
以下のようなコードで実装してみます。

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

class FormSample extends HookWidget {
  const FormSample({super.key});

  
  Widget build(BuildContext context) {
    final emailController = useTextEditingController();
    final passwordController = useTextEditingController();

    // フォーカスノードの定義
    final emailFocusNode = useFocusNode();
    final passwordFocusNode = useFocusNode();

    // 各フィールドがタッチされたかどうかの状態
    final isEmailTouched = useState<bool>(false);
    final isPasswordTouched = useState<bool>(false);

    final formKey = useMemoized(() => GlobalKey<FormState>());

    // フォーカスノードのリスナー設定
    useEffect(() {
      void emailListener() {
        if (!emailFocusNode.hasFocus) {
          isEmailTouched.value = true;
          formKey.currentState?.validate();
        }
      }

      void passwordListener() {
        if (!passwordFocusNode.hasFocus) {
          isPasswordTouched.value = true;
          formKey.currentState?.validate();
        }
      }

      emailFocusNode.addListener(emailListener);
      passwordFocusNode.addListener(passwordListener);

      return () {
        emailFocusNode.removeListener(emailListener);
        passwordFocusNode.removeListener(passwordListener);
      };
    }, [emailFocusNode, passwordFocusNode]);

    // 送信ボタンの処理
    void submit() {
      isEmailTouched.value = true;
      isPasswordTouched.value = true;

      if (formKey.currentState!.validate()) {
        formKey.currentState!.save();
        ScaffoldMessenger.of(context).showSnackBar(
          const SnackBar(content: Text('ログイン成功!')),
        );
      } else {
        ScaffoldMessenger.of(context).showSnackBar(
          const SnackBar(content: Text('入力内容を確認してください。')),
        );
      }
    }

    return Scaffold(
      appBar: AppBar(
        title: const Text('ログインフォーム'),
      ),
      body: Padding(
        padding: const EdgeInsets.all(16.0),
        child: Form(
          key: formKey,
          child: Column(
            children: [
              TextFormField(
                controller: emailController,
                focusNode: emailFocusNode,
                decoration: const InputDecoration(labelText: 'メールアドレス'),
                keyboardType: TextInputType.emailAddress,
                validator: (value) {
                  if (!isEmailTouched.value) return null;
                  if (value == null || value.isEmpty) {
                    return 'メールアドレスを入力してください。';
                  }
                  if (!RegExp(r'^[^@]+@[^@]+\.[^@]+').hasMatch(value)) {
                    return '有効なメールアドレスを入力してください。';
                  }
                  return null;
                },
              ),
              const SizedBox(height: 16.0),
              TextFormField(
                controller: passwordController,
                focusNode: passwordFocusNode,
                decoration: const InputDecoration(labelText: 'パスワード'),
                obscureText: true,
                validator: (value) {
                  if (!isPasswordTouched.value) return null;
                  if (value == null || value.isEmpty) {
                    return 'パスワードを入力してください。';
                  }
                  if (value.length < 6) {
                    return 'パスワードは6文字以上にしてください。';
                  }
                  return null;
                },
              ),
              const SizedBox(height: 32.0),
              ElevatedButton(
                onPressed: submit,
                child: const Text('ログイン'),
              ),
            ],
          ),
        ),
      ),
    );
  }
}

コードを見ていきます。

以下では useFocusNodeFocusNode を生成しています。
これで各フィールドのフォーカスを管理しています。

// フォーカスノードの定義
final emailFocusNode = useFocusNode();
final passwordFocusNode = useFocusNode();

以下では各フィールドがタッチされたかどうかを管理しています。
各フォームがすでに編集されている場合 = タッチされている場合にのみバリデーションを行う為に用意しています。

// 各フィールドがタッチされたかどうかの状態
final isEmailTouched = useState<bool>(false);
final isPasswordTouched = useState<bool>(false);

以下では、 useEffect でそれぞれのフィールドの FocusNode に対して addListener とすることで、フォーカスの状態を監視するリスナーを追加しています。
それぞれ hasFocus が false になった場合、つまりそのフィールドからフォーカスが外れた場合にバリデーションを行うように設定しています。

// フォーカスノードのリスナー設定
useEffect(() {
  void emailListener() {
    if (!emailFocusNode.hasFocus) {
      isEmailTouched.value = true;
      formKey.currentState?.validate();
    }
  }

  void passwordListener() {
    if (!passwordFocusNode.hasFocus) {
      isPasswordTouched.value = true;
      formKey.currentState?.validate();
    }
  }

  emailFocusNode.addListener(emailListener);
  passwordFocusNode.addListener(passwordListener);

  return () {
    emailFocusNode.removeListener(emailListener);
    passwordFocusNode.removeListener(passwordListener);
  };
}, [emailFocusNode, passwordFocusNode]);

以下では作成した FocusNodeTextFormFieldfocusNode に付与しています。
これでフィールドのフォーカスを監視することができるようになります。

TextFormField(
  controller: emailController,
  focusNode: emailFocusNode,

上記のコードで実行すると以下のようになります。

https://youtube.com/shorts/Z66nlWcA0Gk

メリット

  1. 納得感のあるフィードバック
    ユーザーが入力を完了した後にエラーメッセージが表示されるため、「このフィールドでエラーがある」とわかりやすいかと思います。
  2. ストレスの軽減
    入力中に頻繁にエラーメッセージが表示されることがないため、ユーザーの入力中の負荷を軽減できます。
  3. パフォーマンス最適化
    フォーカスが外れた時点でのバリデーションに限定することで、リアルタイムバリデーションに比べて処理負荷が軽減されます。

デメリット

  1. 実装の複雑さ
    各フィールドに対して FocusNode を設定し、リスナーを追加する必要があるため、コードが複雑化します。
  2. 瞬時のフィードバックがないことによる認識のズレ
    ユーザーのよっては、入力に応じた瞬時のフィードバックを期待していることがあり、「フォーカスが外れた段階でエラーを出す」という挙動に慣れていない可能性があります。
  3. リスナーの管理
    フォームの入力項目が多い場合、多数のリスナーを管理する必要があり、パフォーマンスやコードの読みやすさに影響する可能性があります。

一定時間入力がなかった場合にバリデーション、エラー表示

最後に一定時間入力がなかった場合にバリデーション、エラー表示を行う場合について考えてみます。
以下のようなコードで実装してみます。

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

class FormSample extends HookWidget {
  const FormSample({super.key});

  
  Widget build(BuildContext context) {
    const debounceDuration = Duration(milliseconds: 800);
    final emailText = useState<String>('');
    final passwordText = useState<String>('');

    final emailError = useState<String?>(null);
    final passwordError = useState<String?>(null);

    // 各フィールドがタッチされたかどうかの状態
    final isEmailTouched = useState<bool>(false);
    final isPasswordTouched = useState<bool>(false);

    bool validateEmail(String value, ValueNotifier<String?> error) {
      if (value.isEmpty) {
        error.value = 'メールアドレスを入力してください。';
        return false;
      } else if (!RegExp(r'^[^@]+@[^@]+\.[^@]+').hasMatch(value)) {
        error.value = '有効なメールアドレスを入力してください。';
        return false;
      } else {
        error.value = null;
      }
      return true;
    }

    bool validatePassword(String value, ValueNotifier<String?> error) {
      if (value.isEmpty) {
        error.value = 'パスワードを入力してください。';
        return false;
      } else if (value.length < 6) {
        error.value = 'パスワードは6文字以上にしてください。';
        return false;
      } else {
        error.value = null;
      }
      return true;
    }

    // デバウンス用のフック
    final emailDebounced = useDebounced(
      emailText.value,
      debounceDuration,
    );

    final passwordDebounced = useDebounced(
      passwordText.value,
      debounceDuration,
    );

    useEffect(() {
      if (isEmailTouched.value) {
        validateEmail(emailText.value, emailError);
      }
      if (isPasswordTouched.value) {
        validatePassword(passwordText.value, passwordError);
      }
      return null;
    }, [emailDebounced, passwordDebounced]);

    final formKey = useMemoized(() => GlobalKey<FormState>());

    // 送信ボタンの処理
    void submit() {
      final emailValid = validateEmail(emailText.value, emailError);
      final passwordValid = validatePassword(passwordText.value, passwordError);

      if (emailValid && passwordValid) {
        ScaffoldMessenger.of(context).showSnackBar(
          const SnackBar(content: Text('ログイン成功!')),
        );
      } else {
        ScaffoldMessenger.of(context).showSnackBar(
          const SnackBar(content: Text('入力内容を確認してください。')),
        );
      }
    }

    return Scaffold(
      appBar: AppBar(
        title: const Text('ログインフォーム'),
      ),
      body: Padding(
        padding: const EdgeInsets.all(16.0),
        child: Form(
          key: formKey,
          child: Column(
            children: [
              TextFormField(
                decoration: InputDecoration(
                  labelText: 'メールアドレス',
                  errorText: emailError.value,
                ),
                keyboardType: TextInputType.emailAddress,
                onChanged: (value) {
                  emailText.value = value;
                  isEmailTouched.value = true;
                },
              ),
              const SizedBox(height: 16.0),
              TextFormField(
                decoration: InputDecoration(
                  labelText: 'パスワード',
                  errorText: passwordError.value,
                ),
                obscureText: true,
                onChanged: (value) {
                  passwordText.value = value;
                  isPasswordTouched.value = true;
                },
              ),
              const SizedBox(height: 32.0),
              ElevatedButton(
                onPressed: submit,
                child: const Text('ログイン'),
              ),
            ],
          ),
        ),
      ),
    );
  }
}

コードを詳しくみていきます。

以下ではそれぞれ必要な変数を用意しています。
この実装で、メールアドレスとパスワードのテキストの管理に useTextEditingController ではなく useState を用いているのは、後述の useDebounced で値の変化を監視するためです。

  • debounceDuration : デバウンスが発火するまでの時間
  • emailText : メールアドレスのテキスト
  • passwordText : パスワードのテキスト
  • emailError : メールアドレスの入力エラー
  • passwordError : パスワードの入力エラー
const debounceDuration = Duration(milliseconds: 800);
final emailText = useState<String>('');
final passwordText = useState<String>('');

final emailError = useState<String?>(null);
final passwordError = useState<String?>(null);

以下では useDebounced を用いて、デバウンスの処理を実装しています。
第一引数にはメールアドレスの内容を保持する emailText.value を渡し、第二引数には debounceDuration を渡しています。
これで、emailText.value の値に変化があった時点から debounceDuration の時間を足した時間に emailDebounced が変更されます。

この場合だと、ユーザーがメールアドレスを変更した瞬間から debounceDuration の値である 800ミリ秒経過した段階で emailDebounced が更新されます。

// デバウンス用のフック
final emailDebounced = useDebounced(
  emailText.value,
  debounceDuration,
);

以下では useEffect の第一引数にバリデーションを行う処理、第二引数に先ほど定義した emailDebounced, passwordDebounced を渡しています。
これで emailDebounced, passwordDebounced のどちらかが変更された段階でバリデーションが走るようになります。

useEffect(() {
  if (isEmailTouched.value) {
    validateEmail(emailText.value, emailError);
  }
  if (isPasswordTouched.value) {
    validatePassword(passwordText.value, passwordError);
  }
  return null;
}, [emailDebounced, passwordDebounced]);

上記のコードで実行すると以下のようになります。

https://youtube.com/shorts/-6Pq83dEiZ8

メリット

  1. 適度なフィードバック
    ユーザーがある程度入力を行った後にのみエラーメッセージが表示されるため、過剰なフィードバックによる煩わしさを軽減できます。
  2. 複数の項目を一括で変更するストレスの軽減
    各項目で一定時間入力がなければバリデーションを行うため、すべての項目を入力した後にバリデーションを行う場合のように、複数の項目を一括で変更する必要がなくなります。

デメリット

  1. 学習の必要性
    useDebounced のような Hook を導入することで、フックの内部動作や使用方法を理解する必要があり、学習コストが増加します。
  2. テスタビリティの課題
    useDebounced は内部で Timer を用いた実装を行なっているため、テストが困難になると考えられます。
  3. 入力のタイミング
    入力が中断されて一定時間後にバリデーションが行われるため、慣れていないユーザーがいたり、内容が正しいかどうかを確かめる為に一定時間待たなければならない必要があり、タイミングの調整が難しくなる可能性があります。
  4. アクセシビリティの懸念
    エラーメッセージがデバウンス後に表示されることで、スクリーンリーダーでエラー内容を伝達できない場合があります。

まとめ

最後まで読んでいただいてありがとうございました。

今回は入力フォームの実装についてみてきました。

冒頭でも述べた通り、それぞれのプロジェクトで「良い実装」は異なるかと思います。
例えば今回考慮に入れていない以下のような要素で実装が全く変わるかと思います。

  • flutter_hooks を積極的に使用しても問題ないかどうか
  • 入力フォームの実装に時間をかけられるかどうか
  • 既存の実装がどうなっているか
  • ページ内で入力する項目の数
  • 複数の項目で連携した表示が必要になる場合

適宜メリットとデメリットを考えつつ実装していただければと思います。

誤っている点やもっと良い書き方があればご指摘いただければ幸いです。

参考

https://dev.classmethod.jp/articles/flutter-inline-validation/

https://speakerdeck.com/chocoyama/flutterkaigi2024-effective-form-flutterniyorufu-za-nahuomukai-fa-noshi-jian

Discussion