Skip to content

feature: chain keyboard focus between text inputs with next-focus - #70

Open
SRWieZ wants to merge 10 commits into
NativePHP:mainfrom
SRWieZ:feat/text-input-next-focus
Open

feature: chain keyboard focus between text inputs with next-focus#70
SRWieZ wants to merge 10 commits into
NativePHP:mainfrom
SRWieZ:feat/text-input-next-focus

Conversation

@SRWieZ

@SRWieZ SRWieZ commented Aug 24, 2026

Copy link
Copy Markdown
Member

Stacked on #69 (submit-label) — review that one first.

What's wrong

A form can't move the keyboard to the next field. Each iOS input keeps its own private focus state, and Android never wires the IME action key — so "tap Next → jump to the next field" is impossible from an app.

What this does

One opt-in prop: next-focus="<ref>" names the input the keyboard should jump to on submit (the same ref that Native::test() targets — say the word if you'd rather have a dedicated attribute).

<native:outlined-text-input ref="name"  label="Name"  next-focus="email" native:model="name" />
<native:outlined-text-input ref="email" label="Email" keyboard="email" next-focus="pin" native:model="email" />
<native:outlined-text-input ref="pin"   label="PIN"   keyboard="number" submit-label="done" native:model="pin" />

When next-focus is set and submit-label isn't, the key face derives to Next. Multiline fields ignore the chain. A missing target (recycled row, typo'd ref) is a no-op, never a crash. Nothing changes when the props are unset.

How, per platform

  • Both: a screen-wide registry of focusable fields, keyed by ref, token-guarded so stacked screens can shadow a ref safely.
  • Android: the registry holds FocusRequesters; the IME action key triggers the hop. Bonus fix: a submit that doesn't chain now closes the keyboard (our handler used to swallow the platform default, leaving it stranded open).
  • iOS — why there's extra code: SwiftUI starts dismissing the keyboard as part of handling the return key, before .onSubmit runs — and since iOS 17 that dismissal can't be cancelled. So every pure-SwiftUI hop makes the keyboard dip down and back up (Apple's own Focus Cookbook sample dips — forums thread 770226). The fix is the one React Native ships: intercept textFieldShouldReturn on the UIKit field behind the SwiftUI TextField, return false so no dismissal ever starts, and move first responder straight to the target. The keyboard never moves. Fields without a chain keep 100% stock behavior.
  • iOS number pads have no return key at all, so chained number fields get a Next/Done bar above the keyboard — theme-colored, pinned with safeAreaInset. Deliberately not .toolbar(placement: .keyboard): iOS 26 renders that as a floating glass capsule on the keyboard and never shows it inside sheets.
  • autofocus (opt-in, both platforms): focus the field once when it appears — the opening field of a form.

Tests: prop serialization on every variant, camelCase spelling, empty-means-unset, trimming, focus_ref from ref.

On device (same Blade, chain verified on both platforms)

Android emulator, API 36 — tapping Next hops Name → Email → PIN:

iOS simulator, iPhone 17 (26.5) — same chain (screenshot predates the number-pad accessory bar):

SRWieZ and others added 3 commits August 24, 2026 14:42
Forms with several fields need the platform Next/Go/Search/Send key,
but the submit key face was out of reach: hardcoded on iOS
(.done when @submit is wired, .return otherwise) and never set on
Android (keyboardOptionsFor omitted imeAction entirely).

Adds an opt-in submit-label prop (next|done|go|search|send|return) to
the three text inputs, mapping to SwiftUI SubmitLabel and Compose
ImeAction. Unset keeps each platform's behaviour byte-for-byte.
Multiline fields ignore the prop on both platforms — their return key
must keep inserting newlines.

The filled/outlined Android renderers move from KeyboardActions(onDone)
to onAny (the bare renderer's existing shape): with a non-Done IME
action an onDone-only handler would silently drop the @submit dispatch.
For the default action the two are equivalent.

The fluent submitLabel() validates and throws on unknown values;
Blade accepts submit-label and submitLabel.
A multi-field form has no way to move the keyboard to the following
field: on iOS every input owns a private @focusstate with no cross-
field coordination, and on Android nothing wires an IME Next action to
a FocusRequester.

Adds an opt-in next-focus prop that names another input's existing ref
(the one Native::test() already targets). The element surfaces its own
ref to the renderers as focus_ref — the node-level ref is not decoded
natively — and each platform keeps a screen-wide focus registry:
closures asserting @focusstate on iOS, FocusRequesters on Android,
registered while the field is on screen, identity/token-guarded on
unregister so stacked screens can shadow refs safely.

On submit, the field asks the registry to focus its target. A missing
target (recycled row, conditional render, typo'd ref) is a no-op; iOS
then falls back to keep-focus-on-submit when set. next-focus wins over
keep-focus-on-submit otherwise — moving the keyboard IS keeping it up.
Focus is only ever requested from a user-initiated submit, never from
server pushes, so nothing can steal focus spontaneously.

When next-focus is set and submit-label isn't, both platforms derive a
Next submit key, mirroring how capitalization derives from the
keyboard type. Multiline fields ignore the chain like they ignore
submit-label. An empty ref is treated as unset so Blade can pass a
conditional without special-casing the last field.
… bar

iOS pad-style keyboards (number, decimal, phone) have no return key, so
submit-label, next-focus and @submit were physically unreachable on those
fields — a chain of number inputs offered no way to hop to the next one.

Show a keyboard accessory button above pad keyboards whenever the field
declares a submit affordance. It funnels into the exact same submit path
as the return key: flush pending sync, fire @submit, then follow
next-focus / keep-focus-on-submit; with neither, it dismisses like Done.
Title follows resolveSubmitLabel precedence (explicit prop, then Next
for a chain, then Done). Gated on the focused field so stacked fields
don't each contribute a button.
@SRWieZ SRWieZ changed the title Chain keyboard focus between text inputs with next-focus feature: chain keyboard focus between text inputs with next-focus Aug 24, 2026
SRWieZ added 7 commits August 24, 2026 22:33
SwiftUI drops toolbar(placement: .keyboard) items for text fields
presented in a bare .sheet — the pad-keyboard accessory bar simply never
appeared for inputs inside a <native:bottom-sheet>. Wrap the sheet
content in a NavigationStack with the navigation bar hidden: the stack
hosts the toolbar, and the sheet looks exactly as before.
KeyboardActions(onAny) consumes the IME action, which also suppresses
its platform default — so Done/Go/Send/Search dispatched the submit but
left the keyboard up with nothing else to do. After dispatch, when no
next-focus chain takes over, perform the default action so the keyboard
closes like the platform (and iOS's return key) does. All three input
variants.
…dd autofocus

The pad-keyboard Next/Done accessory moved off .toolbar(placement:
.keyboard): iOS 26 renders those items as a floating Liquid Glass
capsule sitting on the keyboard (and drops them from accessibility on
26.1), and SwiftUI never shows them inside a bare .sheet at all — the
NavigationStack workaround is gone with it. Instead the focused field
claims a shared accessory state and a host pins a full-width,
theme-colored bar above the keyboard with safeAreaInset — Apple's own
recommended shape for this affordance. Hosts: the bottom-sheet content
root, and every screen root via NativeRootHostRegistry (which yields
while a sheet is up). The bar's action is refreshed from the focused
field's body every render, so a republish that changes the chain
mid-entry never leaves a stale target.

Also adds an opt-in autofocus prop on all three input variants (iOS +
Android): focus the field and raise the keyboard once per appearance —
the opening field of a form. A re-render that moves the attribute to an
already-mounted field never steals focus.
The chain hop ran only async: the return key resigns first responder
(keyboard starts dismissing), one runloop later the target focuses and
the keyboard comes back — a visible down-and-up bounce on every Next.
Focusing the target synchronously inside the submit handler moves focus
within the same transaction, which the system treats as focus moving
between fields — keyboard stays up, like UIKit's
becomeFirstResponder-in-shouldReturn. The async pass stays as a safety
net: where the system's resign still wins, it re-asserts focus exactly
as before (bounce, but never a lost keyboard). Both writes are
idempotent.
… never dips

Two independent per-field @focusstate bools can never chain cleanly:
SwiftUI processes the old field's resign and the new field's focus as
separate operations, so every Next hop played a keyboard
down-and-back-up bounce regardless of timing (sync or async).

The accessory host (screen root and bottom-sheet root) now owns one
FocusState<String?> for its whole subtree, injected through the
environment; every text input adds a .focused(scope, equals: key)
binding alongside its local bool. A chained hop writes the target's key
into that one shared value — an atomic focus MOVE, the Focus Cookbook
enum recipe — and the keyboard stays up like UIKit's
becomeFirstResponder-in-shouldReturn. The local bool still drives each
field's own logic and remains the fallback when no host wraps the
context.
…dge responder

Even a screen-scoped shared FocusState written atomically inside
onSubmit still dips the keyboard on device — SwiftUI processes the
return key's resign and the target's focus as separate operations and
will not coalesce them across a submit. UIKit never dismisses the
keyboard while SOME responder holds it, so bridge the gap the UIKit
way: a zero-frame off-screen UITextField becomes first responder
synchronously at hop start (handoff, keyboard stays), SwiftUI focuses
the target on its own schedule (handoff, keyboard stays), and the
bridge cleans up. A grace-period settle resigns the bridge if the
target never claims focus, so a broken chain still drops the keyboard
instead of stranding typing in an invisible field. Keyboard type and
autocorrect mirror the source field so the held keyboard keeps its
layout.

(cherry picked from commit 9b854ac)
Deep research (web + code audit) landed on the real mechanism: SwiftUI
queues the keyboard dismissal as part of handling the return key
ITSELF, before .onSubmit runs, and since iOS 17 the keyboard is
out-of-process so that queued hide cannot be cancelled by anything the
submit handler does — sync focus writes, a screen-scoped shared
FocusState, and a UIKit bridge responder all dipped identically (and
Apple's own Focus Cookbook sample dips; developer forums thread 770226
remains unanswered). Every production framework converged on the same
fix: never queue the dismissal. React Native returns NO from
textFieldShouldReturn and moves first responder directly; Flutter and
Compose Multiplatform patched their identical bugs by never resigning
their hidden input view.

So do exactly that. An invisible introspection anchor in each field's
background finds the UIKit text field backing the SwiftUI TextField
(still UITextField-backed through iOS 26), wraps SwiftUI's delegate in
a forwarding proxy that reroutes only textFieldShouldReturn, and
registers the backing field with the focus registry. A chained return
key now runs our submit path and hops with a plain
becomeFirstResponder handoff — no dismissal is ever queued, the
keyboard never moves. Unchained fields forward to SwiftUI untouched.
The proxy reads a per-render-refreshed box, so republishes can't
leave it stale.

Gone with it: the keyboard bridge (its keyWindow() could grab UIKit's
keyboard helper windows and it acted after the dismissal was queued
anyway) and the shared FocusState scope (its owner sat outside the
NavigationStack, a different hosting controller from the fields — the
atomic write never engaged on stack screens). Also: accessory-bar
release is now deferred one runloop and sequence-guarded so a
pad-keyboard hop can't blink the 44pt bar out and back between blur
and claim.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant