Fixing the OTP keyboard bounce on iOS Flutter
On iPhone, our 4-box OTP screen made the keyboard slide down and pop back up on every digit typed. The culprit wasn't a rebuild or an animation — it was the input architecture itself. Here's the root cause, and the zero-dependency fix.
TL;DR — The old screen used 4 separate TextFields (one per digit) and moved focus box-to-box as you typed. On iOS, every focus move between two text fields closes the current keyboard session and opens a new one — the keyboard visibly dismisses and re-presents. The fix: one invisible TextField owns the keyboard for the whole 4-digit code, and the 4 boxes are just painted from its value. Focus is acquired once and never moves, so the keyboard never bounces.
1. What we saw
- Type a digit → keyboard animates down, then immediately back up.
- Tap any of the 4 boxes → same down/up animation.
- Xcode console, while typing:
[UISceneHosting-in.example.app:UIHostedScene-com.apple.InputUI-7F2B9B32]
No scene exists for this identity (didUpdateClientSettingsWithDiff)
cannot add handler to 0 from 0 - dropping
That first line is iOS saying the keyboard's hosted scene (com.apple.InputUI) it was talking to no longer exists — it was torn down and a new one created. The keyboard wasn't glitching; we were destroying and recreating its session on every keystroke.
2. Background: how Flutter talks to the iOS keyboard
Flutter doesn't render the system keyboard — iOS does. The contract is:
- When a
TextField(its innerEditableText) gains focus, Flutter opens a text input connection to the platform (TextInput.setClient). On iOS that makes a native input view the first responder → the keyboard presents. - When that field loses focus, Flutter closes the connection (
TextInput.clearClient) → the native side resigns first responder → the keyboard starts dismissing. - Only one input connection exists at a time.
So moving focus from field A to field B is never "keep the keyboard, swap the target." It is always:
A loses focus → clearClient → iOS resigns first responder → keyboard begins dismissing
B gains focus → setClient → iOS becomes first responder → keyboard presents again
With modern iOS (scene-hosted keyboard UI) plus the number pad and AutofillHints.oneTimeCode on the fields, that teardown/re-setup is visible as the down/up bounce — exactly what the No scene exists for this identity log shows.
3. The old implementation, and where it went wrong
One TextField per digit, with its own controller and FocusNode:
final _controllers = List.generate(4, (_) => TextEditingController());
final _focusNodes = List.generate(4, (_) => FocusNode());
Before — 4 focusable fields. Each box is a real TextField. Every keystroke hops focus to the next one — a full keyboard session teardown per digit.
After — 1 hidden field. The boxes are plain Containers painted from the hidden field's string. Nothing else is focusable — nothing to hop to.
Trigger #1 — typing a digit (auto-advance)
void _onDigitChanged(int index, String value) {
...
// Single digit — normal flow
_controllers[index].text = digits;
_controllers[index].selection = const TextSelection.collapsed(offset: 1);
if (index < 3) {
_focusNodes[index + 1].requestFocus(); // ← THE PROBLEM
}
setState(() {});
_submitIfComplete();
}
Every keystroke called requestFocus() on the next TextField. Per section 2, that is a full close-and-reopen of the keyboard session → bounce on every digit.
Trigger #2 — tapping a box
Each box was a real TextField, so tapping box 3 while box 1 was focused moved focus between two fields → same session teardown → same bounce.
What was not the cause: setState
The first suspicion was the setState(() {}) running on every digit. A rebuild alone never dismisses the keyboard — as long as the focused field keeps its identity and focus, the input connection survives any number of rebuilds. The setState just happened to run on every keystroke alongside the real culprit (the focus hop), which made it look guilty.
Other problems the old code carried
child: KeyboardListener(
focusNode: FocusNode(skipTraversal: true), // created in build() — leaked on
onKeyEvent: onKeyEvent, // every rebuild, never disposed
child: TextField(...),
)
- Backspace hack: an empty
TextFieldproduces no text change on backspace, so aKeyboardListenerper box sniffed raw key events to clear the previous box and hop focus backwards (another bounce). This is also unreliable on some Android IMEs, which don't send key events at all. FocusNodeleak: the listener'sFocusNodewas created inline inbuild()and never disposed — a new leaked node on every rebuild.- Paste/autofill distribution: iOS SMS autofill drops the whole code into whichever box is focused, so the code had to detect a multi-digit value and manually spread it across the 4 controllers.
- 4 autofill targets:
AutofillHints.oneTimeCodesat on four fields, which confuses iOS's "From Messages" suggestion (it expects one OTP field).
4. The fix — one hidden field, boxes become pure rendering
This is the same architecture packages like pinput use internally, done with zero dependencies. Two roles, cleanly split:
- Input: a single invisible
TextFieldholds the entire string"1234"and owns the keyboard. It is focused once and focus never moves again. - Display: the 4 boxes are plain
Containers that paint character i of that string (or the0hint), with the underline highlighting the active position. No box is focusable — there is nothing to hop to.
The hidden field (1×1 px, invisible, untouchable)
SizedBox(
width: 1,
height: 1,
child: Opacity(
opacity: 0,
child: IgnorePointer(
child: TextField(
controller: _textController, // holds the WHOLE code
focusNode: _focusNode, // the ONLY focus node
keyboardType: TextInputType.number,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
LengthLimitingTextInputFormatter(widget.length),
],
autofillHints: const [AutofillHints.oneTimeCode], // ONE autofill target
showCursor: false,
enableInteractiveSelection: false,
style: const TextStyle(color: Colors.transparent, fontSize: 1),
decoration: const InputDecoration(
border: InputBorder.none, counterText: ''),
),
),
),
)
The boxes — just paint, no focus
final text = _textController.text;
final activeIndex = _focusNode.hasFocus
? (text.length < widget.length ? text.length : widget.length - 1)
: -1;
// per box:
Container(
width: widget.width,
padding: widget.contentPadding,
decoration: BoxDecoration(
color: widget.fillColor,
border: Border(bottom: BorderSide(
color: active ? widget.activeColor : widget.inactiveColor,
width: active ? widget.activeBorderWidth : widget.inactiveBorderWidth,
)),
),
child: Stack(alignment: Alignment.center, children: [
Text(digit ?? widget.hintText ?? '',
style: digit != null ? widget.textStyle : widget.hintStyle),
if (active && digit == null) _buildCaret(), // fake blinking caret
]),
)
The visuals (44 px boxes, 20 px spacing, grey 0 hints, 1.2 px grey underline → 1.5 px dark underline on the active box, 26 px light digits) are unchanged — only what's behind them changed. Since the real field is invisible, a small AnimationController draws a blinking caret in the active box so it still feels like a text field.
Tapping anywhere — focus once, then it's a no-op
void _requestKeyboard() {
if (_focusNode.hasFocus) {
// already ours — just make sure the keyboard is visible
unawaited(SystemChannels.textInput.invokeMethod('TextInput.show'));
} else {
_focusNode.requestFocus(); // happens ONCE per entry session
}
}
A GestureDetector over the whole row routes every tap here. Tapping box 1, 2, 3 or 4 all focus the same field — there is no second field to steal focus, so there is nothing to tear down.
Typing always appends, backspace always deletes the last digit
void _handleTextChanged() {
final end = TextSelection.collapsed(offset: _textController.text.length);
if (_textController.selection != end) {
_textController.selection = end; // cursor pinned to the end
}
setState(() {}); // repaint boxes
widget.controller.text = _textController.text; // notify the screen
}
Backspace now just shortens the string — the entire KeyboardListener key-sniffing hack (and its leaked FocusNodes) is deleted.
Exposing the value to the screen
class OtpInputController extends ChangeNotifier {
String _text = '';
String get text => _text;
set text(String value) {
if (_text == value) return;
_text = value;
notifyListeners();
}
void clear() => text = '';
}
The verify screen listens to this to enable the Verify button and to auto-submit:
void _onOtpChanged() {
setState(() {}); // safe — rebuilds don't touch the keyboard
_submitIfComplete(); // fires when length == 4
}
5. Life of an OTP entry, after the fix
| Step | What happens | Keyboard |
|---|---|---|
| Tap any box | Hidden field gains focus (once) | Slides up, once |
Type 1, 2, 3 |
String grows; boxes repaint; active underline & caret advance. No focus change | Rock solid |
| Backspace | Last char removed; boxes repaint | Rock solid |
| Tap another box mid-entry | Same field already focused → no-op | Rock solid |
| iOS "From Messages" autofill | Whole code lands in the one hinted field, capped at 4 by the length formatter | Rock solid |
| 4th digit entered | Controller notifies → screen unfocuses deliberately and calls verify | Slides down, once — intended |
The keyboard now moves exactly twice per login: up at the start, down at the end.
6. Before vs after
| Before (4 fields) | After (1 hidden field) | |
|---|---|---|
| TextFields | 4 focusable | 1, invisible, never loses focus mid-entry |
| Keyboard sessions per entry | ~1 per keystroke/tap | 1 total |
| Focus moves while typing | Every digit (requestFocus hop) |
Zero |
| Backspace across boxes | KeyboardListener raw-key hack |
Natural string edit |
| Paste / SMS autofill | Manual distribution across 4 controllers | Native, single field |
AutofillHints.oneTimeCode |
On 4 fields (confuses iOS) | On 1 field |
FocusNode hygiene |
Leaked one per rebuild (created in build()) |
All nodes owned by state, disposed |
| Visuals | 4 underlined boxes | Identical |
7. Takeaway
Never build an OTP/PIN input from N focusable
TextFields with focus-hopping. One keyboard session must stay alive for the whole entry: a single hidden field for input, dumb boxes for display. Any UI that callsrequestFocus()on a different text field per keystroke will pay the close-and-reopen cost on iOS — the platform simply has no "move the keyboard between fields without dismissing" operation.