Engineering Aug 23, 2026 · 7 min read

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:

  1. When a TextField (its inner EditableText) 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.
  2. When that field loses focus, Flutter closes the connection (TextInput.clearClient) → the native side resigns first responder → the keyboard starts dismissing.
  3. 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 TextField produces no text change on backspace, so a KeyboardListener per 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.
  • FocusNode leak: the listener's FocusNode was created inline in build() 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.oneTimeCode sat 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 TextField holds 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 the 0 hint), 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 calls requestFocus() 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.