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": { 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/BareTextInputRenderer.kt b/resources/android/BareTextInputRenderer.kt index dba10c8..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 @@ -19,9 +20,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 @@ -80,6 +83,16 @@ 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 + + // 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 @@ -124,11 +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. + // 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 + if (state.isFocused) { + KeyboardFocusPolicy.fieldFocused(focusToken, props.keepFocusOnSubmit) + } else { + KeyboardFocusPolicy.fieldBlurred(focusToken) + } } .then(modifier) .nuiAutofocus(props.autofocus), @@ -175,6 +197,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/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/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/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 7941316..5dc84e4 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 @@ -69,6 +70,9 @@ 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 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/FilledTextInputRenderer.kt b/resources/android/FilledTextInputRenderer.kt index 270bb67..9e16393 100644 --- a/resources/android/FilledTextInputRenderer.kt +++ b/resources/android/FilledTextInputRenderer.kt @@ -12,19 +12,23 @@ 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 +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 @@ -85,23 +89,42 @@ 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 -> 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.fieldFocused(focusToken, 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.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 @@ -162,6 +185,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/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..0cf7841 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 { @@ -307,6 +313,7 @@ object ListItemRenderer { Checkbox( checked = checked, onCheckedChange = { newValue -> + KeyboardFocusPolicy.dismissForInteractiveTap() checked = newValue if (onChangeCb != 0) { NativeUIBridge.sendCheckboxChangeEvent(onChangeCb, nodeId, newValue) @@ -320,6 +327,7 @@ object ListItemRenderer { RadioButton( selected = selected, onClick = { + KeyboardFocusPolicy.dismissForInteractiveTap() selected = !selected if (onChangeCb != 0) { NativeUIBridge.sendCheckboxChangeEvent(onChangeCb, nodeId, selected) @@ -418,6 +426,7 @@ object ListItemRenderer { Checkbox( checked = checked, onCheckedChange = { newValue -> + KeyboardFocusPolicy.dismissForInteractiveTap() checked = newValue if (onChangeCb != 0) { NativeUIBridge.sendCheckboxChangeEvent(onChangeCb, nodeId, newValue) @@ -431,6 +440,7 @@ object ListItemRenderer { Switch( checked = checked, onCheckedChange = { newValue -> + KeyboardFocusPolicy.dismissForInteractiveTap() checked = newValue if (onChangeCb != 0) { NativeUIBridge.sendToggleChangeEvent(onChangeCb, nodeId, newValue) @@ -470,6 +480,7 @@ object ListItemRenderer { IconButton( onClick = { if (onPressCb != 0) { + KeyboardFocusPolicy.dismissForInteractiveTap() NativeUIBridge.sendPressEvent(onPressCb, nodeId) } }, diff --git a/resources/android/OutlinedTextInputRenderer.kt b/resources/android/OutlinedTextInputRenderer.kt index a7d78e0..4bad60e 100644 --- a/resources/android/OutlinedTextInputRenderer.kt +++ b/resources/android/OutlinedTextInputRenderer.kt @@ -12,19 +12,23 @@ 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 +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 @@ -102,11 +106,24 @@ 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 + // 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 -> 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.fieldFocused(focusToken, keepFocusOnSubmit) + } is FocusInteraction.Unfocus -> { focusStack.remove(interaction.focus) if (focusStack.isEmpty()) { @@ -114,12 +131,18 @@ object OutlinedTextInputRenderer { // caret lands, then flush any deferred text change. selectionReporter.flush(value) dispatcher.onBlur(value.text) + 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 @@ -192,6 +215,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 = fieldTextColor, fontFamily = customFontFamily, lineHeight = lineHeight), colors = OutlinedTextFieldDefaults.colors( 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/TextInputShared.kt b/resources/android/TextInputShared.kt index 26f6bb7..29d986f 100644 --- a/resources/android/TextInputShared.kt +++ b/resources/android/TextInputShared.kt @@ -59,6 +59,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, @@ -145,6 +146,7 @@ internal fun parseTextInputProps(node: NativeUINode): TextInputProps { keyboard = resolveKeyboardType(p.getString("keyboard")), capitalization = resolveCapitalization(p.getString("autocapitalize"), p.getBool("secure"), 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"), 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/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) { 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/NativeUITextInputCore.swift b/resources/ios/NativeUITextInputCore.swift index bedcece..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, @@ -286,6 +290,17 @@ 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). + 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 @@ -332,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 ────────────────────────────────────────────────── 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 })