From 2e5133fbe16d8e0697a653a6e2d94aad2dd15894 Mon Sep 17 00:00:00 2001 From: gwleuverink Date: Mon, 17 Aug 2026 19:30:01 +0200 Subject: [PATCH 1/6] fix(android): implement keep-focus-on-submit and dismiss the keyboard on submit by default --- resources/android/BareTextInputRenderer.kt | 13 +++++++++++++ resources/android/FilledTextInputRenderer.kt | 17 ++++++++++++++++- resources/android/OutlinedTextInputRenderer.kt | 17 ++++++++++++++++- resources/android/TextInputShared.kt | 2 ++ 4 files changed, 47 insertions(+), 2 deletions(-) diff --git a/resources/android/BareTextInputRenderer.kt b/resources/android/BareTextInputRenderer.kt index a52a7ef..f42325c 100644 --- a/resources/android/BareTextInputRenderer.kt +++ b/resources/android/BareTextInputRenderer.kt @@ -18,9 +18,11 @@ import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.unit.sp +import com.nativephp.mobile.ui.nativerender.KeyboardFocusPolicy import com.nativephp.mobile.ui.nativerender.NativeUINode import com.nativephp.mobile.ui.nativerender.argbToComposeColor import com.nativephp.plugins.native_ui.NativeUITheme @@ -79,6 +81,7 @@ object BareTextInputRenderer { var value by remember { mutableStateOf(TextFieldValue(props.serverValue, TextRange(props.serverValue.length))) } var lastSentValue by remember { mutableStateOf(props.serverValue) } var wasFocused by remember { mutableStateOf(false) } + val focusManager = LocalFocusManager.current // Caret / selection reporter — independent of the direct change/submit // dispatchers; no-op unless `on_selection_change` is wired and the @@ -123,11 +126,15 @@ object BareTextInputRenderer { // layout mode) still wins since it comes later in the chain. // onFocusChanged is Bare's blur hook (no InteractionSource here): // flush the pending selection on the focused → unfocused edge. + // The KeyboardFocusPolicy flag lets interactive taps elsewhere + // honor this field's keep-focus-on-submit (mobile-air #335). modifier = Modifier .fillMaxWidth() .onFocusChanged { state -> if (wasFocused && !state.isFocused) selectionReporter.flush(value) wasFocused = state.isFocused + KeyboardFocusPolicy.focusedFieldKeepsFocus = + state.isFocused && props.keepFocusOnSubmit } .then(modifier), enabled = !props.disabled, @@ -160,6 +167,12 @@ object BareTextInputRenderer { // Flush the settled caret before the submit event fires. selectionReporter.flush(value) props.dispatchSubmit?.invoke(value.text) + // Supplying KeyboardActions replaces Compose's default + // hide-on-Done, so dismissal is restored here to match + // the iOS renderer and the documented default (#335). + if (!props.keepFocusOnSubmit) { + focusManager.clearFocus() + } }) ) } diff --git a/resources/android/FilledTextInputRenderer.kt b/resources/android/FilledTextInputRenderer.kt index 0231c9c..76ac84c 100644 --- a/resources/android/FilledTextInputRenderer.kt +++ b/resources/android/FilledTextInputRenderer.kt @@ -20,11 +20,13 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.unit.dp +import com.nativephp.mobile.ui.nativerender.KeyboardFocusPolicy import com.nativephp.mobile.ui.nativerender.NativeUINode import com.nativephp.plugins.native_ui.NativeUITheme @@ -79,17 +81,24 @@ object FilledTextInputRenderer { } val interactionSource = remember { MutableInteractionSource() } + val focusManager = LocalFocusManager.current LaunchedEffect(interactionSource) { val focusStack = mutableListOf() interactionSource.interactions.collect { interaction: Interaction -> when (interaction) { - is FocusInteraction.Focus -> focusStack += interaction + is FocusInteraction.Focus -> { + focusStack += interaction + // Lets interactive taps elsewhere honor this + // field's keep-focus-on-submit (mobile-air #335). + KeyboardFocusPolicy.focusedFieldKeepsFocus = props.keepFocusOnSubmit + } is FocusInteraction.Unfocus -> { focusStack.remove(interaction.focus) if (focusStack.isEmpty()) { // Flush the pending selection, then any deferred text. selectionReporter.flush(value) dispatcher.onBlur(value.text) + KeyboardFocusPolicy.focusedFieldKeepsFocus = false } } else -> { /* ignore */ } @@ -147,6 +156,12 @@ object FilledTextInputRenderer { // Flush the settled caret before the submit event fires. selectionReporter.flush(value) dispatcher.onSubmit(value.text) + // Supplying KeyboardActions replaces Compose's default + // hide-on-Done, so dismissal is restored here to match + // the iOS renderer and the documented default (#335). + if (!props.keepFocusOnSubmit) { + focusManager.clearFocus() + } }), textStyle = TextStyle(fontSize = textSize, color = theme.onSurface, fontFamily = customFontFamily, lineHeight = lineHeight), colors = TextFieldDefaults.colors( diff --git a/resources/android/OutlinedTextInputRenderer.kt b/resources/android/OutlinedTextInputRenderer.kt index 4013484..eeda43a 100644 --- a/resources/android/OutlinedTextInputRenderer.kt +++ b/resources/android/OutlinedTextInputRenderer.kt @@ -20,11 +20,13 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.unit.dp +import com.nativephp.mobile.ui.nativerender.KeyboardFocusPolicy import com.nativephp.mobile.ui.nativerender.NativeUINode import com.nativephp.plugins.native_ui.NativeUITheme @@ -91,11 +93,17 @@ object OutlinedTextInputRenderer { // modes. Passing our own source also means we don't pay for M3's // default ripple-focus-hover machinery elsewhere. val interactionSource = remember { MutableInteractionSource() } + val focusManager = LocalFocusManager.current LaunchedEffect(interactionSource) { val focusStack = mutableListOf() interactionSource.interactions.collect { interaction: Interaction -> when (interaction) { - is FocusInteraction.Focus -> focusStack += interaction + is FocusInteraction.Focus -> { + focusStack += interaction + // Lets interactive taps elsewhere honor this + // field's keep-focus-on-submit (mobile-air #335). + KeyboardFocusPolicy.focusedFieldKeepsFocus = props.keepFocusOnSubmit + } is FocusInteraction.Unfocus -> { focusStack.remove(interaction.focus) if (focusStack.isEmpty()) { @@ -103,6 +111,7 @@ object OutlinedTextInputRenderer { // caret lands, then flush any deferred text change. selectionReporter.flush(value) dispatcher.onBlur(value.text) + KeyboardFocusPolicy.focusedFieldKeepsFocus = false } } else -> { /* ignore press/hover/drag */ } @@ -160,6 +169,12 @@ object OutlinedTextInputRenderer { // Flush the settled caret before the submit event fires. selectionReporter.flush(value) dispatcher.onSubmit(value.text) + // Supplying KeyboardActions replaces Compose's default + // hide-on-Done, so dismissal is restored here to match + // the iOS renderer and the documented default (#335). + if (!props.keepFocusOnSubmit) { + focusManager.clearFocus() + } }), textStyle = TextStyle(fontSize = textSize, color = theme.onSurface, fontFamily = customFontFamily, lineHeight = lineHeight), colors = OutlinedTextFieldDefaults.colors( diff --git a/resources/android/TextInputShared.kt b/resources/android/TextInputShared.kt index d8eb644..1da1b32 100644 --- a/resources/android/TextInputShared.kt +++ b/resources/android/TextInputShared.kt @@ -53,6 +53,7 @@ internal data class TextInputProps( val keyboard: KeyboardType, val capitalization: KeyboardCapitalization?, val disabled: Boolean, + val keepFocusOnSubmit: Boolean, val readOnly: Boolean, val isError: Boolean, val loading: Boolean, @@ -117,6 +118,7 @@ internal fun parseTextInputProps(node: NativeUINode): TextInputProps { keyboard = resolveKeyboardType(p.getString("keyboard")), capitalization = resolveCapitalization(p.getString("autocapitalize"), p.getString("keyboard")), disabled = p.getBool("disabled"), + keepFocusOnSubmit = p.getBool("keep_focus_on_submit"), readOnly = p.getBool("read_only"), isError = p.getBool("is_error"), loading = p.getBool("loading"), From 963728e0aabb57ad7453df18218dfc5816245336 Mon Sep 17 00:00:00 2001 From: gwleuverink Date: Mon, 17 Aug 2026 19:30:01 +0200 Subject: [PATCH 2/6] fix(ios): report focus state so presses can flush pending input and honor keep-focus-on-submit --- resources/ios/NativeUITextInputCore.swift | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/resources/ios/NativeUITextInputCore.swift b/resources/ios/NativeUITextInputCore.swift index 018d9a4..f7ac7ac 100644 --- a/resources/ios/NativeUITextInputCore.swift +++ b/resources/ios/NativeUITextInputCore.swift @@ -213,6 +213,15 @@ struct NativeUITextInputCore: View { scheduleSelectionEmit(text: text, cb: onSelectionCb, debounceMs: selDebounceMs) } .onChange(of: isFocused) { _, focused in + // Lets interactive taps elsewhere honor this field's + // keep-focus-on-submit, and gives press dispatch a + // flush hook so a tap-committed autocorrection's + // change reaches PHP first (mobile-air #335). + KeyboardFocusPolicy.focusedFieldKeepsFocus = focused && keepFocus + KeyboardFocusPolicy.focusedFieldActive = focused + KeyboardFocusPolicy.flushFocusedField = focused + ? { flushPending(onChangeCb: onChangeCb) } + : nil // On blur, flush any pending change — covers both `blur` mode // (never dispatched mid-typing) and `debounce` mode (in-flight // timer that should commit immediately rather than race with From 71079da9fba7c0c53bf3e58cb278a4b777d94bfd Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Thu, 1 Oct 2026 16:42:11 +0100 Subject: [PATCH 3/6] fix: register the focused field with a token and release it on teardown Follows the KeyboardFocusPolicy change in mobile-air #353. The inputs now call fieldFocused / fieldBlurred with a per-field token instead of writing the policy's flags. On Android, Bare set the flag synchronously while Filled and Outlined set it later from an interaction collector, so moving focus from a Filled field to a Bare one with keep-focus-on-submit left the flag false. A Bare field's first composition also wrote false over whatever another field had set. With the token a blur only counts when it comes from the field that owns the policy. Filled and Outlined read the keep-focus prop through rememberUpdatedState, since their effect keeps the props of the first composition. All three Android inputs and the iOS input also release the policy when they leave the tree, so a field popped while focused doesn't leave a flush closure behind. Co-Authored-By: Claude Opus 5.5 --- resources/android/BareTextInputRenderer.kt | 23 +++++++++++++++---- resources/android/FilledTextInputRenderer.kt | 18 +++++++++++++-- .../android/OutlinedTextInputRenderer.kt | 18 +++++++++++++-- resources/ios/NativeUITextInputCore.swift | 21 +++++++++++++---- 4 files changed, 67 insertions(+), 13 deletions(-) diff --git a/resources/android/BareTextInputRenderer.kt b/resources/android/BareTextInputRenderer.kt index 35a6618..ce94998 100644 --- a/resources/android/BareTextInputRenderer.kt +++ b/resources/android/BareTextInputRenderer.kt @@ -8,6 +8,7 @@ import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -84,6 +85,15 @@ object BareTextInputRenderer { var wasFocused by remember { mutableStateOf(false) } val focusManager = LocalFocusManager.current + // This field's identity in KeyboardFocusPolicy. A blur only releases + // the policy while this field still owns it, so a late blur can't + // clobber the field focus moved to. Released on dispose too, for a + // field that leaves composition while focused. + val focusToken = remember { Any() } + DisposableEffect(focusToken) { + onDispose { KeyboardFocusPolicy.fieldBlurred(focusToken) } + } + // Caret / selection reporter — independent of the direct change/submit // dispatchers; no-op unless `on_selection_change` is wired and the // field isn't secure. @@ -127,15 +137,20 @@ object BareTextInputRenderer { // layout mode) still wins since it comes later in the chain. // onFocusChanged is Bare's blur hook (no InteractionSource here): // flush the pending selection on the focused → unfocused edge. - // The KeyboardFocusPolicy flag lets interactive taps elsewhere - // honor this field's keep-focus-on-submit (mobile-air #335). + // Registering with KeyboardFocusPolicy lets interactive taps + // elsewhere honor this field's keep-focus-on-submit (mobile-air + // #335). The unfocused call this fires on first composition is a + // no-op there, since this field doesn't own the policy yet. modifier = Modifier .fillMaxWidth() .onFocusChanged { state -> if (wasFocused && !state.isFocused) selectionReporter.flush(value) wasFocused = state.isFocused - KeyboardFocusPolicy.focusedFieldKeepsFocus = - state.isFocused && props.keepFocusOnSubmit + if (state.isFocused) { + KeyboardFocusPolicy.fieldFocused(focusToken, props.keepFocusOnSubmit) + } else { + KeyboardFocusPolicy.fieldBlurred(focusToken) + } } .then(modifier) .nuiAutofocus(props.autofocus), diff --git a/resources/android/FilledTextInputRenderer.kt b/resources/android/FilledTextInputRenderer.kt index 0a5ff2c..9e16393 100644 --- a/resources/android/FilledTextInputRenderer.kt +++ b/resources/android/FilledTextInputRenderer.kt @@ -12,11 +12,13 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.TextField import androidx.compose.material3.TextFieldDefaults import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext @@ -88,6 +90,13 @@ object FilledTextInputRenderer { val interactionSource = remember { MutableInteractionSource() } val focusManager = LocalFocusManager.current + // This field's identity in KeyboardFocusPolicy. A blur only releases + // the policy while this field still owns it, so a blur that lands + // after the next field's focus can't clobber that field's state. + val focusToken = remember { Any() } + // The effect below keeps the `props` of the first composition, so + // the keep-focus value is read through updated state. + val keepFocusOnSubmit by rememberUpdatedState(props.keepFocusOnSubmit) LaunchedEffect(interactionSource) { val focusStack = mutableListOf() interactionSource.interactions.collect { interaction: Interaction -> @@ -96,7 +105,7 @@ object FilledTextInputRenderer { focusStack += interaction // Lets interactive taps elsewhere honor this // field's keep-focus-on-submit (mobile-air #335). - KeyboardFocusPolicy.focusedFieldKeepsFocus = props.keepFocusOnSubmit + KeyboardFocusPolicy.fieldFocused(focusToken, keepFocusOnSubmit) } is FocusInteraction.Unfocus -> { focusStack.remove(interaction.focus) @@ -104,13 +113,18 @@ object FilledTextInputRenderer { // Flush the pending selection, then any deferred text. selectionReporter.flush(value) dispatcher.onBlur(value.text) - KeyboardFocusPolicy.focusedFieldKeepsFocus = false + KeyboardFocusPolicy.fieldBlurred(focusToken) } } else -> { /* ignore */ } } } } + // A field that leaves composition while focused never sees its + // Unfocus, so release the policy here. + DisposableEffect(focusToken) { + onDispose { KeyboardFocusPolicy.fieldBlurred(focusToken) } + } val textSize = when (props.size) { "sm" -> theme.fontSm diff --git a/resources/android/OutlinedTextInputRenderer.kt b/resources/android/OutlinedTextInputRenderer.kt index 10e815d..4bad60e 100644 --- a/resources/android/OutlinedTextInputRenderer.kt +++ b/resources/android/OutlinedTextInputRenderer.kt @@ -12,11 +12,13 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextFieldDefaults import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext @@ -105,6 +107,13 @@ object OutlinedTextInputRenderer { // default ripple-focus-hover machinery elsewhere. val interactionSource = remember { MutableInteractionSource() } val focusManager = LocalFocusManager.current + // This field's identity in KeyboardFocusPolicy. A blur only releases + // the policy while this field still owns it, so a blur that lands + // after the next field's focus can't clobber that field's state. + val focusToken = remember { Any() } + // The effect below keeps the `props` of the first composition, so + // the keep-focus value is read through updated state. + val keepFocusOnSubmit by rememberUpdatedState(props.keepFocusOnSubmit) LaunchedEffect(interactionSource) { val focusStack = mutableListOf() interactionSource.interactions.collect { interaction: Interaction -> @@ -113,7 +122,7 @@ object OutlinedTextInputRenderer { focusStack += interaction // Lets interactive taps elsewhere honor this // field's keep-focus-on-submit (mobile-air #335). - KeyboardFocusPolicy.focusedFieldKeepsFocus = props.keepFocusOnSubmit + KeyboardFocusPolicy.fieldFocused(focusToken, keepFocusOnSubmit) } is FocusInteraction.Unfocus -> { focusStack.remove(interaction.focus) @@ -122,13 +131,18 @@ object OutlinedTextInputRenderer { // caret lands, then flush any deferred text change. selectionReporter.flush(value) dispatcher.onBlur(value.text) - KeyboardFocusPolicy.focusedFieldKeepsFocus = false + KeyboardFocusPolicy.fieldBlurred(focusToken) } } else -> { /* ignore press/hover/drag */ } } } } + // A field that leaves composition while focused never sees its + // Unfocus, so release the policy here. + DisposableEffect(focusToken) { + onDispose { KeyboardFocusPolicy.fieldBlurred(focusToken) } + } // Everything INSIDE the box. Two tones by default — typed text at full // emphasis, labels, placeholders and icons muted — which is the M3 diff --git a/resources/ios/NativeUITextInputCore.swift b/resources/ios/NativeUITextInputCore.swift index c8f5e9e..bb50880 100644 --- a/resources/ios/NativeUITextInputCore.swift +++ b/resources/ios/NativeUITextInputCore.swift @@ -53,6 +53,10 @@ struct NativeUITextInputCore: View { /// round-tripped anywhere near a password. @State private var revealed: Bool = false @FocusState private var isFocused: Bool + /// This field's identity in `KeyboardFocusPolicy`. A blur only releases + /// the policy while this field still owns it, so a blur that lands after + /// the next field's focus can't clobber that field's state. + @State private var focusToken = UUID() /// The enclosing vertical ``'s proxy, published by /// `NativeUIScrollViewRenderer`. Nil everywhere there isn't one — sheets, @@ -290,11 +294,13 @@ struct NativeUITextInputCore: View { // keep-focus-on-submit, and gives press dispatch a // flush hook so a tap-committed autocorrection's // change reaches PHP first (mobile-air #335). - KeyboardFocusPolicy.focusedFieldKeepsFocus = focused && keepFocus - KeyboardFocusPolicy.focusedFieldActive = focused - KeyboardFocusPolicy.flushFocusedField = focused - ? { flushPending(onChangeCb: onChangeCb) } - : nil + if focused { + KeyboardFocusPolicy.fieldFocused(focusToken, keepsFocus: keepFocus) { + flushPending(onChangeCb: onChangeCb) + } + } else { + KeyboardFocusPolicy.fieldBlurred(focusToken) + } // On blur, flush any pending change — covers both `blur` mode // (never dispatched mid-typing) and `debounce` mode (in-flight // timer that should commit immediately rather than race with @@ -341,6 +347,11 @@ struct NativeUITextInputCore: View { textSize: textSize, contentColor: contentColor )) + // A field popped off screen while focused gets no blur, so release + // the policy here rather than leave it holding this view's flush + // closure. Last in the chain so it sits outside the reveal toggle, + // whose SecureField / TextField swap is not the field leaving. + .onDisappear { KeyboardFocusPolicy.fieldBlurred(focusToken) } } // ─── Keyboard avoidance ────────────────────────────────────────────────── From 33937fb28f467a8d0b22ec2a5fd233c2fc4bc48c Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Thu, 1 Oct 2026 16:42:11 +0100 Subject: [PATCH 4/6] fix: route native press handlers through KeyboardFocusPolicy Buttons, list items, icons, chips with on_press and pressable text runs sent their press straight to the bridge, so they skipped the policy that @press on a plain container goes through. On Android the control consumes the tap, so nothing dropped the keyboard. These handlers now call dismissForInteractiveTap() first, which clears focus unless the focused field keeps it. On iOS the root gesture in core handles dismissal, but the press still needs dispatchPress so a pending text change, such as an autocorrection the tap just committed, reaches PHP before the press does. Menu items, nav chrome, refresh, modal dismiss, swipe actions and the selection controls are left as they were. Co-Authored-By: Claude Opus 5.5 --- resources/android/ButtonRenderer.kt | 11 ++++++++++- resources/android/ChipRenderer.kt | 4 ++++ resources/android/IconRenderer.kt | 4 ++++ resources/android/ListItemRenderer.kt | 9 ++++++++- resources/android/TextRenderer.kt | 4 ++++ resources/ios/NativeUIButtonRenderer.swift | 7 ++++++- resources/ios/NativeUIChipRenderer.swift | 6 +++++- resources/ios/NativeUIListItemRenderer.swift | 7 ++++++- resources/ios/NativeUITextRenderer.swift | 6 +++++- 9 files changed, 52 insertions(+), 6 deletions(-) diff --git a/resources/android/ButtonRenderer.kt b/resources/android/ButtonRenderer.kt index c4123f1..ff40f60 100644 --- a/resources/android/ButtonRenderer.kt +++ b/resources/android/ButtonRenderer.kt @@ -28,6 +28,7 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.dp import com.nativephp.mobile.ui.MaterialIcon +import com.nativephp.mobile.ui.nativerender.KeyboardFocusPolicy import com.nativephp.mobile.ui.nativerender.NativeUIBridge import com.nativephp.mobile.ui.nativerender.NativeUINode import androidx.compose.foundation.isSystemInDarkTheme @@ -81,7 +82,15 @@ object ButtonRenderer { val onClick: () -> Unit = if (hasMenu) { { menuExpanded = true } } else { - { if (pressCb != 0) NativeUIBridge.sendPressEvent(pressCb, node.id) } + { + if (pressCb != 0) { + // The Button consumes the tap, so the root's tap-away + // never runs. Drop the keyboard here unless the focused + // field keeps focus (mobile-air #335). + KeyboardFocusPolicy.dismissForInteractiveTap() + NativeUIBridge.sendPressEvent(pressCb, node.id) + } + } } val buttonModifier = modifier diff --git a/resources/android/ChipRenderer.kt b/resources/android/ChipRenderer.kt index 7941316..28e9196 100644 --- a/resources/android/ChipRenderer.kt +++ b/resources/android/ChipRenderer.kt @@ -14,6 +14,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import com.nativephp.mobile.ui.MaterialIcon +import com.nativephp.mobile.ui.nativerender.KeyboardFocusPolicy import com.nativephp.mobile.ui.nativerender.NativeUIBridge import com.nativephp.mobile.ui.nativerender.NativeUINode import com.nativephp.plugins.native_ui.NativeUITheme @@ -73,6 +74,9 @@ object ChipRenderer { // Server-driven: the press handler owns selection. Toggling // locally would make the chip fight the state it is handed // back, so a filter chip would flicker off on its own tap. + // The chip consumes the tap, so drop the keyboard here + // unless the focused field keeps focus (mobile-air #335). + KeyboardFocusPolicy.dismissForInteractiveTap() NativeUIBridge.sendPressEvent(onPressCb, node.id) } else { val new = !isSelected diff --git a/resources/android/IconRenderer.kt b/resources/android/IconRenderer.kt index d668014..4879662 100644 --- a/resources/android/IconRenderer.kt +++ b/resources/android/IconRenderer.kt @@ -8,6 +8,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.semantics.Role import androidx.compose.ui.unit.dp +import com.nativephp.mobile.ui.nativerender.KeyboardFocusPolicy import com.nativephp.mobile.ui.nativerender.NativeUIBridge import com.nativephp.mobile.ui.nativerender.NativeUINode @@ -41,6 +42,9 @@ object IconRenderer { Modifier .minimumInteractiveComponentSize() .clickable(role = Role.Button) { + // `clickable` consumes the tap, so drop the keyboard + // here unless the focused field keeps focus (#335). + KeyboardFocusPolicy.dismissForInteractiveTap() NativeUIBridge.sendPressEvent(node.onPress, node.id) } } else { diff --git a/resources/android/ListItemRenderer.kt b/resources/android/ListItemRenderer.kt index 29dc703..3a0463c 100644 --- a/resources/android/ListItemRenderer.kt +++ b/resources/android/ListItemRenderer.kt @@ -34,6 +34,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import coil3.compose.SubcomposeAsyncImage +import com.nativephp.mobile.ui.nativerender.KeyboardFocusPolicy import com.nativephp.mobile.ui.nativerender.NativeUIBridge import com.nativephp.mobile.ui.nativerender.NativeUINode @@ -81,17 +82,21 @@ object ListItemRenderer { val pressCbId = node.onPress val longPressCbId = node.onLongPress - // Click / gesture modifier + // Click / gesture modifier. These consume the tap, so the root's + // tap-away never runs; each one drops the keyboard itself unless + // the focused field keeps focus (mobile-air #335). val clickModifier = if (longPressCbId != 0) { modifier.pointerInput(pressCbId, longPressCbId) { detectTapGestures( onTap = { if (!disabled && pressCbId != 0) { + KeyboardFocusPolicy.dismissForInteractiveTap() NativeUIBridge.sendPressEvent(pressCbId, node.id) } }, onLongPress = { if (!disabled) { + KeyboardFocusPolicy.dismissForInteractiveTap() NativeUIBridge.sendLongPressEvent(longPressCbId, node.id) } } @@ -99,6 +104,7 @@ object ListItemRenderer { } } else if (pressCbId != 0) { modifier.clickable(enabled = !disabled, role = Role.Button) { + KeyboardFocusPolicy.dismissForInteractiveTap() NativeUIBridge.sendPressEvent(pressCbId, node.id) } } else { @@ -470,6 +476,7 @@ object ListItemRenderer { IconButton( onClick = { if (onPressCb != 0) { + KeyboardFocusPolicy.dismissForInteractiveTap() NativeUIBridge.sendPressEvent(onPressCb, nodeId) } }, diff --git a/resources/android/TextRenderer.kt b/resources/android/TextRenderer.kt index c50cf00..0ca11ac 100644 --- a/resources/android/TextRenderer.kt +++ b/resources/android/TextRenderer.kt @@ -26,6 +26,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.em import androidx.compose.ui.unit.sp +import com.nativephp.mobile.ui.nativerender.KeyboardFocusPolicy import com.nativephp.mobile.ui.nativerender.NativeUIBridge import com.nativephp.mobile.ui.nativerender.NativeUINode import com.nativephp.mobile.ui.nativerender.argbToComposeColor @@ -211,6 +212,9 @@ private fun AnnotatedString.Builder.appendTextRuns(node: NativeUINode, inherited val target = ctx.pressNodeId withLink( LinkAnnotation.Clickable(tag = "nativephp-press", styles = linkStyles, linkInteractionListener = { + // The link consumes the tap, so drop the keyboard here + // unless the focused field keeps focus (mobile-air #335). + KeyboardFocusPolicy.dismissForInteractiveTap() NativeUIBridge.sendPressEvent(cb, target) }) ) { diff --git a/resources/ios/NativeUIButtonRenderer.swift b/resources/ios/NativeUIButtonRenderer.swift index 9d5ad41..312cab5 100644 --- a/resources/ios/NativeUIButtonRenderer.swift +++ b/resources/ios/NativeUIButtonRenderer.swift @@ -99,7 +99,12 @@ struct NativeUIButtonRenderer: View { let action = { if pressCb != 0 { - NativeElementBridge.sendPressEvent(pressCb, nodeId: node.id) + // Through the focus policy so a focused field's pending + // change (an autocorrection this tap just committed) + // reaches PHP before the press does (mobile-air #335). + KeyboardFocusPolicy.dispatchPress { + NativeElementBridge.sendPressEvent(pressCb, nodeId: node.id) + } } } diff --git a/resources/ios/NativeUIChipRenderer.swift b/resources/ios/NativeUIChipRenderer.swift index 3884352..c073b8a 100644 --- a/resources/ios/NativeUIChipRenderer.swift +++ b/resources/ios/NativeUIChipRenderer.swift @@ -50,7 +50,11 @@ struct NativeUIChipRenderer: View { // Server-driven: the press handler owns selection. Toggling // locally would make the chip fight the state it is handed // back, so a filter chip would flicker off on its own tap. - NativeElementBridge.sendPressEvent(onPressCb, nodeId: node.id) + // Through the focus policy so a focused field's pending + // change reaches PHP before the press does (mobile-air #335). + KeyboardFocusPolicy.dispatchPress { + NativeElementBridge.sendPressEvent(onPressCb, nodeId: node.id) + } } else { let new = !isSelected isSelected = new diff --git a/resources/ios/NativeUIListItemRenderer.swift b/resources/ios/NativeUIListItemRenderer.swift index f89ba7d..534cf7f 100644 --- a/resources/ios/NativeUIListItemRenderer.swift +++ b/resources/ios/NativeUIListItemRenderer.swift @@ -281,7 +281,12 @@ struct NativeUIListItemRenderer: View { Button(action: { let onPressCb = node.props.getCallbackId("on_trailing_press") if onPressCb != 0 { - NativeUIBridge.sendPressEvent(onPressCb, nodeId: node.id) + // Through the focus policy so a focused field's + // pending change reaches PHP before the press + // does (mobile-air #335). + KeyboardFocusPolicy.dispatchPress { + NativeUIBridge.sendPressEvent(onPressCb, nodeId: node.id) + } } }) { Image(systemName: getIconForName(value)) diff --git a/resources/ios/NativeUITextRenderer.swift b/resources/ios/NativeUITextRenderer.swift index 79a579e..318ecb7 100644 --- a/resources/ios/NativeUITextRenderer.swift +++ b/resources/ios/NativeUITextRenderer.swift @@ -146,7 +146,11 @@ struct NativeUITextRenderer: View { guard url.scheme == "nativephp-press" else { return .systemAction } let parts = url.pathComponents.filter { $0 != "/" } if parts.count == 2, let cb = Int(parts[0]), let nodeId = Int(parts[1]), cb != 0 { - NativeElementBridge.sendPressEvent(cb, nodeId: nodeId) + // Through the focus policy so a focused field's pending + // change reaches PHP before the press does (mobile-air #335). + KeyboardFocusPolicy.dispatchPress { + NativeElementBridge.sendPressEvent(cb, nodeId: nodeId) + } } return .handled }) From 178937ceb2d325f6c7f0ea9e876d7cb3dce0a64f Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Thu, 1 Oct 2026 16:42:11 +0100 Subject: [PATCH 5/6] chore: require nativephp/mobile ^4.6 The renderers now reference KeyboardFocusPolicy, which first ships in the core release that includes mobile-air #353. Without the bump an app that updates this plugin on an older core fails to compile on both platforms. 4.6 is the assumed version for that release. Adjust if it ships under a different number. Co-Authored-By: Claude Opus 5.5 --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index e0e5574..12af0e3 100644 --- a/composer.json +++ b/composer.json @@ -11,7 +11,7 @@ ], "require": { "php": "^8.3", - "nativephp/mobile": "^4.5" + "nativephp/mobile": "^4.6" }, "autoload": { "psr-4": { From 341437ba8349c79cb191aaf92972ef66be8646a2 Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Fri, 2 Oct 2026 14:16:56 +0100 Subject: [PATCH 6/6] fix(android): drop the keyboard for taps on the remaining controls On iOS the root gesture in core drops the keyboard for any tap a control claims. On Android the control consumes the tap, so each one has to ask the policy itself. Buttons, list items, icons and pressable text already did. This adds the same call to checkbox, radio, toggle, chip, accordion, button group, tab row, the list item's leading and trailing selection controls, and the select and date picker triggers. As before, a focused field with keep-focus-on-submit keeps the keyboard. The select and date picker are the exception: they open their own window, which takes the keyboard whatever the field asked. Co-Authored-By: Claude Opus 5.5 --- resources/android/AccordionRenderer.kt | 6 +++++- resources/android/ButtonGroupRenderer.kt | 5 +++++ resources/android/CheckboxRenderer.kt | 4 ++++ resources/android/ChipRenderer.kt | 6 +++--- resources/android/DatePickerRenderer.kt | 5 +++++ resources/android/ListItemRenderer.kt | 4 ++++ resources/android/RadioRenderer.kt | 8 +++++++- resources/android/SelectRenderer.kt | 10 +++++++++- resources/android/TabRowRenderer.kt | 5 +++++ resources/android/ToggleRenderer.kt | 4 ++++ 10 files changed, 51 insertions(+), 6 deletions(-) diff --git a/resources/android/AccordionRenderer.kt b/resources/android/AccordionRenderer.kt index 286a3ee..03cf6d3 100644 --- a/resources/android/AccordionRenderer.kt +++ b/resources/android/AccordionRenderer.kt @@ -24,6 +24,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.rotate import androidx.compose.ui.unit.dp +import com.nativephp.mobile.ui.nativerender.KeyboardFocusPolicy import com.nativephp.mobile.ui.nativerender.NativeUIBridge import com.nativephp.mobile.ui.nativerender.NativeUINode import com.nativephp.mobile.ui.nativerender.NodeView @@ -55,8 +56,11 @@ object AccordionRenderer { } // One definition of "the user toggled it", shared by the header row - // and the chevron button so either route reports back to PHP. + // and the chevron button so either route reports back to PHP. Both + // consume the tap, so drop the keyboard here unless the focused + // field keeps focus (mobile-air #335). val toggle = { + KeyboardFocusPolicy.dismissForInteractiveTap() val new = !isExpanded isExpanded = new lastSentValue = new diff --git a/resources/android/ButtonGroupRenderer.kt b/resources/android/ButtonGroupRenderer.kt index c6651f6..734f1b5 100644 --- a/resources/android/ButtonGroupRenderer.kt +++ b/resources/android/ButtonGroupRenderer.kt @@ -14,6 +14,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics +import com.nativephp.mobile.ui.nativerender.KeyboardFocusPolicy import com.nativephp.mobile.ui.nativerender.NativeUIBridge import com.nativephp.mobile.ui.nativerender.NativeUINode import com.nativephp.plugins.native_ui.NativeUITheme @@ -67,6 +68,10 @@ object ButtonGroupRenderer { SegmentedButton( shape = SegmentedButtonDefaults.itemShape(index = index, count = options.size), onClick = { + // The segment consumes the tap, so drop the keyboard + // here unless the focused field keeps focus + // (mobile-air #335). + KeyboardFocusPolicy.dismissForInteractiveTap() selectedIndex = index lastSentValue = index if (onChangeCb != 0) { diff --git a/resources/android/CheckboxRenderer.kt b/resources/android/CheckboxRenderer.kt index 4ef6ef2..5d2eada 100644 --- a/resources/android/CheckboxRenderer.kt +++ b/resources/android/CheckboxRenderer.kt @@ -19,6 +19,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.semantics.Role import androidx.compose.ui.unit.dp +import com.nativephp.mobile.ui.nativerender.KeyboardFocusPolicy import com.nativephp.mobile.ui.nativerender.NativeUIBridge import com.nativephp.mobile.ui.nativerender.NativeUINode import com.nativephp.plugins.native_ui.NativeUITheme @@ -68,6 +69,9 @@ object CheckboxRenderer { ) val onChanged = { new: Boolean -> + // The row consumes the tap, so drop the keyboard here unless the + // focused field keeps focus (mobile-air #335). + KeyboardFocusPolicy.dismissForInteractiveTap() checked = new lastSentValue = new if (onChangeCb != 0) { diff --git a/resources/android/ChipRenderer.kt b/resources/android/ChipRenderer.kt index 28e9196..5dc84e4 100644 --- a/resources/android/ChipRenderer.kt +++ b/resources/android/ChipRenderer.kt @@ -70,13 +70,13 @@ object ChipRenderer { FilterChip( selected = isSelected, onClick = { + // The chip consumes the tap, so drop the keyboard here unless + // the focused field keeps focus (mobile-air #335). + KeyboardFocusPolicy.dismissForInteractiveTap() if (onPressCb != 0) { // Server-driven: the press handler owns selection. Toggling // locally would make the chip fight the state it is handed // back, so a filter chip would flicker off on its own tap. - // The chip consumes the tap, so drop the keyboard here - // unless the focused field keeps focus (mobile-air #335). - KeyboardFocusPolicy.dismissForInteractiveTap() NativeUIBridge.sendPressEvent(onPressCb, node.id) } else { val new = !isSelected diff --git a/resources/android/DatePickerRenderer.kt b/resources/android/DatePickerRenderer.kt index c515704..f6911da 100644 --- a/resources/android/DatePickerRenderer.kt +++ b/resources/android/DatePickerRenderer.kt @@ -29,6 +29,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.text.TextStyle import androidx.compose.ui.unit.dp import com.nativephp.mobile.ui.MaterialIcon +import com.nativephp.mobile.ui.nativerender.KeyboardFocusPolicy import com.nativephp.mobile.ui.nativerender.NativeUIBridge import com.nativephp.mobile.ui.nativerender.NativeUINode import com.nativephp.plugins.native_ui.NativeUITheme @@ -194,6 +195,10 @@ object DatePickerRenderer { Modifier .matchParentSize() .clickable(enabled = !disabled) { + // The trigger consumes the tap, so drop the + // keyboard here unless the focused field + // keeps focus (mobile-air #335). + KeyboardFocusPolicy.dismissForInteractiveTap() pendingDate = null showDialog = true } diff --git a/resources/android/ListItemRenderer.kt b/resources/android/ListItemRenderer.kt index 3a0463c..0cf7841 100644 --- a/resources/android/ListItemRenderer.kt +++ b/resources/android/ListItemRenderer.kt @@ -313,6 +313,7 @@ object ListItemRenderer { Checkbox( checked = checked, onCheckedChange = { newValue -> + KeyboardFocusPolicy.dismissForInteractiveTap() checked = newValue if (onChangeCb != 0) { NativeUIBridge.sendCheckboxChangeEvent(onChangeCb, nodeId, newValue) @@ -326,6 +327,7 @@ object ListItemRenderer { RadioButton( selected = selected, onClick = { + KeyboardFocusPolicy.dismissForInteractiveTap() selected = !selected if (onChangeCb != 0) { NativeUIBridge.sendCheckboxChangeEvent(onChangeCb, nodeId, selected) @@ -424,6 +426,7 @@ object ListItemRenderer { Checkbox( checked = checked, onCheckedChange = { newValue -> + KeyboardFocusPolicy.dismissForInteractiveTap() checked = newValue if (onChangeCb != 0) { NativeUIBridge.sendCheckboxChangeEvent(onChangeCb, nodeId, newValue) @@ -437,6 +440,7 @@ object ListItemRenderer { Switch( checked = checked, onCheckedChange = { newValue -> + KeyboardFocusPolicy.dismissForInteractiveTap() checked = newValue if (onChangeCb != 0) { NativeUIBridge.sendToggleChangeEvent(onChangeCb, nodeId, newValue) diff --git a/resources/android/RadioRenderer.kt b/resources/android/RadioRenderer.kt index d3decda..c665acf 100644 --- a/resources/android/RadioRenderer.kt +++ b/resources/android/RadioRenderer.kt @@ -13,6 +13,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.semantics.Role import androidx.compose.ui.unit.dp +import com.nativephp.mobile.ui.nativerender.KeyboardFocusPolicy import com.nativephp.mobile.ui.nativerender.NativeUINode import com.nativephp.plugins.native_ui.NativeUITheme @@ -63,7 +64,12 @@ object RadioRenderer { selected = isSelected, enabled = !disabled, role = Role.RadioButton, - onClick = { onSelect?.invoke(value) }, + onClick = { + // The row consumes the tap, so drop the keyboard here + // unless the focused field keeps focus (mobile-air #335). + KeyboardFocusPolicy.dismissForInteractiveTap() + onSelect?.invoke(value) + }, ), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), diff --git a/resources/android/SelectRenderer.kt b/resources/android/SelectRenderer.kt index 2251484..c5d92a6 100644 --- a/resources/android/SelectRenderer.kt +++ b/resources/android/SelectRenderer.kt @@ -17,6 +17,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.text.TextStyle +import com.nativephp.mobile.ui.nativerender.KeyboardFocusPolicy import com.nativephp.mobile.ui.nativerender.NativeUIBridge import com.nativephp.mobile.ui.nativerender.NativeUINode import com.nativephp.plugins.native_ui.NativeUITheme @@ -60,7 +61,14 @@ object SelectRenderer { ExposedDropdownMenuBox( expanded = expanded, - onExpandedChange = { if (!disabled) expanded = it }, + onExpandedChange = { + if (!disabled) { + // The anchor consumes the tap, so drop the keyboard here + // unless the focused field keeps focus (mobile-air #335). + KeyboardFocusPolicy.dismissForInteractiveTap() + expanded = it + } + }, modifier = anchorModifier, ) { OutlinedTextField( diff --git a/resources/android/TabRowRenderer.kt b/resources/android/TabRowRenderer.kt index 3660f06..1a9bf6c 100644 --- a/resources/android/TabRowRenderer.kt +++ b/resources/android/TabRowRenderer.kt @@ -16,6 +16,7 @@ import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp import com.nativephp.mobile.ui.MaterialIcon +import com.nativephp.mobile.ui.nativerender.KeyboardFocusPolicy import com.nativephp.mobile.ui.nativerender.NativeUIBridge import com.nativephp.mobile.ui.nativerender.NativeUINode import com.nativephp.plugins.native_ui.NativeUITheme @@ -67,6 +68,10 @@ object TabRowRenderer { Tab( selected = isSelected, onClick = { + // The tab consumes the tap, so drop the keyboard + // here unless the focused field keeps focus + // (mobile-air #335). + KeyboardFocusPolicy.dismissForInteractiveTap() selectedIndex = index lastSentValue = index if (onChangeCb != 0) { diff --git a/resources/android/ToggleRenderer.kt b/resources/android/ToggleRenderer.kt index b51f013..b9b31bf 100644 --- a/resources/android/ToggleRenderer.kt +++ b/resources/android/ToggleRenderer.kt @@ -18,6 +18,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.semantics.Role import androidx.compose.ui.unit.dp +import com.nativephp.mobile.ui.nativerender.KeyboardFocusPolicy import com.nativephp.mobile.ui.nativerender.NativeUIBridge import com.nativephp.mobile.ui.nativerender.NativeUINode import com.nativephp.plugins.native_ui.NativeUITheme @@ -64,6 +65,9 @@ object ToggleRenderer { ) val onChanged = { new: Boolean -> + // The switch consumes the tap, so drop the keyboard here unless + // the focused field keeps focus (mobile-air #335). + KeyboardFocusPolicy.dismissForInteractiveTap() checked = new lastSentValue = new if (onChangeCb != 0) {