fix(ios): SwiftUI Button synthesized tap on iOS 18+

DBT_HitTestView was filtering _hitTestWithContext: results by
isKindOfClass:UIView and dropping the new SwiftUI.UIKitGestureContainer
(a UIResponder, not UIView). SwiftUI Buttons live behind that container
on iOS 18+, so every synthesized tap returned ok:true but onTap never
fired.

Mirror KIF PR #1323: return id, pass the responder through to
UITouch.setView: directly (the setter accepts non-UIView responders).

Verified: real iPhone 17 Pro Max, iOS 26.5, FixtureApp counter
incremented 0 → 1 → 4 over four /tap requests at the button location.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-05-20 07:01:21 -07:00
parent 945600428e
commit cf65bb055a
@@ -188,7 +188,13 @@ static IOHIDEventRef DBT_IOHIDEventWithTouch(UITouch *touch) {
#pragma mark - SwiftUI-aware hit test (iOS 18+)
static UIView *DBT_HitTestView(UIWindow *window, CGPoint point) {
// Returns `id` because iOS 18's _hitTestWithContext: can return either a UIView
// OR a SwiftUI.UIKitGestureContainer (a plain UIResponder, NOT a UIView).
// The latter is the case for SwiftUI Buttons. KIF's observation: the returned
// responder is still compatible with UITouch.setView: even when it isn't a
// UIView — so we pass it through as-is. Filtering by isKindOfClass:UIView
// here would drop every SwiftUI Button tap silently. Mirrors KIF PR #1323.
static id DBT_HitTestView(UIWindow *window, CGPoint point) {
UIView *fallback = [window hitTest:point withEvent:nil];
if (@available(iOS 18.0, *)) {
@@ -206,8 +212,8 @@ static UIView *DBT_HitTestView(UIWindow *window, CGPoint point) {
found = [current _hitTestWithContext:ctx];
current = current.superview;
}
if (found && [found isKindOfClass:[UIView class]]) {
return (UIView *)found;
if (found) {
return found;
}
}
}
@@ -222,7 +228,7 @@ static UIView *DBT_HitTestView(UIWindow *window, CGPoint point) {
+ (BOOL)sendTapAtPoint:(CGPoint)point inWindow:(UIWindow *)window {
if (!window) return NO;
UIView *hit = DBT_HitTestView(window, point);
id hit = DBT_HitTestView(window, point);
if (!hit) return NO;
// Build a single synthetic UITouch via private setters. Order matters —
@@ -231,17 +237,17 @@ static UIView *DBT_HitTestView(UIWindow *window, CGPoint point) {
[touch setWindow:window];
[touch setTapCount:1];
[touch _setLocationInWindow:point resetPrevious:YES];
[touch setView:hit];
// setView: typed UIView * but accepts SwiftUI.UIKitGestureContainer
// (UIResponder) too — that's how SwiftUI Buttons get routed on iOS 18+.
[touch setView:(UIView *)hit];
[touch setPhase:UITouchPhaseBegan];
if ([touch respondsToSelector:@selector(_setIsFirstTouchForView:)]) {
[touch _setIsFirstTouchForView:YES];
}
[touch setTimestamp:[[NSProcessInfo processInfo] systemUptime]];
// KIF sets the gestureView too — required for SwiftUI Button gesture
// recognition. Without this the gesture system sees the touch as
// unattached and drops it.
if ([touch respondsToSelector:@selector(setGestureView:)]) {
[touch setGestureView:hit];
if ([touch respondsToSelector:@selector(setGestureView:)] &&
[hit isKindOfClass:[UIView class]]) {
[touch setGestureView:(UIView *)hit];
}
// Attach a real IOHIDEvent (required iOS 9+).