authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-10-12 00:40:31-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-12 00:40:31-07:00
logf498b3514b0b82e39e66c549468b2b889d5e520d
tree637aef8fd8e0308c5ab37282c9f36bac8010b510
parenteac6ba5ae9dfcd8106b43ac1b5612b2983bda642
signature Signed by PGP key B5690EEEBB952194

fix(mobile): menu dismiss taps no longer press the view underneath (#18565)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

2 files changed, 85 insertions(+), 1 deletions(-)

apps/mobile/modules/t3-native-controls/expo-module.config.json+4-1
...@@ -2,7 +2,10 @@...@@ -2,7 +2,10 @@
2 "platforms": ["apple", "android"],2 "platforms": ["apple", "android"],
3 "apple": {3 "apple": {
4 "modules": ["T3NativeControlsModule", "T3KeyboardCommandsModule"],4 "modules": ["T3NativeControlsModule", "T3KeyboardCommandsModule"],
5 "appDelegateSubscribers": ["T3WorkspaceOrientationSubscriber"]5 "appDelegateSubscribers": [
6 "T3WorkspaceOrientationSubscriber",
7 "T3MenuDismissTouchGateSubscriber"
8 ]
6 },9 },
7 "android": {10 "android": {
8 "modules": [11 "modules": [
apps/mobile/modules/t3-native-controls/ios/T3MenuDismissTouchGate.swift created+81
...@@ -0,0 +1,81 @@
1import ExpoModulesCore
2// Brings in `state` and `ignore(_:for:)` for the gate and React Native's touch handler.
3import 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.
11final 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.
67public 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}