Forms, Input & Focus
TextField, TextEditingController, Form validation, FocusNode, input formatters, keyboard insets
- 01
Every keystroke in a TextField vanishes as soon as the parent rebuilds. What do you look at first?
EasyA
TextEditingControllerconstructed insidebuild()— every rebuild hands the field a fresh, empty controller, and the old text goes with the old object.// ❌ a new controller on every build — the text resets and nothing disposes it class BadForm extends StatelessWidget { const BadForm({super.key}); @override Widget build(BuildContext context) { … - 02
A search field wired with controller.addListener fires a query when the user only moves the caret — how do onChanged, onSubmitted and a controller listener differ?
EasyonChangedfires only on user edits,onSubmittedonly when the user presses the keyboard's action key, and a controller listener fires on every change to the wholeTextEditingValue— caret moves included.class _SearchState extends State<Search> { final _query = TextEditingController(); String _lastQuery = ''; bool _typing = false; Timer? _debounce; … - 03
Showing an error under one field pushes the rest of the form down a few pixels. Where do label, hint, helper and error text actually live?
EasyAll four are painted by
InputDecoratorinside the field's own box, anderrorTextadds a subtext line that was not there before, so the field grows and everything below it shifts.TextFormField( decoration: const InputDecoration( labelText: 'Email', // floats up on focus or when filled hintText: 'you@example.com', // visible only while empty helperText: ' ', // reserves the subtext line: no jump later prefixIcon: Icon(Icons.alternate_email), … - 04
You set textInputAction: TextInputAction.next and the key does nothing. What do keyboardType, textInputAction and textCapitalization actually control?
EasyAll three are hints sent to the platform's IME, and the next key only moves focus while the framework's default
onEditingCompleteis in place — supply your own callback and you have replaced it.Column( children: [ TextField( focusNode: _emailNode, keyboardType: TextInputType.emailAddress, textInputAction: TextInputAction.next, // default handler: nextFocus() … - 05
You call _formKey.currentState!.save() and the model you meant to fill is still empty. Where do a Form's values actually live?
MediumEach
FormFieldkeeps its own value in itsFormFieldState; theFormholds nothing but the list of fields that registered with it, andsave()merely calls theironSavedcallbacks — with noonSaved, it does nothing at all.class _CheckoutState extends State<Checkout> { final _formKey = GlobalKey<FormState>(); Order _order = const Order(); // filled by onSaved, not by save() itself void _submit() { final form = _formKey.currentState!; // null before the first build … - 06
A sign-up form opens covered in red "Email is required" before the user has typed a character. Which autovalidateMode is that, and what would you use instead?
MediumAutovalidateMode.always— it runs every validator on every build, the first one included, so an untouched empty form is already full of errors.// ❌ every validator runs on the first build — the form opens in red Form( key: _formKey, autovalidateMode: AutovalidateMode.always, child: Column(children: _fields), ); … - 07
On a login screen, tapping the background never closes the keyboard and the next key skips a field. How does focus work and who owns the nodes?
MediumFocus lives in its own tree of
FocusNodes alongside the widget tree — aTextFieldmerely attaches to a node, so the node is created and disposed by theState, and on touch platforms nothing dismisses the keyboard unless you ask for it.class _LoginState extends State<Login> { final _email = FocusNode(); final _password = FocusNode(); @override void dispose() { … - 08
Product wants "that username is taken" under the field while the user types, but a FormFieldValidator has to return synchronously. How do you wire it?
MediumKeep the network call out of the validator: debounce
onChanged, park the server's answer in theState, and let the synchronous validator return the message that is already sitting there.class _UsernameFieldState extends State<UsernameField> { final _controller = TextEditingController(); Timer? _debounce; int _request = 0; String? _taken; // the message the synchronous validator will hand back bool _checking = false; … - 09
Your thousands-separator formatter produces the right text, but the caret jumps to the end of the field after every keystroke — where is the bug?
MediumA
TextInputFormatterreturns a wholeTextEditingValue, so the moment you rebuild the text you are also declaring where the caret goes — and the usual guess istext.length.class ThousandsFormatter extends TextInputFormatter { static final _digit = RegExp(r'[0-9]'); @override TextEditingValue formatEditUpdate(TextEditingValue old, TextEditingValue next) { final digits = next.text.replaceAll(RegExp(r'[^0-9]'), ''); … - 10
On a small phone the keyboard covers your Continue button and the field beneath it — what does Scaffold already do here, and what is left for you?
MediumScaffold already shrinks the body by the keyboard height —
resizeToAvoidBottomInsetdefaults to true — but it cannot scroll a body you built as a fixed Column, so whatever no longer fits overflows or hides.// ❌ nothing can scroll, so the resize just squeezes the Column Scaffold( body: Column( children: [ TextField(controller: _email), const Spacer(), … - 11
The OS password manager never offers to fill or save your login form, and the SMS code still has to be typed by hand — what is missing?
MediumAutofill is a contract with the platform: the fields must carry
autofillHints, live inside anAutofillGroup, and the context must be finished — and for the right saved credential to appear, the app has to be linked to your domain.AutofillGroup( // these fields are ONE credential child: Column( children: [ TextField( controller: _email, autofillHints: const [AutofillHints.username], // not .email … - 12
Your date picker sits inside a Form but validate() ignores it and save() never sees its value — how do you make a non-text widget a real form field?
MediumSubclass
FormField<T>: it owns the value, the error text and — crucially — the registration with the enclosingFormState, which is the part a hand-rolled widget never does.class DateFormField extends FormField<DateTime> { DateFormField({ super.key, super.initialValue, super.validator, super.onSaved, … - 13
A search field driven by a Bloc drops characters when you type fast and the cursor keeps jumping to the end — who is supposed to own the text?
MediumThe
TextEditingControllerowns the text and the caret; if you assigncontroller.textfrom every emitted state you are throwing the caret away and racing the keyboard.// ❌ the bloc echoes the text back and fights the keyboard BlocBuilder<SearchBloc, SearchState>( builder: (context, state) { _ctrl.text = state.query; // caret to the end on every keystroke return TextField( controller: _ctrl, … - 14
A user rotates the phone half-way through a signup form and everything typed is gone — what actually got destroyed, and what would have restored it?
HardRotation is not the culprit: a configuration change in Flutter is just a resize, the isolate and every
Stateobject survive it.class _SignupPageState extends State<SignupPage> with RestorationMixin { // Survives a rebuild AND process death; a plain controller only survives rebuilds. final RestorableTextEditingController _email = RestorableTextEditingController(); final RestorableBool _newsletter = RestorableBool(false); @override … - 15
A 30-field form built with ListView.builder loses typed values and error messages once a field scrolls off screen — what causes it and what do you change?
HardListView.builderunmounts children that scroll out of the cache extent, and everything those Elements owned goes with them — the row'sTextEditingControllerand itsFormFieldState.// ❌ the controller lives in the row and dies when the row scrolls away class _RowState extends State<FieldRow> { final _ctrl = TextEditingController(); // disposed with the Element @override Widget build(BuildContext context) => TextFormField(controller: _ctrl, validator: widget.validator); … - 16
Your uppercase-everything input formatter breaks Japanese typing and chops emoji in half — what is TextEditingValue.composing and why does rewriting text corrupt it?
HardWhile an IME is composing, the field holds provisional text marked by
TextEditingValue.composing, a range the platform owns; rewrite the text underneath it and Flutter and the IME stop agreeing about what is on screen.// ❌ rewrites text mid-composition and keeps a range that no longer fits it class ShoutFormatter extends TextInputFormatter { @override TextEditingValue formatEditUpdate(TextEditingValue old, TextEditingValue next) { return TextEditingValue( text: next.text.toUpperCase(), …