Skip to content
Open
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions ios/RNSScreen.h
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
#import "RNSScreenContentWrapper.h"
#import "RNSScrollEdgeEffectApplicator.h"
#import "RNSScrollViewBehaviorOverriding.h"
#import "RNSViewInteractionManager.h"

#if !TARGET_OS_TV
#import "RNSOrientationProviding.h"
Expand Down Expand Up @@ -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.
Expand Down
30 changes: 23 additions & 7 deletions ios/RNSScreen.mm
Original file line number Diff line number Diff line change
Expand Up @@ -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, *)) {
Expand Down Expand Up @@ -802,17 +813,18 @@ - (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];
}
}

- (void)presentationControllerWillDismiss:(UIPresentationController *)presentationController
{
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
Expand Down Expand Up @@ -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
Expand All @@ -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:)]) {
Expand Down Expand Up @@ -1665,7 +1677,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) {
Expand Down Expand Up @@ -1707,6 +1719,10 @@ - (void)viewDidDisappear:(BOOL)animated
#else
[self traverseForScrollView:self.screenView];
#endif
if (@available(iOS 26, *)) {
// Reenable interactions, see willMoveToWindow
[RNSScreenView.viewInteractionManagerInstance enableInteractionsForLastSubtree];
}
}

- (void)viewDidLayoutSubviews
Expand Down
35 changes: 34 additions & 1 deletion ios/RNSScreenStack.mm
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -42,7 +43,8 @@ @interface RNSScreenStackView () <
UINavigationControllerDelegate,
UIAdaptivePresentationControllerDelegate,
UIGestureRecognizerDelegate,
UIViewControllerTransitioningDelegate
UIViewControllerTransitioningDelegate,
RNSViewInteractionAware
#ifdef RCT_NEW_ARCH_ENABLED
,
RCTMountingTransactionObserving
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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]) {
Expand Down
9 changes: 9 additions & 0 deletions ios/helpers/RNSViewInteractionAware.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
#pragma once

@protocol RNSViewInteractionAware

- (void)rnscreens_disableInteractions;

- (void)rnscreens_enableInteractions;

@end
19 changes: 19 additions & 0 deletions ios/helpers/RNSViewInteractionManager.h
Original file line number Diff line number Diff line change
@@ -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
55 changes: 55 additions & 0 deletions ios/helpers/RNSViewInteractionManager.mm
Original file line number Diff line number Diff line change
@@ -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<id<RNSViewInteractionAware>>(current) rnscreens_disableInteractions];
} else {
current.userInteractionEnabled = NO;
}

lastRootWithInteractionsDisabled = current;
}
}

- (void)enableInteractionsForLastSubtree
{
if (lastRootWithInteractionsDisabled) {
if ([lastRootWithInteractionsDisabled respondsToSelector:@selector(rnscreens_enableInteractions)]) {
[static_cast<id<RNSViewInteractionAware>>(lastRootWithInteractionsDisabled) rnscreens_enableInteractions];
} else {
lastRootWithInteractionsDisabled.userInteractionEnabled = YES;
}

lastRootWithInteractionsDisabled = nil;
}
}

@end
Loading