From 41a3e3189091a97d5e3e2d1cf3f601be13b12b90 Mon Sep 17 00:00:00 2001 From: Jasleen Kaur Date: Fri, 19 Jun 2026 13:40:02 +0530 Subject: [PATCH] fix(ios): block screen touches while header UIMenu is open When a native stack header menu is presented, taps outside the menu were reaching RN Pressables underneath. Hook menu presentation via a deferred menu element, disable content interaction on the stack, and restore it on dismiss or item selection. Fixes #4139 --- ios/RNSBarButtonItem.h | 2 + ios/RNSBarButtonItem.mm | 27 +++++++++++- ios/RNSScreenStack.h | 3 ++ ios/RNSScreenStack.mm | 69 +++++++++++++++++++++++++++++++ ios/RNSScreenStackHeaderConfig.mm | 10 +++++ 5 files changed, 110 insertions(+), 1 deletion(-) diff --git a/ios/RNSBarButtonItem.h b/ios/RNSBarButtonItem.h index ea5325ea8d..b91f18e64b 100644 --- a/ios/RNSBarButtonItem.h +++ b/ios/RNSBarButtonItem.h @@ -5,12 +5,14 @@ typedef void (^RNSBarButtonItemAction)(NSString *buttonId); typedef void (^RNSBarButtonMenuItemAction)(NSString *menuId); +typedef void (^RNSBarButtonMenuPresentedCallback)(void); @interface RNSBarButtonItem : UIBarButtonItem - (instancetype)initWithConfig:(NSDictionary *)dict action:(RNSBarButtonItemAction)action menuAction:(RNSBarButtonMenuItemAction)menuAction + menuPresented:(RNSBarButtonMenuPresentedCallback)menuPresented imageLoader:(RCTImageLoader *)imageLoader; @end diff --git a/ios/RNSBarButtonItem.mm b/ios/RNSBarButtonItem.mm index 0eb1f09dee..9eaf0a63ef 100644 --- a/ios/RNSBarButtonItem.mm +++ b/ios/RNSBarButtonItem.mm @@ -14,6 +14,7 @@ @implementation RNSBarButtonItem { - (instancetype)initWithConfig:(NSDictionary *)dict action:(RNSBarButtonItemAction)action menuAction:(RNSBarButtonMenuItemAction)menuAction + menuPresented:(RNSBarButtonMenuPresentedCallback)menuPresented imageLoader:(RCTImageLoader *)imageLoader { self = [super init]; @@ -115,7 +116,31 @@ - (instancetype)initWithConfig:(NSDictionary *)dict if (@available(tvOS 17.0, *)) { NSDictionary *menu = dict[@"menu"]; if (menu) { - self.menu = [[self class] initUIMenuWithDict:menu menuAction:menuAction imageLoader:imageLoader]; + UIMenu *builtMenu = [[self class] initUIMenuWithDict:menu menuAction:menuAction imageLoader:imageLoader]; + +#if !TARGET_OS_TV + if (menuPresented && builtMenu) { + if (@available(iOS 15.0, *)) { + NSArray *children = builtMenu.children; + UIDeferredMenuElement *sentinel = + [UIDeferredMenuElement elementWithUncachedProvider:^(void (^completion)(NSArray *)) { + dispatch_async(dispatch_get_main_queue(), ^{ + if (menuPresented) { + menuPresented(); + } + completion(children); + }); + }]; + builtMenu = [UIMenu menuWithTitle:builtMenu.title + image:builtMenu.image + identifier:builtMenu.identifier + options:builtMenu.options + children:@[sentinel]]; + } + } +#endif // !TARGET_OS_TV + + self.menu = builtMenu; } } #endif diff --git a/ios/RNSScreenStack.h b/ios/RNSScreenStack.h index 41b17bab4d..ee3807d8a9 100644 --- a/ios/RNSScreenStack.h +++ b/ios/RNSScreenStack.h @@ -28,6 +28,9 @@ NS_ASSUME_NONNULL_BEGIN - (void)updateScreenTransition:(double)progress; - (void)finishScreenTransition:(BOOL)canceled; +- (void)headerMenuWillPresent; +- (void)headerMenuDidDismiss; + @property (nonatomic) BOOL customAnimation; @property (nonatomic) BOOL disableSwipeBack; diff --git a/ios/RNSScreenStack.mm b/ios/RNSScreenStack.mm index 192f5d2c77..20589cc4e4 100644 --- a/ios/RNSScreenStack.mm +++ b/ios/RNSScreenStack.mm @@ -167,6 +167,41 @@ @implementation RNSPanGestureRecognizer @end #endif +@interface RNSScreenStackView (RNSHeaderMenuPrivate) +- (void)cancelTouchesInParent; +@end + +@interface RNSMenuDismissGestureRecognizer : UIGestureRecognizer +@property (nonatomic, weak) RNSScreenStackView *stackView; +@property (nonatomic, weak) UINavigationBar *navigationBar; +@end + +@implementation RNSMenuDismissGestureRecognizer + +- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event +{ + [super touchesBegan:touches withEvent:event]; + + UITouch *touch = touches.anyObject; + if (!touch) { + self.state = UIGestureRecognizerStateFailed; + return; + } + + CGPoint point = [touch locationInView:self.stackView]; + + if (self.navigationBar && CGRectContainsPoint(self.navigationBar.frame, point)) { + self.state = UIGestureRecognizerStateFailed; + return; + } + + [self.stackView cancelTouchesInParent]; + self.state = UIGestureRecognizerStateRecognized; + [self.stackView headerMenuDidDismiss]; +} + +@end + @implementation RNSScreenStackView { UINavigationController *_controller; NSMutableArray *_reactSubviews; @@ -175,6 +210,8 @@ @implementation RNSScreenStackView { RNSPercentDrivenInteractiveTransition *_interactionController; __weak RNSScreenStackManager *_manager; BOOL _updateScheduled; + BOOL _isMenuPresented; + RNSMenuDismissGestureRecognizer *_menuDismissGestureRecognizer; } - (instancetype)initWithFrame:(CGRect)frame @@ -781,6 +818,38 @@ - (void)cancelTouchesInParent [[self rnscreens_findTouchHandlerInAncestorChain] rnscreens_cancelTouches]; } +- (void)headerMenuWillPresent +{ + if (_isMenuPresented) { + return; + } + _isMenuPresented = YES; + + [self cancelTouchesInParent]; + _controller.topViewController.view.userInteractionEnabled = NO; + + _menuDismissGestureRecognizer = [[RNSMenuDismissGestureRecognizer alloc] initWithTarget:nil action:nil]; + _menuDismissGestureRecognizer.stackView = self; + _menuDismissGestureRecognizer.navigationBar = _controller.navigationBar; + _menuDismissGestureRecognizer.cancelsTouchesInView = YES; + [self addGestureRecognizer:_menuDismissGestureRecognizer]; +} + +- (void)headerMenuDidDismiss +{ + if (!_isMenuPresented) { + return; + } + _isMenuPresented = NO; + + _controller.topViewController.view.userInteractionEnabled = YES; + + if (_menuDismissGestureRecognizer) { + [self removeGestureRecognizer:_menuDismissGestureRecognizer]; + _menuDismissGestureRecognizer = nil; + } +} + - (BOOL)gestureRecognizerShouldBegin:(UIGestureRecognizer *)gestureRecognizer { if (_disableSwipeBack) { diff --git a/ios/RNSScreenStackHeaderConfig.mm b/ios/RNSScreenStackHeaderConfig.mm index 5dbe2e2e65..8b25292bb7 100644 --- a/ios/RNSScreenStackHeaderConfig.mm +++ b/ios/RNSScreenStackHeaderConfig.mm @@ -21,6 +21,7 @@ #import "RNSConvert.h" #import "RNSDefines.h" #import "RNSScreen.h" +#import "RNSScreenStack.h" #import "RNSSearchBar.h" #import "UINavigationBar+RNSUtility.h" @@ -777,6 +778,10 @@ - (void)applySemanticContentAttributeIfNeededToNavCtrl:(UINavigationController * for (NSUInteger i = 0; i < dicts.count; i++) { NSDictionary *dict = dicts[i]; if (dict[@"buttonId"] || dict[@"menu"]) { + UIView *stackCandidate = self.screenView.controller.navigationController.view.superview; + __weak RNSScreenStackView *weakStackView = + [stackCandidate isKindOfClass:RNSScreenStackView.class] ? (RNSScreenStackView *)stackCandidate : nil; + RNSBarButtonItem *item = [[RNSBarButtonItem alloc] initWithConfig:dict action:^(NSString *buttonId) { auto eventEmitter = std::static_pointer_cast( @@ -788,6 +793,8 @@ - (void)applySemanticContentAttributeIfNeededToNavCtrl:(UINavigationController * } } menuAction:^(NSString *menuId) { + [weakStackView headerMenuDidDismiss]; + auto eventEmitter = std::static_pointer_cast( self->_eventEmitter); if (eventEmitter && menuId) { @@ -796,6 +803,9 @@ - (void)applySemanticContentAttributeIfNeededToNavCtrl:(UINavigationController * .menuId = std::string([menuId UTF8String])}); } } + menuPresented:^{ + [weakStackView headerMenuWillPresent]; + } imageLoader:_imageLoader]; NSNumber *index = dict[@"index"]; if (index.integerValue < items.count) {