修正前
テキストフィールドに文字が入力されたら、ボタンを活性化し、文字が全て削除されたら非活性化する、といった機能の実装を行なっていました。
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:text_controller_riverpod/view_models/report_view_model.dart';
class ReportComponent extends HookConsumerWidget {
const ReportComponent({
super.key,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final textController = useTextEditingController(
text: ref.watch(reportViewModelProvider).value);
useListenable(textController);
void onPressedThen() {
ref.read(reportViewModelProvider.notifier).setValue(textController.text);
}
final fieldKey = GlobalKey();
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SizedBox(height: 16),
Container(
key: fieldKey,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.onSecondary,
border: Border.all(
color: Theme.of(context).colorScheme.outlineVariant,
),
borderRadius: BorderRadius.circular(12),
),
child: TextField(
controller: textController,
maxLines: 1,
keyboardType: TextInputType.text,
inputFormatters: [
FilteringTextInputFormatter.singleLineFormatter,
],
decoration: InputDecoration(
border: InputBorder.none,
hintStyle: TextStyle(
fontSize: Theme.of(context).textTheme.bodyLarge!.fontSize,
height: 1.25,
color: Theme.of(context).colorScheme.secondaryContainer,
),
),
style: TextStyle(
fontSize: Theme.of(context).textTheme.bodyLarge!.fontSize,
height: 1.25,
color: Theme.of(context).colorScheme.onSurface,
),
),
),
SizedBox(height: 64),
ElevatedButton(
onPressed:
textController.text.isNotEmpty ? () => onPressedThen() : null,
style: ElevatedButton.styleFrom(
elevation: 4,
padding: EdgeInsets.symmetric(vertical: 24),
backgroundColor: textController.text.isNotEmpty
? Theme.of(context).colorScheme.primary
: Theme.of(context).colorScheme.outlineVariant,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: Text(
"OK",
style: TextStyle(
fontSize: 24,
),
),
),
],
);
}
}
- flutter_hooksを使っているため、
TextField
にuseTextEditingController
を使って定義したコントローラTextEditingController
を指定します。 -
TextField
に文字が入力されたをことを検知できるようにするため、useListenable
によるコントローラの監視を実施します。
final textController = useTextEditingController();
useListenable(textController);
しかし、テキストを一文字入力するとテキストフィールドが固まったような挙動を見せてしまいます。
また、ページにアクセスしてから初めてテキストフィールドをタップしてフォーカスを合わせるときに、フォーカスがすぐに外れてしまう現象も発生しました。
考察
「固まったような挙動」と表現しましたが、実際にはテキストフィールドからフォーカスが外れて、文字を連続して入力できない、という現象が発生していました。
ボタンの活性化状態を変えるということは、ボタンのウィジェットに関しては再描画をする必要があります。監視対象を参照しているウィジェットのみ再描画する挙動を期待していました。
しかし、実際には再描画する必要のないテキストフィールドのウィジェットまでも再描画されてしまっていて、このような現象が起こったと私は考えました。
描画回数の確認
考察したことがあっているか確認するために、今回はAndroid Studioを使って、どのタイミングで何回描画が行われているか確認します。
Android StudioのFlutter DevTools>Rebuild Statsで、Count widget buildsにチェックを入れることで、どのウィジェットが何回描画されたかどうかを確認することができます。
コンポーネントを構築する全てのウィジェットに再描画が走っていることがわかります。
解決策1. GlobalKeyを指定しない
色々試したところ、ColumnウィジェットのkeyにGlobalKeyを指定していると、今回の不具合が発生することがわかりました。
GlobalKeyを指定しない場合に、描画がどう行われるのか確認します。
- final fieldKey = GlobalKey();
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SizedBox(height: 16),
Container(
- key: fieldKey,
対応前と、描画回数に変わりは無かった…
解決策2. buildの外でGlobalKeyの変数を定義する
TextFieldとGlobalKeyの両方に関する、今回の問題に近いものが見つかりました。
https://stackoverflow.com/questions/48845568/textformfield-is-losing-focus-flutter
build内でGlobalKeyの変数を定義すると、文字が入力されたタイミングで、画面全体にリビルドが走るタイミングでGlobalKeyの値が変わってしまい、別のウィジェットになったと扱われて、フォーカスが外れる、という感じだと思います。
(以前、アプリでKeyの付与をむやみに行って、アプリの挙動をおかしくしたことを思い出しました…)
対処は、ReportComponent
を呼び出す親ウィジェットで変数を定義して引数として渡すか、ReportComponent
でbuildの外で変数を定義するかになると思います。
コード全体
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:text_controller_riverpod/view_models/report_view_model.dart';
class ReportComponent extends HookConsumerWidget {
ReportComponent({
super.key,
});
final fieldKey = GlobalKey();
@override
Widget build(BuildContext context, WidgetRef ref) {
final textController = useTextEditingController(
text: ref.watch(reportViewModelProvider).value);
useListenable(textController);
void onPressedThen() {
ref.read(reportViewModelProvider.notifier).setValue(textController.text);
}
return Column(
key: fieldKey,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SizedBox(height: 16),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.onSecondary,
border: Border.all(
color: Theme.of(context).colorScheme.outlineVariant,
),
borderRadius: BorderRadius.circular(12),
),
child: TextField(
controller: textController,
maxLines: 1,
keyboardType: TextInputType.text,
inputFormatters: [
FilteringTextInputFormatter.singleLineFormatter,
],
decoration: InputDecoration(
border: InputBorder.none,
hintStyle: TextStyle(
fontSize: Theme.of(context).textTheme.bodyLarge!.fontSize,
height: 1.25,
color: Theme.of(context).colorScheme.secondaryContainer,
),
),
style: TextStyle(
fontSize: Theme.of(context).textTheme.bodyLarge!.fontSize,
height: 1.25,
color: Theme.of(context).colorScheme.onSurface,
),
),
),
SizedBox(height: 64),
ElevatedButton(
onPressed:
textController.text.isNotEmpty ? () => onPressedThen() : null,
style: ElevatedButton.styleFrom(
elevation: 4,
padding: EdgeInsets.symmetric(vertical: 24),
backgroundColor: textController.text.isNotEmpty
? Theme.of(context).colorScheme.primary
: Theme.of(context).colorScheme.outlineVariant,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: Text(
"OK",
style: TextStyle(
fontSize: 24,
),
),
),
],
);
}
}
解決策3. 再描画範囲を絞り込む
ConsumerWidgetの特性が原因だと決めつけていた時の対応策です。
部分的な再描画をするために、ValueListenableBuilder
を使います。
valueListenable
に監視対象のtextController
を指定します。
監視対象の値は、builder の2番目の変数から参照することができます。
このようにすることで、再描画の範囲を最小限にすることができ、ボタンの活性状態を変えつつ、テキストの入力も正常に行えるようになりました。
- useListenable(textController);
- ElevatedButton(
+ ValueListenableBuilder<TextEditingValue>(
+ valueListenable: textController,
+ builder: (context, textValue, _) {
+ return ElevatedButton(
onPressed: textValue.text.isNotEmpty ? () => onPressedThen() : null,
style: ElevatedButton.styleFrom(
elevation: 4,
padding: EdgeInsets.symmetric(vertical: 24),
backgroundColor: textValue.text.isNotEmpty
? Theme.of(context).colorScheme.primary
: Theme.of(context).colorScheme.outlineVariant,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: Text(
"OK",
style: TextStyle(
fontSize: 24,
),
),
+ );
+ }
),
再描画範囲が活性状態を変更したElevatedButtonウィジェット + ボタンラベルのText
ウィジェット(ValueListenableBuilder
の子ウィジェット)の最低限になっていることが確認できます。
再描画範囲は根本的な解決策ではありませんでしたが、結果的に、ボタンの活性状態を変える時に、GlobalKeyの変数を初期化しなくなったため、問題を解決できていたようです。
こちらを適用したコードの全体
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:text_controller_riverpod/view_models/report_view_model.dart';
class ReportComponent extends HookConsumerWidget {
const ReportComponent({
super.key,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final textController = useTextEditingController(
text: ref.watch(reportViewModelProvider).value);
void onPressedThen() {
ref.read(reportViewModelProvider.notifier).setValue(textController.text);
}
final fieldKey = GlobalKey();
return Column(
key: fieldKey,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SizedBox(height: 16),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.onSecondary,
border: Border.all(
color: Theme.of(context).colorScheme.outlineVariant,
),
borderRadius: BorderRadius.circular(12),
),
child: TextField(
controller: textController,
maxLines: 1,
keyboardType: TextInputType.text,
inputFormatters: [
FilteringTextInputFormatter.singleLineFormatter,
],
decoration: InputDecoration(
border: InputBorder.none,
hintStyle: TextStyle(
fontSize: Theme.of(context).textTheme.bodyLarge!.fontSize,
height: 1.25,
color: Theme.of(context).colorScheme.secondaryContainer,
),
),
style: TextStyle(
fontSize: Theme.of(context).textTheme.bodyLarge!.fontSize,
height: 1.25,
color: Theme.of(context).colorScheme.onSurface,
),
),
),
SizedBox(height: 64),
ValueListenableBuilder<TextEditingValue>(
valueListenable: textController,
builder: (context, textValue, _) {
return ElevatedButton(
onPressed:
textValue.text.isNotEmpty ? () => onPressedThen() : null,
style: ElevatedButton.styleFrom(
elevation: 4,
padding: EdgeInsets.symmetric(vertical: 24),
backgroundColor: textValue.text.isNotEmpty
? Theme.of(context).colorScheme.primary
: Theme.of(context).colorScheme.outlineVariant,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: Text(
"OK",
style: TextStyle(
fontSize: 24,
),
),
);
},
),
],
);
}
}
結論
GlobalKeyを指定する必要がなければ解決策1を、GlobalKeyを指定する必要があるなら、解決策2(リビルドによる意図しないKeyの変更の防止)と解決策3(リビルドされる範囲の絞り込みによる高速化)の合わせ技がいいと思います。
おまけ
https://github.com/flutter/flutter/issues/46773
TextFieldの親ウィジェットがグローバルキーを持つ場合に、フォーカスに問題が出るということで今回の問題と似ている内容です。
識別子としてGlobalKeyを使っているので、その代わりにfocusNodeが使えるという回答の内容だったため、今回の私の求めていた答えにはなりませんでした。
参考にしたサイト
https://qiita.com/SoarTec-lab/items/809aed85eb4253de8165
https://qiita.com/Harx02663971/items/1e07bbb32abf0ea1f477
https://qiita.com/mas821/items/ad3d48cd867a20cb1be6
https://zenn.dev/faucon/articles/590d848d9ea16e
https://zenn.dev/kiiimii/articles/96d5dc181228b2