From cf65bb055a21e4512d182ec47ca043128c6b56ca Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Wed, 20 May 2026 07:01:21 -0700 Subject: [PATCH] fix(ios): SwiftUI Button synthesized tap on iOS 18+ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../DebugBridgeTouch/DebugBridgeTouch.m | 26 ++++++++++++------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/test/fixtures/ios-qa/FixtureApp/Sources/DebugBridgeTouch/DebugBridgeTouch.m b/test/fixtures/ios-qa/FixtureApp/Sources/DebugBridgeTouch/DebugBridgeTouch.m index d2bd8632e..b3f9c978c 100644 --- a/test/fixtures/ios-qa/FixtureApp/Sources/DebugBridgeTouch/DebugBridgeTouch.m +++ b/test/fixtures/ios-qa/FixtureApp/Sources/DebugBridgeTouch/DebugBridgeTouch.m @@ -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+).