diff --git a/ios/RNSScreen.h b/ios/RNSScreen.h index accc78a2c1..e3f5b96ab5 100644 --- a/ios/RNSScreen.h +++ b/ios/RNSScreen.h @@ -7,6 +7,7 @@ #import "RNSScreenContentWrapper.h" #import "RNSScrollEdgeEffectApplicator.h" #import "RNSScrollViewBehaviorOverriding.h" +#import "RNSViewInteractionManager.h" #if !TARGET_OS_TV #import "RNSOrientationProviding.h" @@ -171,6 +172,12 @@ namespace react = facebook::react; - (BOOL)isModal; - (BOOL)isPresentedAsNativeModal; +/** + * Holds a shared instance to a service that finds the view that needs to have interactions disabled for stack to not + * have multiple screen transitions at once. + */ ++ (RNSViewInteractionManager *)viewInteractionManagerInstance; + /** * Tell `Screen` component that it has been removed from react state and can safely cleanup * any retained resources. diff --git a/ios/RNSScreen.mm b/ios/RNSScreen.mm index a432b151a8..717eacfe6f 100644 --- a/ios/RNSScreen.mm +++ b/ios/RNSScreen.mm @@ -145,6 +145,17 @@ - (void)initCommonProps #endif // RCT_NEW_ARCH_ENABLED } ++ (RNSViewInteractionManager *)viewInteractionManagerInstance +{ + static RNSViewInteractionManager *manager = nil; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + manager = [[RNSViewInteractionManager alloc] init]; + }); + + return manager; +} + - (BOOL)getFullScreenSwipeShadowEnabled { if (@available(iOS 26, *)) { @@ -802,9 +813,10 @@ - (void)willMoveToWindow:(UIWindow *)newWindow // To avoid glitches resulting from clicking buttons mid transition, we temporarily disable all interactions // Disabling interactions for parent navigation controller won't be enough in case of nested stack // Furthermore, a stack put inside a modal will exist in an entirely different hierarchy - // To be sure, we block interactions on the whole window. - // Note that newWindows is nil when moving from instead of moving to, and Obj-C handles nil correctly - newWindow.userInteractionEnabled = false; + + // Use RNSViewInteractionManager util to find a suitable subtree to disable interations on, + // starting from reactSuperview, because on Paper, self is not attached yet. + [RNSScreenView.viewInteractionManagerInstance disableInteractionsForSubtreeWith:self.reactSuperview]; } } @@ -812,7 +824,7 @@ - (void)presentationControllerWillDismiss:(UIPresentationController *)presentati { if (@available(iOS 26, *)) { // Disable interactions to disallow multiple modals dismissed at once; see willMoveToWindow - presentationController.containerView.window.userInteractionEnabled = false; + [RNSScreenView.viewInteractionManagerInstance disableInteractionsForSubtreeWith:self.reactSuperview]; } #if !RCT_NEW_ARCH_ENABLED @@ -843,7 +855,7 @@ - (void)presentationControllerDidAttemptToDismiss:(UIPresentationController *)pr { if (@available(iOS 26, *)) { // Reenable interactions; see presentationControllerWillDismiss - presentationController.containerView.window.userInteractionEnabled = true; + [RNSScreenView.viewInteractionManagerInstance enableInteractionsForLastSubtree]; } // NOTE(kkafar): We should consider depracating the use of gesture cancel here & align @@ -859,7 +871,7 @@ - (void)presentationControllerDidDismiss:(UIPresentationController *)presentatio if (@available(iOS 26, *)) { // Reenable interactions; see presentationControllerWillDismiss // Dismissed screen doesn't hold a reference to window, but presentingViewController.view does - presentationController.presentingViewController.view.window.userInteractionEnabled = true; + [RNSScreenView.viewInteractionManagerInstance enableInteractionsForLastSubtree]; } if ([_reactSuperview respondsToSelector:@selector(presentationControllerDidDismiss:)]) { @@ -1672,7 +1684,7 @@ - (void)viewDidAppear:(BOOL)animated { if (@available(iOS 26, *)) { // Reenable interactions, see willMoveToWindow - self.view.window.userInteractionEnabled = true; + [RNSScreenView.viewInteractionManagerInstance enableInteractionsForLastSubtree]; } [super viewDidAppear:animated]; if (!_isSwiping || _shouldNotify) { @@ -1714,6 +1726,10 @@ - (void)viewDidDisappear:(BOOL)animated #else [self traverseForScrollView:self.screenView]; #endif + if (@available(iOS 26, *)) { + // Reenable interactions, see willMoveToWindow + [RNSScreenView.viewInteractionManagerInstance enableInteractionsForLastSubtree]; + } } - (void)viewDidLayoutSubviews diff --git a/ios/RNSScreenStack.mm b/ios/RNSScreenStack.mm index 6d4edacc02..23cc0f0824 100644 --- a/ios/RNSScreenStack.mm +++ b/ios/RNSScreenStack.mm @@ -29,6 +29,7 @@ #import "RNSScreenWindowTraits.h" #import "RNSScrollViewFinder.h" #import "RNSTabsScreenViewController.h" +#import "RNSViewInteractionAware.h" #import "UIScrollView+RNScreens.h" #import "UIView+RNSUtility.h" #import "integrations/RNSDismissibleModalProtocol.h" @@ -42,7 +43,8 @@ @interface RNSScreenStackView () < UINavigationControllerDelegate, UIAdaptivePresentationControllerDelegate, UIGestureRecognizerDelegate, - UIViewControllerTransitioningDelegate + UIViewControllerTransitioningDelegate, + RNSViewInteractionAware #ifdef RCT_NEW_ARCH_ENABLED , RCTMountingTransactionObserving @@ -210,6 +212,7 @@ @implementation RNSScreenStackView { RNSPercentDrivenInteractiveTransition *_interactionController; __weak RNSScreenStackManager *_manager; BOOL _updateScheduled; + UIPanGestureRecognizer *_sinkEventsPanGestureRecognizer; #ifdef RCT_NEW_ARCH_ENABLED /// Screens that are subject of `ShadowViewMutation::Type::Delete` mutation /// in current transaction. This vector should be populated when we receive notification via @@ -255,6 +258,7 @@ - (void)initCommonProps _presentedModals = [NSMutableArray new]; _controller = [RNSNavigationController new]; _controller.delegate = self; + _sinkEventsPanGestureRecognizer = [[UIPanGestureRecognizer alloc] init]; #if !TARGET_OS_TV && !TARGET_OS_VISION [self setupGestureHandlers]; #endif @@ -363,6 +367,12 @@ - (void)didMoveToWindow [self maybeAddToParentAndUpdateContainer]; } #endif + if (self.window == nil) { + // When hot reload happens that would remove the whole stack, disabling the interaction on a screen out transition + // will not be matched with enabling the interactions on another screen's in transition. We need to make sure + // that the subtree is interactive again + [RNSScreenView.viewInteractionManagerInstance enableInteractionsForLastSubtree]; + } } - (void)maybeAddToParentAndUpdateContainer @@ -844,6 +854,20 @@ - (void)cancelTouchesInParent [[self rnscreens_findTouchHandlerInAncestorChain] rnscreens_cancelTouches]; } +- (void)rnscreens_disableInteractions +{ + // When transitioning between screens, disable interactions on stack subview which wraps the screens + // and sink all gesture events. This should work for nested stacks and stack inside bottom tabs, inside stack. + self.subviews[0].userInteractionEnabled = NO; + [self addGestureRecognizer:_sinkEventsPanGestureRecognizer]; +} + +- (void)rnscreens_enableInteractions +{ + self.subviews[0].userInteractionEnabled = YES; + [self removeGestureRecognizer:_sinkEventsPanGestureRecognizer]; +} + - (BOOL)gestureRecognizerShouldBegin:(UIGestureRecognizer *)gestureRecognizer { if (_disableSwipeBack) { @@ -1211,6 +1235,15 @@ - (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer - (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldRequireFailureOfGestureRecognizer:(UIGestureRecognizer *)otherGestureRecognizer { + if (otherGestureRecognizer == _sinkEventsPanGestureRecognizer) { + // When transition happens between two stack screens, a special "sink" recognizer is added, and then removed. + // It captures all gestures for the time of transition and does nothing, so that in nested stack scenario, + // the outer most stack does not recognize swipe gestures, otherwise it would dismiss the whole nested stack. + // For the recognizer to work as described, it should have precedence over all other recognizers. + // see also: rnscreens_enableInteractions, rnscreens_disableInteractions + return YES; + } + if (@available(iOS 26, *)) { if (gestureRecognizer == _controller.interactiveContentPopGestureRecognizer && [self isScrollViewPanGestureRecognizer:otherGestureRecognizer]) { diff --git a/ios/helpers/RNSViewInteractionAware.h b/ios/helpers/RNSViewInteractionAware.h new file mode 100644 index 0000000000..f659c112f1 --- /dev/null +++ b/ios/helpers/RNSViewInteractionAware.h @@ -0,0 +1,9 @@ +#pragma once + +@protocol RNSViewInteractionAware + +- (void)rnscreens_disableInteractions; + +- (void)rnscreens_enableInteractions; + +@end diff --git a/ios/helpers/RNSViewInteractionManager.h b/ios/helpers/RNSViewInteractionManager.h new file mode 100644 index 0000000000..4fabc6e413 --- /dev/null +++ b/ios/helpers/RNSViewInteractionManager.h @@ -0,0 +1,19 @@ +#pragma once + +@interface RNSViewInteractionManager : NSObject + +- (instancetype)init; + +/** + * Given a view, traverse its ancestors hierarchy and find a view that supports RNSViewInteractionAware protocol + * and can disable interactions for the time of screen transition. Make sure that at most one view is disabled at any + * time, re-enabling interactions on previously affected views when necessary. + */ +- (void)disableInteractionsForSubtreeWith:(UIView *)view; + +/** + * Re-enable interactions on the view that had them previously disabled. + */ +- (void)enableInteractionsForLastSubtree; + +@end diff --git a/ios/helpers/RNSViewInteractionManager.mm b/ios/helpers/RNSViewInteractionManager.mm new file mode 100644 index 0000000000..c2f401f8fd --- /dev/null +++ b/ios/helpers/RNSViewInteractionManager.mm @@ -0,0 +1,55 @@ +#import "RNSViewInteractionManager.h" +#import "RNSBottomTabsScreenComponentView.h" +#import "RNSViewInteractionAware.h" + +@implementation RNSViewInteractionManager { + __weak UIView *lastRootWithInteractionsDisabled; +} + +- (instancetype)init +{ + if (self = [super init]) { + lastRootWithInteractionsDisabled = nil; + } + return self; +} + +- (void)disableInteractionsForSubtreeWith:(UIView *)view +{ + UIView *current = view; + while (current && ![current isKindOfClass:UIWindow.class] && + ![current respondsToSelector:@selector(rnscreens_disableInteractions)]) { + current = current.superview; + } + + if (current) { + if (lastRootWithInteractionsDisabled && lastRootWithInteractionsDisabled != current) { + // When one view already has interactions disabled, and we request a different view, + // we need to restore the first one + [self enableInteractionsForLastSubtree]; + } + + if ([current respondsToSelector:@selector(rnscreens_disableInteractions)]) { + [static_cast>(current) rnscreens_disableInteractions]; + } else { + current.userInteractionEnabled = NO; + } + + lastRootWithInteractionsDisabled = current; + } +} + +- (void)enableInteractionsForLastSubtree +{ + if (lastRootWithInteractionsDisabled) { + if ([lastRootWithInteractionsDisabled respondsToSelector:@selector(rnscreens_enableInteractions)]) { + [static_cast>(lastRootWithInteractionsDisabled) rnscreens_enableInteractions]; + } else { + lastRootWithInteractionsDisabled.userInteractionEnabled = YES; + } + + lastRootWithInteractionsDisabled = nil; + } +} + +@end