| ... | ... | @@ -0,0 +1,81 @@ |
| 1 | import ExpoModulesCore |
| 2 | // Brings in `state` and `ignore(_:for:)` for the gate and React Native's touch handler. |
| 3 | import UIKit.UIGestureRecognizerSubclass |
| 4 | |
| 5 | /// Keeps the tap that dismisses a native menu from also pressing the React Native view under it. |
| 6 | /// |
| 7 | /// UIKit passes that tap through the menu's window-level container and only holds it back from |
| 8 | /// UIResponder touches, so React Native's surface touch handler still delivers a full press |
| 9 | /// (react-native#58983). Adapted from expo-modules-core's SystemMenuTouchGate (MIT), which Expo |
| 10 | /// installs only on windows hosting SwiftUI views; this covers MenuView buttons and header menus. |
| 11 | final class T3MenuDismissTouchGate: UIGestureRecognizer, UIGestureRecognizerDelegate { |
| 12 | static func install(in window: UIWindow) { |
| 13 | guard !(window.gestureRecognizers ?? []).contains(where: { $0 is T3MenuDismissTouchGate }) else { |
| 14 | return |
| 15 | } |
| 16 | window.addGestureRecognizer(T3MenuDismissTouchGate()) |
| 17 | } |
| 18 | |
| 19 | private init() { |
| 20 | super.init(target: nil, action: nil) |
| 21 | // Never hold a touch back, and never give React Native's handler a reason to cancel. |
| 22 | delaysTouchesEnded = false |
| 23 | cancelsTouchesInView = false |
| 24 | delegate = self |
| 25 | } |
| 26 | |
| 27 | // UIKit asks this before delivering the event's touches to any recognizer, so an ignored touch |
| 28 | // never reaches JS as touchStart, pressIn, or press. |
| 29 | func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive event: UIEvent) -> Bool { |
| 30 | if let touches = event.allTouches { |
| 31 | ignoreSurfaceTouches(touches.filter { $0.phase == .began }, for: event) |
| 32 | } |
| 33 | return true |
| 34 | } |
| 35 | |
| 36 | override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent) { |
| 37 | super.touchesBegan(touches, with: event) |
| 38 | ignoreSurfaceTouches(touches, for: event) |
| 39 | state = .failed |
| 40 | } |
| 41 | |
| 42 | private func ignoreSurfaceTouches(_ touches: Set<UITouch>, for event: UIEvent) { |
| 43 | guard let window = view as? UIWindow, window.subviews.contains(where: Self.isOpenMenuContainer) else { |
| 44 | return |
| 45 | } |
| 46 | for touch in touches { |
| 47 | var current = touch.view |
| 48 | while let view = current { |
| 49 | for recognizer in view.gestureRecognizers ?? [] |
| 50 | where NSStringFromClass(type(of: recognizer)) == "RCTSurfaceTouchHandler" { |
| 51 | recognizer.ignore(touch, for: event) |
| 52 | } |
| 53 | current = view.superview |
| 54 | } |
| 55 | } |
| 56 | } |
| 57 | |
| 58 | // UIKit adds this container to the window while a menu is presented, and turns its interaction |
| 59 | // off once dismissal commits so taps during the dismiss animation reach the app again. |
| 60 | private static func isOpenMenuContainer(_ view: UIView) -> Bool { |
| 61 | view.isUserInteractionEnabled |
| 62 | && NSStringFromClass(type(of: view)) == "_UI".appending("ContextMenuContainerView") |
| 63 | } |
| 64 | } |
| 65 | |
| 66 | /// Installs the menu dismiss gate on every window the app shows. |
| 67 | public final class T3MenuDismissTouchGateSubscriber: ExpoAppDelegateSubscriber { |
| 68 | public func subscriberDidRegister() { |
| 69 | NotificationCenter.default.addObserver( |
| 70 | self, |
| 71 | selector: #selector(windowDidBecomeVisible(_:)), |
| 72 | name: UIWindow.didBecomeVisibleNotification, |
| 73 | object: nil |
| 74 | ) |
| 75 | } |
| 76 | |
| 77 | @objc private func windowDidBecomeVisible(_ notification: Notification) { |
| 78 | guard let window = notification.object as? UIWindow else { return } |
| 79 | T3MenuDismissTouchGate.install(in: window) |
| 80 | } |
| 81 | } |