Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
],
"require": {
"php": "^8.3",
"nativephp/mobile": "^4.5"
"nativephp/mobile": "^4.6"
},
"autoload": {
"psr-4": {
Expand Down
6 changes: 5 additions & 1 deletion resources/android/AccordionRenderer.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
28 changes: 28 additions & 0 deletions resources/android/BareTextInputRenderer.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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()
}
})
)
}
Expand Down
5 changes: 5 additions & 0 deletions resources/android/ButtonGroupRenderer.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand Down
11 changes: 10 additions & 1 deletion resources/android/ButtonRenderer.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions resources/android/CheckboxRenderer.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand Down
4 changes: 4 additions & 0 deletions resources/android/ChipRenderer.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions resources/android/DatePickerRenderer.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Expand Down
31 changes: 30 additions & 1 deletion resources/android/FilledTextInputRenderer.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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<FocusInteraction.Focus>()
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
Expand Down Expand Up @@ -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(
Expand Down
4 changes: 4 additions & 0 deletions resources/android/IconRenderer.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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 {
Expand Down
13 changes: 12 additions & 1 deletion resources/android/ListItemRenderer.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -81,24 +82,29 @@ 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)
}
}
)
}
} else if (pressCbId != 0) {
modifier.clickable(enabled = !disabled, role = Role.Button) {
KeyboardFocusPolicy.dismissForInteractiveTap()
NativeUIBridge.sendPressEvent(pressCbId, node.id)
}
} else {
Expand Down Expand Up @@ -307,6 +313,7 @@ object ListItemRenderer {
Checkbox(
checked = checked,
onCheckedChange = { newValue ->
KeyboardFocusPolicy.dismissForInteractiveTap()
checked = newValue
if (onChangeCb != 0) {
NativeUIBridge.sendCheckboxChangeEvent(onChangeCb, nodeId, newValue)
Expand All @@ -320,6 +327,7 @@ object ListItemRenderer {
RadioButton(
selected = selected,
onClick = {
KeyboardFocusPolicy.dismissForInteractiveTap()
selected = !selected
if (onChangeCb != 0) {
NativeUIBridge.sendCheckboxChangeEvent(onChangeCb, nodeId, selected)
Expand Down Expand Up @@ -418,6 +426,7 @@ object ListItemRenderer {
Checkbox(
checked = checked,
onCheckedChange = { newValue ->
KeyboardFocusPolicy.dismissForInteractiveTap()
checked = newValue
if (onChangeCb != 0) {
NativeUIBridge.sendCheckboxChangeEvent(onChangeCb, nodeId, newValue)
Expand All @@ -431,6 +440,7 @@ object ListItemRenderer {
Switch(
checked = checked,
onCheckedChange = { newValue ->
KeyboardFocusPolicy.dismissForInteractiveTap()
checked = newValue
if (onChangeCb != 0) {
NativeUIBridge.sendToggleChangeEvent(onChangeCb, nodeId, newValue)
Expand Down Expand Up @@ -470,6 +480,7 @@ object ListItemRenderer {
IconButton(
onClick = {
if (onPressCb != 0) {
KeyboardFocusPolicy.dismissForInteractiveTap()
NativeUIBridge.sendPressEvent(onPressCb, nodeId)
}
},
Expand Down
Loading
Loading