feature: chain keyboard focus between text inputs with next-focus - #70
Open
SRWieZ wants to merge 10 commits into
Open
feature: chain keyboard focus between text inputs with next-focus#70SRWieZ wants to merge 10 commits into
SRWieZ wants to merge 10 commits into
Conversation
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.
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 samerefthatNative::test()targets — say the word if you'd rather have a dedicated attribute).When
next-focusis set andsubmit-labelisn'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
ref, token-guarded so stacked screens can shadow a ref safely.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)..onSubmitruns — 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: intercepttextFieldShouldReturnon the UIKit field behind the SwiftUI TextField, returnfalseso no dismissal ever starts, and move first responder straight to the target. The keyboard never moves. Fields without a chain keep 100% stock behavior.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_reffromref.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):