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
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package com.nativephp.mobile.ui.nativerender

/**
* Focus policy shared between the text-input renderers, which know
* whether the focused field opted into `keep-focus-on-submit`, and
* the gesture layer, which decides whether a tap on an interactive
* element should also dismiss the keyboard (mobile-air #335).
*/
object KeyboardFocusPolicy {
/** Set by the input renderers on every focus change, cleared on blur. */
@Volatile
var focusedFieldKeepsFocus = false

/**
* Registered by the root renderer from composition, since gesture
* modifiers run outside composable scope and cannot reach the
* FocusManager themselves. Cleared when the root leaves.
*/
@Volatile
var clearFocus: (() -> Unit)? = null

/**
* Clear focus for a tap on an interactive element, unless the focused
* field asked to keep focus through sends. Plain-area taps skip
* this and always clear, so tap-away keeps working.
*/
fun dismissForInteractiveTap() {
if (!focusedFieldKeepsFocus) {
clearFocus?.invoke()
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,16 @@ fun NativeUIContent() {

val focusManager = LocalFocusManager.current

// Gesture modifiers dispatch @press outside composable scope, so the
// policy carries a clear-focus hook for them; interactive taps route
// through it and honor `keep-focus-on-submit` (mobile-air #335).
DisposableEffect(focusManager) {
KeyboardFocusPolicy.clearFocus = { focusManager.clearFocus() }
onDispose {
KeyboardFocusPolicy.clearFocus = null
}
}

BoxWithConstraints(
modifier = Modifier
.fillMaxSize()
Expand All @@ -62,7 +72,9 @@ fun NativeUIContent() {
indication = null,
interactionSource = remember { MutableInteractionSource() }
) {
// Tap outside any input dismisses keyboard
// Tap on a plain area dismisses the keyboard. Fires only for
// taps no child consumed; interactive elements dismiss via
// KeyboardFocusPolicy in their own handlers instead.
focusManager.clearFocus()
}
) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -328,19 +328,26 @@ fun Modifier.nodeGestures(
if (callbackId == 0 && longPressId == 0 && doubleTapId == 0) return mod

val onClickAction: () -> Unit = {
KeyboardFocusPolicy.dismissForInteractiveTap()
if (callbackId != 0) {
NativeElementBridge.sendPressEvent(callbackId, nodeId)
}
}
val onLongClickAction: (() -> Unit)? = if (longPressId != 0) {
{ NativeElementBridge.sendLongPressEvent(longPressId, nodeId) }
{
KeyboardFocusPolicy.dismissForInteractiveTap()
NativeElementBridge.sendLongPressEvent(longPressId, nodeId)
}
} else {
null
}
// Double-tap reuses the press event type — the callback id alone routes
// to the @doubleTap handler on the PHP side.
val onDoubleClickAction: (() -> Unit)? = if (doubleTapId != 0) {
{ NativeElementBridge.sendPressEvent(doubleTapId, nodeId) }
{
KeyboardFocusPolicy.dismissForInteractiveTap()
NativeElementBridge.sendPressEvent(doubleTapId, nodeId)
}
} else {
null
}
Expand Down
83 changes: 67 additions & 16 deletions resources/xcode/NativePHP/NativeRender/SwiftUINodeRenderer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,51 @@ struct NativeTreeRenderer: View {

// MARK: - Tap-to-dismiss Keyboard

/// Focus policy shared between the text-input renderers, which know
/// whether the focused field opted into `keep-focus-on-submit`, and
/// the gesture layer, which decides whether a tap on an interactive
/// element should also dismiss the keyboard (mobile-air #335).
enum KeyboardFocusPolicy {
/// Set by the input renderers on every focus change, cleared on blur.
static var focusedFieldKeepsFocus = false

/// True while any text field holds focus, so press dispatch knows
/// a pending autocorrection or debounced change might be in play.
static var focusedFieldActive = false

/// Registered by the focused input; flushes its undispatched text
/// change so PHP sees the field's latest value before a press.
static var flushFocusedField: (() -> Void)?

/// Dispatch a press event with focus handling around it: flush the
/// focused field's pending change, resign unless the field keeps
/// focus, and defer the press one runloop turn while focused so
/// a tap-committed autocorrection's change event lands in PHP
/// before the press does (mobile-air #335).
static func dispatchPress(_ send: @escaping () -> Void) {
flushFocusedField?()

if !focusedFieldKeepsFocus {
resignKeyboard()
}

if focusedFieldActive {
DispatchQueue.main.async(execute: send)
} else {
send()
}
}

static func resignKeyboard() {
UIApplication.shared.sendAction(
#selector(UIResponder.resignFirstResponder),
to: nil,
from: nil,
for: nil
)
}
}

extension View {
/// Dismiss the keyboard when the user taps anywhere in this subtree.
///
Expand All @@ -120,20 +165,20 @@ extension View {
/// also the correct scope — a tap on the tab bar or a toolbar button is
/// that control's business, not a dismiss.
///
/// `simultaneousGesture` rather than `onTapGesture` so it runs ALONGSIDE
/// whatever it lands on: buttons, pressables and list rows underneath keep
/// receiving their own taps instead of having them swallowed.
/// A regular gesture rather than `simultaneousGesture`, so a child's own
/// tap wins and this only fires for taps nothing else claimed: the
/// plain-area tap-away path. Interactive elements dismiss through
/// `KeyboardFocusPolicy.dismissForInteractiveTap()` in their own
/// handlers, which is what lets `keep-focus-on-submit` exempt
/// them (mobile-air #335). The `contentShape` keeps empty
/// regions of the screen hit-testable for the gesture.
func dismissesKeyboardOnTap() -> some View {
simultaneousGesture(
TapGesture().onEnded {
UIApplication.shared.sendAction(
#selector(UIResponder.resignFirstResponder),
to: nil,
from: nil,
for: nil
)
}
)
contentShape(Rectangle())
.gesture(
TapGesture().onEnded {
KeyboardFocusPolicy.resignKeyboard()
}
)
}
}

Expand Down Expand Up @@ -325,7 +370,9 @@ private struct DoubleTapModifier: ViewModifier {
content.onTapGesture(count: 2) {
// Reuses the Press event type — the callback id alone routes
// to the @doubleTap handler, and Press dispatch passes no args.
NativeElementBridge.sendPressEvent(callbackId, nodeId: nodeId)
KeyboardFocusPolicy.dispatchPress {
NativeElementBridge.sendPressEvent(callbackId, nodeId: nodeId)
}
}
} else {
content
Expand All @@ -340,7 +387,9 @@ private struct TapModifier: ViewModifier {
func body(content: Content) -> some View {
if callbackId != 0 {
content.onTapGesture {
NativeElementBridge.sendPressEvent(callbackId, nodeId: nodeId)
KeyboardFocusPolicy.dispatchPress {
NativeElementBridge.sendPressEvent(callbackId, nodeId: nodeId)
}
}
} else {
content
Expand All @@ -355,7 +404,9 @@ private struct LongPressModifier: ViewModifier {
func body(content: Content) -> some View {
if callbackId != 0 {
content.onLongPressGesture(minimumDuration: 0.5) {
NativeElementBridge.sendLongPressEvent(callbackId, nodeId: nodeId)
KeyboardFocusPolicy.dispatchPress {
NativeElementBridge.sendLongPressEvent(callbackId, nodeId: nodeId)
}
}
} else {
content
Expand Down
12 changes: 9 additions & 3 deletions resources/xcode/NativePHP/NativeRender/ViewClickHandlers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,9 @@ private struct ClickHandlerModifier: ViewModifier {
let nodeId = node.id
view = AnyView(
view.onTapGesture(count: 2) {
NativeUIBridge.sendPressEvent(doubleTapId, nodeId: nodeId)
KeyboardFocusPolicy.dispatchPress {
NativeUIBridge.sendPressEvent(doubleTapId, nodeId: nodeId)
}
}
)
}
Expand All @@ -94,7 +96,9 @@ private struct ClickHandlerModifier: ViewModifier {
let nodeId = node.id
view = AnyView(
view.onTapGesture {
NativeUIBridge.sendPressEvent(cbId, nodeId: nodeId)
KeyboardFocusPolicy.dispatchPress {
NativeUIBridge.sendPressEvent(cbId, nodeId: nodeId)
}
}
)
}
Expand All @@ -104,7 +108,9 @@ private struct ClickHandlerModifier: ViewModifier {
let nodeId = node.id
view = AnyView(
view.onLongPressGesture(minimumDuration: 0.5) {
NativeUIBridge.sendLongPressEvent(cbId, nodeId: nodeId)
KeyboardFocusPolicy.dispatchPress {
NativeUIBridge.sendLongPressEvent(cbId, nodeId: nodeId)
}
}
)
}
Expand Down
Loading