入力を受け取り、結果を返す
入力された文字はどこに保持され、いつ検証され、どうやって呼び出し元へ返るのか。TextEditingController と Form、validator、フォーカスとキーボード、そしてダイアログから値を受け取るまでを一本の線でつなぐ。
前回はレイアウト——空間をどう配るかを扱いました。今回はその上に、ユーザーからの入力を 載せます。
入力といっても、文字を打ってもらうことだけではありません。「本当に削除していいか確認する」 「一覧から一つ選んでもらう」も同じ仲間です。どれも構造は共通していて、問われているのは次の 三点に尽きます。値はどこに保持されるのか。いつ検証するのか。どうやって呼び出し元へ返るのか。 この順に見ていきます。
§ 01CONTROLLER入力値はどこにあるか
まず押さえるべきは、TextField は入力された文字を自分では持っていないということです。連載 I
で見たとおり Widget は不変なので、変化する文字列を抱えることはできません。値を保持するのは
TextEditingController です。
class _ProfileEditScreenState extends State<ProfileEditScreen> {final _nameController = TextEditingController();@overridevoid dispose() {_nameController.dispose(); // 忘れると解放されずに残るsuper.dispose();}@overrideWidget build(BuildContext context) {return TextField(controller: _nameController);}}
現在の値は _nameController.text で読め、_nameController.text = '初期値' で書き込めます。
初期値を入れたいだけなら TextEditingController(text: user.name) と生成時に渡せます。
ここで必ず守る作法が一つあります。コントローラは使い終わったら dispose() すること
1TextEditingController は ChangeNotifier で、リスナーを保持する。破棄せずに参照が残ると解放されず、debug ビルドではリークとして検出されることがある。ScrollController や AnimationController も同じ扱い。。State の dispose() で解放するのが定石で、そのために入力を扱う画面は
StatefulWidget になります。
なお、onChanged で毎回 setState して自前の変数に持つ書き方もできますが、キーストロークごとに
画面全体が再構築されます。値を保持したいだけならコントローラ、入力に応じて表示を変えたい
ときだけ再構築、と分けて考えると無駄が出ません。
§ 02FORM複数の入力をまとめて扱う
入力欄が増えてくると、「全部まとめて検証したい」「まとめてリセットしたい」が出てきます。これを
引き受けるのが Form です。Form の配下に置いた TextFormField(TextField に検証機能を足した
もの)は、自動的にその Form に登録されます。
Form の操作には GlobalKey<FormState> を使います。連載 I で「GlobalKey は濫用しない」と
書きましたが、ここは数少ない正当な用途です。ツリーの外側から FormState を呼び出す必要がある
ためです。
final _formKey = GlobalKey<FormState>();Form(key: _formKey,autovalidateMode: AutovalidateMode.onUserInteraction,child: Column(children: [TextFormField(controller: _nameController,decoration: const InputDecoration(labelText: '表示名'),validator: _validateName,),const SizedBox(height: 16),ElevatedButton(onPressed: _submit, child: const Text('保存')),],),)
送信側はこうなります。
void _submit() {if (!_formKey.currentState!.validate()) return; // 落ちたら送らない// ここから先は、全項目が検証を通っている}
validate() は配下すべての validator を走らせ、全部が通ったときだけ true を返します
2validate() は配下の全 validator を実行してから結果を返すため、最初の一つで打ち切られない。すべてのエラーが同時に表示される。。エラーがあれば、該当する入力欄の下に自動でメッセージが表示されます。
autovalidateMode は検証のタイミングです。既定の disabled は validate() を呼んだときだけ検証
します。onUserInteraction にすると、ユーザーがその欄を触った後から自動で検証が走ります——
入力前からいきなり赤くならないので、実用上はこれが扱いやすい既定です3AutovalidateMode.always は初期表示の時点で未入力欄が赤くなる。フォームを開いた直後にエラーだらけになるため、通常は onUserInteraction を選ぶ。。
§ 03VALIDATOR検証は「文字列を返す関数」
validator の型は String? Function(String?) です。ここが少し独特で、null を返せば正常、
文字列を返せばそれがエラーメッセージになります。
String? _validateName(String? value) {final name = value?.trim() ?? '';if (name.isEmpty) return '表示名を入力してください';if (name.length > 20) return '20 文字以内で入力してください';return null; // null が「問題なし」}
検証を関数として切り出しておくと、そのままユニットテストの対象になります。連載 I の最終回で見た 「UI を描かずに確かめられるものは unit テストへ」が、まさに当てはまる場所です。
test('空文字はエラーメッセージを返す', () {expect(validateName(''), isNotNull);expect(validateName('みぎわ'), isNull);});
入力の前に trim() を通すこと、そして境界値(空、上限ちょうど、上限 +1)を意識することは、
そのままテストケースの設計にもなります。
§ 04FOCUSキーボードと入力の順序
入力欄が複数あるときは、次の欄へ進む導線を作ります。キーボード右下のキーを「次へ」にして、 確定で次の欄へフォーカスを移す形が標準的です。
TextFormField(controller: _nameController,textInputAction: TextInputAction.next,onFieldSubmitted: (_) => FocusScope.of(context).nextFocus(),)
最後の欄は TextInputAction.done にして、確定時に送信するか、FocusScope.of(context).unfocus()
でキーボードを閉じます。
もう一つ、キーボードが出るとその高さぶん画面が狭くなります。Scaffold は既定で
resizeToAvoidBottomInset: true なので、本文の領域は自動的に縮みます4キーボードの高さは MediaQuery.viewInsetsOf(context).bottom で取得できる。resizeToAvoidBottomInset を false にする場合は、この値を使って自分で余白を確保する。。ここで前回の
レイアウトの話が効いてきます——縮んだ結果、中身が入りきらなければ overflow します。入力欄を
並べる画面は SingleChildScrollView などで包み、スクロールできるようにしておくのが安全です。
§ 05DIALOG画面の上に一時的な層を重ねる
ここからは「返ってくる入力」です。確認や選択は、画面遷移ではなく一時的な層を重ねて受け取り ます。
showDialog… 中央に浮かべる。確認や短い選択にshowModalBottomSheet… 下から迫り上げる。選択肢が多いときや、入力欄を含むときに5入力欄を含むボトムシートではisScrollControlled: trueを指定する。既定では画面高の半分までに制限され、キーボードが出ると入力欄が隠れてしまう。
重要なのは、どちらも Future を返すことです。ダイアログは「表示して終わり」ではなく、
閉じられるまで待てる非同期処理として扱えます。
Future<bool?> confirmDelete(BuildContext context) {return showDialog<bool>(context: context,barrierDismissible: false,builder: (context) => AlertDialog(title: const Text('この項目を削除しますか'),content: const Text('削除すると元に戻せません。'),actions: [TextButton(onPressed: () => Navigator.pop(context, false),child: const Text('キャンセル'),),TextButton(onPressed: () => Navigator.pop(context, true),child: const Text('削除'),),],),);}
barrierDismissible: false は、背景をタップしても閉じない設定です。取り返しのつかない操作では、
誤タップで曖昧に閉じられるより、明示的に選ばせるほうが安全です。
§ 06RESULTNavigator.pop で値を返す
ダイアログを閉じる Navigator.pop(context, value) の第 2 引数が、そのまま showDialog の
Future の結果になります。閉じる操作と値を返す操作が同じ一つの呼び出しである、というのが
Flutter の作法です。
呼び出し側はこうなります。
Future<void> _onDeletePressed() async {final confirmed = await confirmDelete(context);if (confirmed != true) return; // null も「実行しない」に倒すif (!context.mounted) return; // await をまたいだので確認するawait _repository.delete(item.id);if (!context.mounted) return;Navigator.pop(context); // 削除できたので一覧へ戻る}
二つ、見落としやすい点があります。
一つは、返り値が null になりうること。戻るボタンや背景タップで閉じられた場合、pop に値が
渡されないので null が返ります。だから if (confirmed == false) ではなく、confirmed != true
のように「はっきり true のときだけ実行する」と書きます。安全側に倒すのが定石です。
もう一つは context.mounted です。ダイアログを待っている間にユーザーが画面を離れているかも
しれない——連載 I の LOG-006 で扱った、await をまたいだ context の問題がそのまま出てきます。
ダイアログは典型的な「長い await」なので、その後に context を使うなら必ず確認します。
この「値を返す」形は、確認ダイアログに限りません。ボトムシートで選ばせた項目、別画面で入力させた
結果——いずれも Navigator.pop で返し、呼び出し側は await で受け取ります。次回以降のナビ
ゲーションでも、同じ形が繰り返し出てきます。
§ 07SUMMARY入力を扱う地図
- 入力値を持つのは Widget ではなく
TextEditingController。dispose()は必須 Form+GlobalKey<FormState>でまとめて検証。validate()がtrueのときだけ送信するvalidatorはnullが正常、文字列がエラー。関数に切り出せばそのまま unit テストできる- キーボードは画面を狭くする。入力画面はスクロール可能にしておく
- ダイアログは
Futureを返す。Navigator.pop(context, value)が結果になる - 返り値は
nullになりうる。!= trueで安全側に倒し、awaitの後はcontext.mounted
ところで、この記事の最初に書いた「コントローラを作って dispose() する」という定型。入力欄が
増えるほど、State はこの手続きで埋まっていきます。次回は Flutter Hooks を扱い、この定型を
畳む方法と、それが StatefulWidget の何に対応しているのかを見ます。
- [1]
TextEditingControllerはChangeNotifierで、リスナーを保持する。破棄せずに参照が残ると解放されず、debug ビルドではリークとして検出されることがある。ScrollControllerやAnimationControllerも同じ扱い。 ↩ - [2]
validate()は配下の全validatorを実行してから結果を返すため、最初の一つで打ち切られない。すべてのエラーが同時に表示される。 ↩ - [3]
AutovalidateMode.alwaysは初期表示の時点で未入力欄が赤くなる。フォームを開いた直後にエラーだらけになるため、通常はonUserInteractionを選ぶ。 ↩ - [4] キーボードの高さは
MediaQuery.viewInsetsOf(context).bottomで取得できる。resizeToAvoidBottomInsetをfalseにする場合は、この値を使って自分で余白を確保する。 ↩ - [5] 入力欄を含むボトムシートでは
isScrollControlled: trueを指定する。既定では画面高の半分までに制限され、キーボードが出ると入力欄が隠れてしまう。 ↩