Skip to content
Open
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
17 changes: 17 additions & 0 deletions resources/android/BareTextInputRenderer.kt
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,13 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.focusRequester
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.text.TextRange
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.sp
import com.nativephp.mobile.ui.nativerender.NativeUINode
Expand Down Expand Up @@ -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 focusRequester = rememberRegisteredFocusRequester(props.focusRef, props.autofocus)

// Caret / selection reporter — independent of the direct change/submit
// dispatchers; no-op unless `on_selection_change` is wired and the
Expand Down Expand Up @@ -125,6 +128,7 @@ object BareTextInputRenderer {
// flush the pending selection on the focused → unfocused edge.
modifier = Modifier
.fillMaxWidth()
.focusRequester(focusRequester)
.onFocusChanged { state ->
if (wasFocused && !state.isFocused) selectionReporter.flush(value)
wasFocused = state.isFocused
Expand Down Expand Up @@ -160,6 +164,19 @@ object BareTextInputRenderer {
// Flush the settled caret before the submit event fires.
selectionReporter.flush(value)
props.dispatchSubmit?.invoke(value.text)
// Chained focus (`next-focus`): move the keyboard to the
// target field. A missing target is a no-op.
if (props.nextFocus.isNotEmpty()) {
NativeUIFocusRegistry.request(props.nextFocus)
} else {
// Consuming the IME action suppresses its platform
// default, so Done/Go/Send/Search left the keyboard up.
// Restore it — close the keyboard like the platform (and
// iOS's return key) does. Next keeps the keyboard: the
// chain moved it, or the target is gone and there is
// nothing sensible to do.
defaultKeyboardAction(ImeAction.Done)
}
})
)
}
Expand Down
23 changes: 21 additions & 2 deletions resources/android/FilledTextInputRenderer.kt
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,12 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.platform.LocalContext
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.ImeAction
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.dp
import com.nativephp.mobile.ui.nativerender.NativeUINode
Expand Down Expand Up @@ -79,6 +81,7 @@ object FilledTextInputRenderer {
}

val interactionSource = remember { MutableInteractionSource() }
val focusRequester = rememberRegisteredFocusRequester(props.focusRef, props.autofocus)
LaunchedEffect(interactionSource) {
val focusStack = mutableListOf<FocusInteraction.Focus>()
interactionSource.interactions.collect { interaction: Interaction ->
Expand Down Expand Up @@ -124,7 +127,7 @@ object FilledTextInputRenderer {
// Full width by default (parity with the iOS renderer's
// maxWidth: .infinity); an explicit width in `modifier` (FIXED
// layout mode) still wins since it comes later in the chain.
modifier = Modifier.fillMaxWidth().then(modifier).nuiA11y(props.a11yLabel, props.a11yHint),
modifier = Modifier.fillMaxWidth().focusRequester(focusRequester).then(modifier).nuiA11y(props.a11yLabel, props.a11yHint),
enabled = props.enabled,
readOnly = props.readOnly,
interactionSource = interactionSource,
Expand All @@ -143,10 +146,26 @@ object FilledTextInputRenderer {
minLines = props.minLines,
visualTransformation = props.visualTransformation,
keyboardOptions = keyboardOptionsFor(props),
keyboardActions = KeyboardActions(onDone = {
// onAny, not onDone: `submit-label` can make the IME action Next /
// Go / Search / Send, and an onDone-only handler would silently
// drop the submit for those. Matches the bare renderer.
keyboardActions = KeyboardActions(onAny = {
// Flush the settled caret before the submit event fires.
selectionReporter.flush(value)
dispatcher.onSubmit(value.text)
// Chained focus (`next-focus`): move the keyboard to the
// target field. A missing target is a no-op.
if (props.nextFocus.isNotEmpty()) {
NativeUIFocusRegistry.request(props.nextFocus)
} else {
// Consuming the IME action suppresses its platform
// default, so Done/Go/Send/Search left the keyboard up.
// Restore it — close the keyboard like the platform (and
// iOS's return key) does. Next keeps the keyboard: the
// chain moved it, or the target is gone and there is
// nothing sensible to do.
defaultKeyboardAction(ImeAction.Done)
}
}),
textStyle = TextStyle(fontSize = textSize, color = theme.onSurface, fontFamily = customFontFamily, lineHeight = lineHeight),
colors = TextFieldDefaults.colors(
Expand Down
23 changes: 21 additions & 2 deletions resources/android/OutlinedTextInputRenderer.kt
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,12 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.platform.LocalContext
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.ImeAction
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.dp
import com.nativephp.mobile.ui.nativerender.NativeUINode
Expand Down Expand Up @@ -91,6 +93,7 @@ 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 focusRequester = rememberRegisteredFocusRequester(props.focusRef, props.autofocus)
LaunchedEffect(interactionSource) {
val focusStack = mutableListOf<FocusInteraction.Focus>()
interactionSource.interactions.collect { interaction: Interaction ->
Expand Down Expand Up @@ -137,7 +140,7 @@ object OutlinedTextInputRenderer {
// Full width by default (parity with the iOS renderer's
// maxWidth: .infinity); an explicit width in `modifier` (FIXED
// layout mode) still wins since it comes later in the chain.
modifier = Modifier.fillMaxWidth().then(modifier).nuiA11y(props.a11yLabel, props.a11yHint),
modifier = Modifier.fillMaxWidth().focusRequester(focusRequester).then(modifier).nuiA11y(props.a11yLabel, props.a11yHint),
enabled = props.enabled,
readOnly = props.readOnly,
interactionSource = interactionSource,
Expand All @@ -156,10 +159,26 @@ object OutlinedTextInputRenderer {
minLines = props.minLines,
visualTransformation = props.visualTransformation,
keyboardOptions = keyboardOptionsFor(props),
keyboardActions = KeyboardActions(onDone = {
// onAny, not onDone: `submit-label` can make the IME action Next /
// Go / Search / Send, and an onDone-only handler would silently
// drop the submit for those. Matches the bare renderer.
keyboardActions = KeyboardActions(onAny = {
// Flush the settled caret before the submit event fires.
selectionReporter.flush(value)
dispatcher.onSubmit(value.text)
// Chained focus (`next-focus`): move the keyboard to the
// target field. A missing target is a no-op.
if (props.nextFocus.isNotEmpty()) {
NativeUIFocusRegistry.request(props.nextFocus)
} else {
// Consuming the IME action suppresses its platform
// default, so Done/Go/Send/Search left the keyboard up.
// Restore it — close the keyboard like the platform (and
// iOS's return key) does. Next keeps the keyboard: the
// chain moved it, or the target is gone and there is
// nothing sensible to do.
defaultKeyboardAction(ImeAction.Done)
}
}),
textStyle = TextStyle(fontSize = textSize, color = theme.onSurface, fontFamily = customFontFamily, lineHeight = lineHeight),
colors = OutlinedTextFieldDefaults.colors(
Expand Down
136 changes: 132 additions & 4 deletions resources/android/TextInputShared.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,15 @@ package com.nativephp.plugins.native_ui.ui
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
Expand Down Expand Up @@ -52,6 +57,10 @@ internal data class TextInputProps(
val maxLength: Int,
val keyboard: KeyboardType,
val capitalization: KeyboardCapitalization?,
val submitLabel: String,
val focusRef: String,
val nextFocus: String,
val autofocus: Boolean,
val disabled: Boolean,
val readOnly: Boolean,
val isError: Boolean,
Expand Down Expand Up @@ -116,6 +125,10 @@ internal fun parseTextInputProps(node: NativeUINode): TextInputProps {
maxLength = p.getInt("max_length"),
keyboard = resolveKeyboardType(p.getString("keyboard")),
capitalization = resolveCapitalization(p.getString("autocapitalize"), p.getBool("secure"), p.getString("keyboard")),
submitLabel = p.getString("submit_label"),
focusRef = p.getString("focus_ref"),
nextFocus = p.getString("next_focus"),
autofocus = p.getBool("autofocus"),
disabled = p.getBool("disabled"),
readOnly = p.getBool("read_only"),
isError = p.getBool("is_error"),
Expand Down Expand Up @@ -212,10 +225,125 @@ internal fun resolveCapitalization(explicit: String, secure: Boolean, keyboard:
}
}

internal fun keyboardOptionsFor(props: TextInputProps): KeyboardOptions =
props.capitalization
?.let { KeyboardOptions(keyboardType = props.keyboard, capitalization = it) }
?: KeyboardOptions(keyboardType = props.keyboard)
/**
* IME action for the submit key — the `submit_label` prop. The explicit
* value wins; unset — or unknown, same policy as [resolveKeyboardType] —
* derives [ImeAction.Next] when a `next_focus` chain is set (mirroring how
* capitalization derives from the keyboard type), else keeps
* [ImeAction.Default], i.e. exactly the pre-prop behaviour.
*
* "return" is iOS vocabulary (a plain Return key); Android has no exact
* equivalent, so it resolves to the IME default — listed explicitly so an
* author's `return` beats the `next_focus` derivation.
*
* A multiline field ignores both props entirely: a non-default IME action
* replaces the return key, and multiline's return key must keep inserting
* newlines. iOS ignores them for multiline the same way
* (`resolveSubmitLabel` in `NativeUITextInputCore.swift`) — keep the two
* in sync.
*/
internal fun resolveImeAction(explicit: String, multiline: Boolean, nextFocus: String): ImeAction {
if (multiline) return ImeAction.Default

return when (explicit.lowercase()) {
"next" -> ImeAction.Next
"done" -> ImeAction.Done
"go" -> ImeAction.Go
"search" -> ImeAction.Search
"send" -> ImeAction.Send
"return" -> ImeAction.Default
else -> if (nextFocus.isNotEmpty()) ImeAction.Next else ImeAction.Default
}
}

internal fun keyboardOptionsFor(props: TextInputProps): KeyboardOptions {
val imeAction = resolveImeAction(props.submitLabel, props.multiline, props.nextFocus)

return props.capitalization
?.let { KeyboardOptions(keyboardType = props.keyboard, capitalization = it, imeAction = imeAction) }
?: KeyboardOptions(keyboardType = props.keyboard, imeAction = imeAction)
}

/**
* Screen-wide focus routing for text inputs — the `next-focus` prop.
*
* Each renderer whose element carries a `ref` registers its
* [FocusRequester] under that name (a `DisposableEffect` keyed on the
* ref), and the submit handler of a field with `next_focus` set asks the
* registry to move the keyboard there. Focus is only ever requested from
* a user-initiated submit on another field — never from server pushes —
* so the registry cannot steal focus spontaneously.
*
* Main-thread only: registration happens in composition effects and
* requests in IME action handlers. Last registration wins (refs are
* unique per screen by convention — they are the same refs
* `Native::test()` targets); unregistration is identity-guarded so a
* disposed screen can't tear down the ref it was shadowing. iOS keeps the
* same contract (`NativeUIFocusRegistry.swift`) — keep the two in sync.
*/
internal object NativeUIFocusRegistry {
private val entries = mutableMapOf<String, FocusRequester>()

fun register(ref: String, requester: FocusRequester) {
entries[ref] = requester
}

fun unregister(ref: String, requester: FocusRequester) {
if (entries[ref] === requester) {
entries.remove(ref)
}
}

/**
* Focus the field registered under [ref]. Returns whether a live
* target existed — a missing or detached target (off-screen, recycled
* row, typo'd ref) is a no-op, never a crash.
*/
fun request(ref: String): Boolean {
val requester = entries[ref] ?: return false
return try {
requester.requestFocus()
true
} catch (_: IllegalStateException) {
// Registered but no longer attached to a composed node (the
// row was recycled between registration and this submit).
false
}
}
}

/**
* Remember a [FocusRequester] kept registered in [NativeUIFocusRegistry]
* under the field's `focus_ref` while it is in composition. Hang the
* result on the field's `Modifier.focusRequester`; an empty ref returns a
* plain, unregistered requester.
*/
@Composable
internal fun rememberRegisteredFocusRequester(focusRef: String, autofocus: Boolean = false): FocusRequester {
val requester = remember { FocusRequester() }
DisposableEffect(focusRef) {
if (focusRef.isNotEmpty()) {
NativeUIFocusRegistry.register(focusRef, requester)
}
onDispose {
if (focusRef.isNotEmpty()) {
NativeUIFocusRegistry.unregister(focusRef, requester)
}
}
}
// `autofocus`: focus (and raise the IME on) the field the user came to
// fill, once per composition — a re-render that moves the attribute to
// an already-composed field never steals focus mid-edit. The short
// delay lets the host (screen push, bottom sheet) finish laying out;
// requesting focus on an unplaced node is silently dropped.
if (autofocus) {
LaunchedEffect(Unit) {
delay(200)
runCatching { requester.requestFocus() }
}
}
return requester
}

/**
* Outbound dispatch state machine. Call [onTextChanged] whenever local text
Expand Down
18 changes: 13 additions & 5 deletions resources/ios/NativeUIBottomSheetRenderer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -30,13 +30,21 @@ struct NativeUIBottomSheetRenderer: View {
NativeUIBridge.sendSheetDismissEvent(onDismissCb, nodeId: node.id)
}
}) {
VStack(spacing: 0) {
ForEach(node.children) { child in
NodeView(node: child).equatable()
// The sheet hosts its own keyboard accessory bar — the
// screen-root host's bar sits BEHIND a presented sheet, so
// fields inside the sheet publish to this copy instead
// (`sheetDepth` makes the root host yield while we're up).
NativeUIKeyboardAccessoryHost {
VStack(spacing: 0) {
ForEach(node.children) { child in
NodeView(node: child).equatable()
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.background(theme.surface)
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.background(theme.surface)
.onAppear { NativeUIKeyboardAccessoryState.shared.sheetDepth += 1 }
.onDisappear { NativeUIKeyboardAccessoryState.shared.sheetDepth -= 1 }
.presentationDetents(resolveDetents(detentsStr))
.presentationDragIndicator(.visible)
.interactiveDismissDisabled(permanent)
Expand Down
8 changes: 8 additions & 0 deletions resources/ios/NativeUIDrawerHost.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,14 @@ func registerNativeUIChrome() {
return AnyView(NativeUIBackgroundLayerHost(layerNode: layerNode) { content })
}

// Keyboard accessory bar for pad-keyboard text inputs on plain screens
// (fields inside a bottom sheet use the sheet renderer's own host — a
// root-level bar would sit behind the sheet). No sentinel element:
// the host is driven by the shared focus state, not the tree.
NativeRootHostRegistry.shared.register("native-ui.keyboard-accessory") { _, content in
AnyView(NativeUIKeyboardAccessoryHost(hidesUnderSheets: true) { content })
}

// Resolve chrome font tokens (per-layout / per-bar `font_name` props on
// the root sentinels) for core's chrome renderers — bundle lookup +
// CoreText registration + PostScript naming is this plugin's knowledge.
Expand Down
Loading
Loading