diff --git a/apps/mobile/modules/t3-native-controls/expo-module.config.json b/apps/mobile/modules/t3-native-controls/expo-module.config.json index 17ddda2f766c6bf92a32f61a11cfd517fa8e3e4c..08ac3e0785344029f3dab3c40029144806831b99 100644 --- a/apps/mobile/modules/t3-native-controls/expo-module.config.json +++ b/apps/mobile/modules/t3-native-controls/expo-module.config.json @@ -2,7 +2,10 @@ "platforms": ["apple", "android"], "apple": { "modules": ["T3NativeControlsModule", "T3KeyboardCommandsModule"], - "appDelegateSubscribers": ["T3WorkspaceOrientationSubscriber"] + "appDelegateSubscribers": [ + "T3WorkspaceOrientationSubscriber", + "T3MenuDismissTouchGateSubscriber" + ] }, "android": { "modules": [ diff --git a/apps/mobile/modules/t3-native-controls/ios/T3MenuDismissTouchGate.swift b/apps/mobile/modules/t3-native-controls/ios/T3MenuDismissTouchGate.swift new file mode 100644 index 0000000000000000000000000000000000000000..b8828fd28702cf26ab12dc9c1dfdc45d190d21fc --- /dev/null +++ b/apps/mobile/modules/t3-native-controls/ios/T3MenuDismissTouchGate.swift @@ -0,0 +1,81 @@ +import ExpoModulesCore +// Brings in `state` and `ignore(_:for:)` for the gate and React Native's touch handler. +import UIKit.UIGestureRecognizerSubclass + +/// Keeps the tap that dismisses a native menu from also pressing the React Native view under it. +/// +/// UIKit passes that tap through the menu's window-level container and only holds it back from +/// UIResponder touches, so React Native's surface touch handler still delivers a full press +/// (react-native#58983). Adapted from expo-modules-core's SystemMenuTouchGate (MIT), which Expo +/// installs only on windows hosting SwiftUI views; this covers MenuView buttons and header menus. +final class T3MenuDismissTouchGate: UIGestureRecognizer, UIGestureRecognizerDelegate { + static func install(in window: UIWindow) { + guard !(window.gestureRecognizers ?? []).contains(where: { $0 is T3MenuDismissTouchGate }) else { + return + } + window.addGestureRecognizer(T3MenuDismissTouchGate()) + } + + private init() { + super.init(target: nil, action: nil) + // Never hold a touch back, and never give React Native's handler a reason to cancel. + delaysTouchesEnded = false + cancelsTouchesInView = false + delegate = self + } + + // UIKit asks this before delivering the event's touches to any recognizer, so an ignored touch + // never reaches JS as touchStart, pressIn, or press. + func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive event: UIEvent) -> Bool { + if let touches = event.allTouches { + ignoreSurfaceTouches(touches.filter { $0.phase == .began }, for: event) + } + return true + } + + override func touchesBegan(_ touches: Set, with event: UIEvent) { + super.touchesBegan(touches, with: event) + ignoreSurfaceTouches(touches, for: event) + state = .failed + } + + private func ignoreSurfaceTouches(_ touches: Set, for event: UIEvent) { + guard let window = view as? UIWindow, window.subviews.contains(where: Self.isOpenMenuContainer) else { + return + } + for touch in touches { + var current = touch.view + while let view = current { + for recognizer in view.gestureRecognizers ?? [] + where NSStringFromClass(type(of: recognizer)) == "RCTSurfaceTouchHandler" { + recognizer.ignore(touch, for: event) + } + current = view.superview + } + } + } + + // UIKit adds this container to the window while a menu is presented, and turns its interaction + // off once dismissal commits so taps during the dismiss animation reach the app again. + private static func isOpenMenuContainer(_ view: UIView) -> Bool { + view.isUserInteractionEnabled + && NSStringFromClass(type(of: view)) == "_UI".appending("ContextMenuContainerView") + } +} + +/// Installs the menu dismiss gate on every window the app shows. +public final class T3MenuDismissTouchGateSubscriber: ExpoAppDelegateSubscriber { + public func subscriberDidRegister() { + NotificationCenter.default.addObserver( + self, + selector: #selector(windowDidBecomeVisible(_:)), + name: UIWindow.didBecomeVisibleNotification, + object: nil + ) + } + + @objc private func windowDidBecomeVisible(_ notification: Notification) { + guard let window = notification.object as? UIWindow else { return } + T3MenuDismissTouchGate.install(in: window) + } +}