Skip to content

Symmetric keyboard dismissal with keep-focus-on-submit as the escape hatch - #353

Open
gwleuverink wants to merge 1 commit into
NativePHP:mainfrom
trailhead-labs:fix/335-keyboard-dismiss-symmetry
Open

gwleuverink wants to merge 1 commit into
NativePHP:mainfrom
trailhead-labs:fix/335-keyboard-dismiss-symmetry

Conversation

@gwleuverink

Copy link
Copy Markdown
Contributor

Fixes #335. Companion PR: NativePHP/mobile-ui#53 carries the input-renderer half. They need to land together.

The report was a chat composer, a bare-text-input and a send pressable in one row. On iOS, tapping send fires @press and dismisses the keyboard in the same tap, so the user re-focuses the field after every message. keep-focus-on-submit didn't help, it only covered the return key. Reproduced exactly as filed.

While verifying I mapped the dismissal behaviour on both platforms, and they disagreed on nearly everything. Tapping a button while a field was focused dismissed on iOS but not on Android. Submit dismissed on iOS but not on Android, even though the docs describe dismiss as the default. And keep-focus-on-submit worked on iOS but was silently ignored on Android, the renderer never parsed the prop.

The fix rests on three decisions. The platforms should behave identically, and the web-like default wins: the keyboard drops on submit and on any tap outside the field, which is also the right default for regular forms, where tapping Submit should close the keyboard. Only Android's defaults change, iOS already behaved this way and its defaults are untouched. And keep-focus-on-submit becomes the one escape hatch that genuinely works on both platforms, keeping the field focused through the return key and through button sends alike. Tapping empty space always dismisses, even with the attribute, same as iMessage and WhatsApp transcripts.

Mechanically, the screen-level dismiss gesture is now a regular gesture instead of a simultaneousGesture, so it only fires for taps nothing else claimed, the plain-area path. Taps on interactive elements dismiss through a small KeyboardFocusPolicy that press dispatch consults, which is what lets the focused field's attribute exempt them. Android mirrors the same shape, its existing root clickable stays the plain-area path and the press modifiers route through the policy.

While testing on iOS we also caught a subtle one, worth spelling out because it shaped how press dispatch works. iOS commits a pending autocorrect suggestion when you tap elsewhere, and with the keyboard no longer resigning on a send tap, that commit now lands after the press has already been dispatched. The sequence was: tap send, PHP receives the press and sends the draft as typed, iOS then applies the correction to the field, and the late change event re-fills the server-side draft that send() just cleared. The field stays filled with the corrected word, and tapping send again produces a ghost second message.

Press dispatch on iOS now does two things about that. It flushes the focused field's pending change first, and it defers the press event by one runloop turn, so the correction's change event reaches PHP before the press does. A send with a pending suggestion now sends the corrected text, clears the field, and adds exactly one message. Verified on the simulator. Android turned out not to have the race at all, Gboard only suggests passively and never applies a correction on an unrelated tap, also verified on the emulator.

The behavioural change is Android-only and lives mostly in the companion PR: return and button taps start dropping the keyboard by default there, like iOS always did. No API changes, existing code runs as is. Android chat screens that relied on the keyboard staying up add keep-focus-on-submit to the input.

Verified on device, Android emulator (dumpsys mInputShown plus screenshots) and iOS simulator (manual):

iOS before iOS after Android before Android after
Button tap, default drops drops keeps drops
Submit, default drops drops keeps drops
Plain-area tap, default drops drops drops drops
Button tap + attr drops keeps keeps keeps
Submit + attr keeps keeps keeps keeps
Plain-area tap + attr drops drops drops drops

Also verified on both platforms: the multiline composer from the issue (return inserts a newline, the send button sends the whole draft, the keyboard never moves) and the autocorrect scenario above.

One known limitation, pre-existing: with the attribute on a single-line field, the return key still shows a brief keyboard bounce on iOS (resign then refocus). That's an acknowledged SwiftUI platform bug, Apple's own focus sample reproduces it, and the in-code notes in NativeUITextInputCore already point at a UIKit-backed field as the eventual fix if it ever becomes worth it. It doesn't affect button sends or multiline composers, which is where the attribute matters.

@shrutibalasawebdev

Copy link
Copy Markdown

Verified on iOS with a real chat composer (bare-text-input + send button, keep-focus-on-submit), running this branch together with NativePHP/mobile-ui#53. Send keeps the keyboard up, the field clears, and I get exactly one message.

@shanerbaner82 shanerbaner82 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Read this together with NativePHP/mobile-ui#53. The direction is right (symmetric defaults, keep-focus-on-submit as the one escape hatch); these are the things I hit verifying the pair.

1. iOS regression: native <button> taps stop dismissing the keyboard. SwiftUINodeRenderer.swift:175-181 swaps the root simultaneousGesture for .gesture(TapGesture()). SwiftUI gives the child gesture precedence, so a Button(action:) swallows the tap and the root gesture never fires. NativeUIButtonRenderer.swift:100-104 (also Chip:46, Checkbox:29, ListItem:210/285) calls sendPressEvent straight from Button(action:), never through KeyboardFocusPolicy.dispatchPress. Focused field, tap a <button> submit: keyboard stays up. Before this PR it dropped. The matrix's "button tap, default: iOS drops" only holds for @press pressables, which is what the issue's repro used.

2. Android: the same bypass, so the symmetry isn't reached. ButtonRenderer.kt:84 and ListItemRenderer.kt:89-101 dispatch from Compose Button(onClick) / clickable; the inner clickable consumes the tap, so nodeGestures' dismissForInteractiveTap() (NodeModifiers.kt:331) never runs for them. After both PRs: pressable tap drops, <button> tap keeps. Not a regression, but the description says Android drops on button tap.

3. Merge order: mobile-ui#53 alone breaks every native build. BareTextInputRenderer.kt:25, Filled:29, Outlined:29 import com.nativephp.mobile.ui.nativerender.KeyboardFocusPolicy; NativeUITextInputCore.swift:220-222 references it unqualified. mobile-ui's composer.json still allows core ^4.0, so anyone updating the plugin before the core release gets an unresolved symbol on both platforms. This PR first is safe (flags default false). #53 needs a constraint bump to the core version this ships in.

4. Android: stale flag after a cross-variant focus move. Bare sets focusedFieldKeepsFocus synchronously in onFocusChanged (Bare:136); Filled/Outlined set it from a LaunchedEffect collector (Filled:93/101). Focus moving Filled(no attr) → Bare(with attr): Bare writes true, then Filled's deferred unfocus writes false, and the next send tap dismisses despite the attribute. Only on screens mixing variants with different attrs.

5. iOS: the static policy state has no teardown. NativeUITextInputCore.swift has onAppear but no onDisappear. Pop a screen while a field is focused and flushFocusedField keeps a closure over the dead view; the next press anywhere calls that stale flush and gets deferred. Self-heals once any field focuses. Low, not reproduced on device.

6. No tests. KeyboardFocusPolicy (Kotlin) is a plain object and the JUnit harness exists (NativeUIBridgeTest.kt), so the dismissForInteractiveTap gating is unit-testable.

Checked and fine: Android submit clears focus in all three variants; long-press/double-tap routed on both platforms; iOS flush-before-resign order; DisposableEffect clears clearFocus; PHP side unchanged (BaseTextInput.php:307 already emits keep_focus_on_submit).

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.

[Masterclass] No way to keep the keyboard up when sending via a button - tap-to-dismiss fires on every tap outside the input

3 participants