【Flutter】入力フォームのボタンを非活性にする、いちばん楽な方法を考える
こういうやつ
非活性 | 活性 |
---|---|
Flutter の Form のエラーの課題
Flutter には Form
という、入力フォームを構築するためのウィジェットが用意されています。
しかし、これらでボタン非活性をハンドリングしようとすると、以下のような壁にぶつかります
-
validate
の実行は、String
のerrorText
が前提になっているので、単純なbool
での管理ができない -
autovalidateMode
というものがあるが、TextFormField
とかの変更にしか対応してない
Form
はエラーテキストの表示を前提にしてるので、単純なボタン非活性のみに使う場合は、少し過剰な機構です。
解決
Form
の別の機能、onChanged
を使う
Form
の別の機能、onChanged
を使います。
class _HogeState extends State<Hoge> {
void _onFormChanged() {
}
Widget build(BuildContext context) {
return Form(
onChanged: _onFormChanged,
child: Column(
children: [
TextFormField()
]
)
);
}
}
この onChanged
は、Form
ウィジェットの子孫にある FormField
ウィジェットの値に変更があった時に発火します。
ここで言う FormField
ウィジェットは TextFormField
です。
なので、たとえば 「TextEditingController
に addListener
を追加する」みたいなことをしなくても、簡単に構築できます。
続いて、バリデーションをできるようにしていきます
class _HogeState extends State<Hoge> {
+ // バリデーションの条件式を記述するための、フォームの値
+ final _textEditingController = TextEditingController();
+ // バリデーション実行結果の値
+ bool _isValid = false;
+ // バリデーション条件式
+ bool _validate() {
+ return _textEditingController.text.isNotEmpty;
+ }
void _onFormChanged() {
+ // バリデーション実行結果の保存
+ final isValid = _validate();
+
+ if (_isValid != isValid) {
+ setState(() {
+ _isValid = isValid;
+ });
+ }
+ }
Widget build(BuildContext context) {
return Form(
onChanged: _onFormChanged,
child: Column(
children: [
TextFormField(
+ controller: _textEditingController,
)
]
)
);
}
}
TextFormField
以外の値も管理
Form
の onChanged
で感知できるのは、FormField
ウィジェットの値のみです。
しかし、onChanged
で管理する方法の利点は、他の値のバリデーションも容易に組み込むことができる点です。
以下は、試しに DateTime
の値を取得する自作ウィジェットによりフィールドを追加した例です。
class _HogeState extends State<Hoge> {
final _textEditingController = TextEditingController();
+ DateTime? birthDate;
+ Future<void> _pickDate() async {
+ final now = DateTime.now();
+ final date = await showDatePicker(
+ context: context,
+ firstDate: DateTime(1970),
+ lastDate: now,
+ );
+
+ if (date != null) {
+ setState(() {
+ _birthDate = date;
+ });
+
+ // _onFormChanged を手動で発火させる
+ _onFormChanged();
+ }
+ }
// バリデーション実行結果の値
bool _isValid = false;
// バリデーション条件式
bool _validate() {
return _textEditingController.text.isNotEmpty;
}
void _onFormChanged() {
// バリデーション実行結果の保存
final isValid = _validate();
if (_isValid != isValid) {
setState(() {
_isValid = isValid;
});
}
}
Widget build(BuildContext context) {
return Form(
onChanged: _onFormChanged,
child: Column(
children: [
TextFormField(
controller: _textEditingController,
),
+ Material(
+ child: InkWell(
+ onTap: _pickDate,
+ child: Padding(
+ padding: const EdgeInsets.all(16),
+ child: Row(
+ children: [
+ const Icon(Icons.calendar_month),
+ const SizedBox(width: 16),
+ if (_birthDate != null)
+ Text(DateFormat.yMMMEd().format(_birthDate!)),
+ ],
+ ),
+ ),
+ ),
+ ),
]
)
);
}
}
この実装の欠点は、_onFormChanged
を手動で発火させなければならない点です。
なので、入力フォームを追加したときに _onFormChanged
の実行漏れがあると、テストなどをちゃんとしておかないと、うまく動かなくなるリスクがあります。
しかし、逆に言えば、「値が変わった時に _onFormChanged
を毎回実行する」という一つの方針が固まっているので、実装の迷いも軽減され、読むのも簡単になります。
実務的な利点を言えば、大体の値は文字列だったりするので、「TextFormField
は勝手にやってくれるので、それ以外のちょっとのやつは _onFormChanged
を加える」みたいな方針で書けます。
加えて、どんなウィジェットにも onChanged
はほぼ確実に存在するので、不自由することは少ないと思います。
まとめ
-
Form
ウィジェットで囲む - 文字列のフォームは
TextFormField
を利用する -
Form
のonChanged
に、バリデーションの真偽値を更新する関数を加える
例のソースコード
class _Example1 extends StatefulWidget {
const _Example1();
State<_Example1> createState() => _Example1State();
}
class _Example1State extends State<_Example1> {
final _firstNameController = TextEditingController();
final _lastNameController = TextEditingController();
DateTime? _birthDate;
bool _isValid = false;
Future<void> _pickDate() async {
final now = DateTime.now();
final date = await showDatePicker(
context: context,
firstDate: DateTime(1970),
lastDate: now,
);
if (date != null) {
setState(() {
_birthDate = date;
});
_onFormChanged();
}
}
void _save() {
final snackBar = SnackBar(
content: Text(
'${_birthDate!.year}年生まれの ${_lastNameController.text}${_firstNameController.text} さん',
),
);
ScaffoldMessenger.of(context).showSnackBar(snackBar);
}
bool _validate() {
return _firstNameController.text.isNotEmpty &&
_lastNameController.text.isNotEmpty &&
_birthDate != null;
}
void _onFormChanged() {
setState(() {
_isValid = _validate();
});
}
Widget build(BuildContext context) {
return Form(
onChanged: _onFormChanged,
child: SeparatedColumn(
gap: 16,
children: [
Row(
children: [
Expanded(
child: TextFormField(
controller: _lastNameController,
decoration: const InputDecoration(
labelText: '姓',
),
),
),
const SizedBox(width: 8),
Expanded(
child: TextFormField(
controller: _firstNameController,
decoration: const InputDecoration(
labelText: '名',
),
maxLines: null,
),
),
],
),
Material(
child: InkWell(
onTap: _pickDate,
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
const Icon(Icons.calendar_month),
const SizedBox(width: 16),
if (_birthDate != null)
Text(DateFormat.yMMMEd().format(_birthDate!)),
],
),
),
),
),
FilledButton(
onPressed: _isValid ? _save : null,
style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(48),
),
child: const Text('保存'),
),
],
),
);
}
}
更なる改善
_isValid
を ValueNotifier
に変える
Flutter は再描画のシステムが優秀なので、そこまで気にしすぎなくても良いですが、「フォームの値が変わるたびに setState
を実行する」のを解消したい場合は、_isValid
の監視範囲を、ValueNotifier
で絞ることが有効です。
class _HogeState extends State<Hoge> {
- bool _isValid = false
+ // bool 変数を、ValueNotifier<bool> へ変更
+ bool _isValidNotifier = ValueNotifier<bool>(false);
// ...中略
void _onFormChanged() {
+ _isValidNotifier.value = _validate();
}
Widget build(BuildContext context) {
// ...中略
// ValueListenableBuilder で、ValueNotifier を監視
ValueListenableBuilder(
valueListenable: _isValidNotifier,
builder: (context, isValid, child) {
return FilledButton(
onPressed: isValid ? _save : null,
style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(48),
),
child: const Text('保存'),
);
},
)
}
}
全て FormField にする
自作ウィジェットも全て FormField
で実装すれば、_onFormChanged
を手動で実行しなくても良くなります。
しかし、これだと色々気にすることが増えてしまうので、個人的にはお勧めしません。。
+ FormField<DateTime>(
+ builder: (field) {
return Material(
child: InkWell(
onTap: () async {
final date = await _pickDate();
if (date != null) {
setState(() {
_birthDate = date;
});
+ // didChange は、_birthDate を更新した後で、実行しなければならない
+ field.didChange(date);
}
},
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
const Icon(Icons.calendar_month),
const SizedBox(width: 16),
if (_birthDate != null)
Text(DateFormat.yMMMEd().format(_birthDate)),
],
),
),
),
);
},
),
Discussion
👏
私はhook派なので、
以下のhook使って大体書きます!
かなりスッキリして好きです!
Listenable.merge
使えば複数の hooks や Listenable を監視できるので、良さそうですね!