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