Skip to content

Stop the keyboard-dismiss taps cancelling UIKit controls - #545

Merged
simonhamp merged 1 commit into
mainfrom
fix/uikit-control-taps
Oct 22, 2026
Merged

simonhamp merged 1 commit into
mainfrom
fix/uikit-control-taps

Conversation

@simonhamp

@simonhamp simonhamp commented Oct 16, 2026 •

Copy link
Copy Markdown
Member

UIKit-backed controls inside an EDGE screen never get their taps on iOS. They draw, but a tap does nothing. SwiftUI's own SignInWithAppleButton is one, which is how I found it (NativePHP/mobile-ui#133). A plugin renderer that wraps a UIControl hits the same thing.

The cause is dismissesKeyboardOnTap(). It used a SwiftUI TapGesture and a simultaneous SpatialTapGesture, and when either recognises a tap, UIKit cancels the touch in the UIKit view underneath. I checked each one on its own and both do it.

Both are now UIKit tap recognizers with cancelsTouchesInView = false and no touch delays:

  • The plain-area tap is a UIGestureRecognizerRepresentable attached with .gesture. It still loses to a child's own tap, so it only fires for taps nothing else claimed, and it still always dismisses.
  • The every-tap one is a recognizer on the window, like EscapeHatchGesture. I tried it as a second representable that recognises simultaneously first, but that stopped text fields taking focus. Since a window recognizer sees everything, it only acts when the tapped view is inside the view controller of a screen that uses dismissesKeyboardOnTap(). Taps on the tab bar, a navigation bar or a sheet are left alone, as before.

Checked on an iOS 26.2 simulator

Same results before and after, except the Apple button:

  • A checkbox tap drops a normal field's keyboard and keeps a keep-focus-on-submit field's.
  • A tap on empty space drops the keyboard, even for a keep-focus-on-submit field.
  • Tapping the focused field again keeps it focused, and tapping another field moves focus.
  • Buttons fire, and lists and scroll views still scroll.
  • SignInWithAppleButton now starts its request. Before, nothing happened.

To watch for: the comment in this file warns that a recognizer spanning the tab bar could upset iOS 26's Tab(role: .search) capsule. This one never cancels, delays or blocks other recognizers and ignores taps outside screen content, but I haven't tried an app with a search tab. Worth a tap there before merging.

Related

#524 fixes the same kind of problem for press-* feedback: a SwiftUI gesture taking touches it shouldn't, replaced by a UIGestureRecognizerRepresentable that doesn't cancel them. The two PRs change different files and don't conflict. One difference worth knowing: #524's recognizer recognises alongside everything and is attached with .gesture. I tried the same for the every-tap half here, on the whole screen, and it stopped text fields taking focus, so that half lives on the window instead.

🤖 Generated with Claude Code

dismissesKeyboardOnTap() used a SwiftUI TapGesture and a simultaneous
SpatialTapGesture. When either recognised a tap, UIKit cancelled the
touch in any UIKit view underneath, so UIKit-backed controls never got
their touch-up. SwiftUI's own SignInWithAppleButton is one: it draws
fine inside an EDGE screen but a tap does nothing. Removing either
gesture alone wasn't enough; each one cancels the touch.

Both are now UIKit tap recognizers that never cancel or delay touches.
The plain-area one is a UIGestureRecognizerRepresentable attached with
.gesture, so it still loses to a child's own tap. The every-tap one is
a recognizer on the window, like EscapeHatchGesture, because a SwiftUI
gesture that recognises alongside its children stopped text fields
taking focus. It only acts on taps inside a screen that uses
dismissesKeyboardOnTap(), so the tab bar, navigation bars and sheets
are still left alone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
simonhamp added a commit to NativePHP/mobile-ui that referenced this pull request Oct 16, 2026
The element drew UIKit's ASAuthorizationAppleIDButton and only fired
@press, because SwiftUI's button always runs the Apple ID request
itself. Now it is SwiftUI's button, and the element runs the request.

New props feed the request: scopes (email, full-name), nonce (raw; the
button sends Apple its SHA-256 hex) and state. The answer goes to PHP
as a native event, Native\Mobile\UI\Events\SignInWithAppleCompleted or
SignInWithAppleFailed, which components listen for with #[On]. @press
now fires as the request starts.

SwiftUI's button is UIKit underneath, so inside an EDGE screen it needs
NativePHP/mobile-air#545, which stops core's keyboard-dismiss taps
cancelling UIKit controls. Without it a tap does nothing.

The corner radius is now a clip, so it can round the corners more than
the system does but can't square them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@simonhamp
simonhamp marked this pull request as ready for review October 22, 2026 19:06
@simonhamp
simonhamp merged commit 956eaea into main Oct 22, 2026
5 checks passed
@simonhamp
simonhamp deleted the fix/uikit-control-taps branch October 22, 2026 19:07
simonhamp added a commit that referenced this pull request Oct 23, 2026
It came in with #545 alongside an unrelated Swift fix. Nothing in the package reads it. The desktop v2 service provider creates this file in debug mode when it boots against a project, which is likely how it ended up in a checkout and got swept into a commit.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
gwleuverink added a commit that referenced this pull request Oct 25, 2026
Covers mobile PRs #241, #305, #344, #348, #370, #372, #401, #436, #439, #441, #447, #451, #461, #499, #500, #501, #502, #509, #511, #517, #522, #524, #529, #530, #537, #539, #541, #543, #545, #549, #550, #553, #557, #558, #561, #564, #565, #566, #571, #572, #575, #576, #577, #578, #579, #580, #581, #582, #583, #584, #585, #586, #587, #589, #591, #592, #593, #594, #595, #596, #597, #598, #599, #600, #601, #602, #603 and #604 (main d5dd874..b107ed9). #539 was already on this branch, merged as if accepted on 10 Oct. Its later pushes only brought main in and fixed OnWhenTest, and both were already here.

Ported to core in NativePHP/core#17 (branch sync/mobile-main-2026-10-25), which this merge needs: #572 and #589 (the ImageFit enum, which moves to core), #578 (route() and external URLs in navigate() and replace(), with an openedExternally() seam), #502 (unknown element tags; UnknownElementException moves to core with a hint seam), #529, #499, #372, #586, #241, #549, #601, #603, #344 and #593, the core halves of #511, #370, #348 and #401, and --json on core's native:run for #305. --fresh on core's native:run too, for #513, which came before the last sync and was missed then.

Kept on this branch: #578's exitToWeb() and the openedExternally() that opens the browser, #558's browser handoff, #370's system back and #596's root back, all in InteractsWithAppShell. #439's stack depth in WrapsScreensInNativeChrome, read through router(). #511's PERF gating in runLoop(). ScreenGuard (#348, #595) and UnknownElementHint (#502) stay in mobile, and the provider hands core the hints at boot. Platform reads go through System::platform(), as #596 has it.

Removed again in the merge: the moved files main changed, ImageFit and UnknownElementException (now core's), and src/Events/GlobalEventDispatcher.php, which main added back (core has it, and the old name is an alias). Main's import of the old dispatcher name came out of DispatchEventFromAppController and NativeServiceProvider, where it clashed with core's.

Adapted for the extraction: RunloopIdleTest reflects on core's NativeComponent, where the runloop state it reads is private. NativeComponentAnalyzer counts a render() declared on core's class as the default one, which #501's new test caught.

Needs NativePHP/core#17.

Tests: mobile on this merge against the core sync branch (52db12f), 2,343 passed, 4 risky, 5 skipped (Devkeepr run 5371). PHPStan clean (run 5372). Mobile main (b107ed9), 2,288 passed, 4 risky, 5 skipped (run 5359). Desktop main (24f3003) against the core sync branch, test:php 1,883 passed (run 5373), as before.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant