-
-
Couldn't load subscription status.
- Fork 592
feat(iOS): Handle interactiveContentPopGesture for iOS 26 #3173
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat(iOS): Handle interactiveContentPopGesture for iOS 26 #3173
Conversation
9a80b45 to
468b1fe
Compare
| if (@available(iOS 26, *)) { | ||
| // Reenable interactions, see viewWillAppear | ||
| [self findReactRootViewController].view.userInteractionEnabled = true; | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm thinking, is it possible that viewWillAppear will run but viewDidAppear will not? Did you check how prevent remove works now?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I tried this on Test2125 which demos preventRemove
When using back button with prevent remove, the calls look like this:

We are covered by SecondScreen going back on top
When using swipe and not dismissing fully:

It's the same
When using swipe and actually dismissing SecondScreen (preventRemove still on):

Which is different ( 🤔 ) but works for our case
and without preventRemove it's like this:

so in every case at least one of the screens goes through the whole process
08ccb30 to
267ff70
Compare
…during transition
267ff70 to
852ed1f
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Please also check behavior with stack nested in modal, as it is attached directly to UIWindow instead of react root view (@WoLewicki's suggestion).
ios/RNSScreen.mm
Outdated
| // Furthermore, a stack put inside a modal will exist in an entirely different hierarchy | ||
| // To be sure, we block interactions on the whole window | ||
| // We need to get the window instance from parent view because it is not set until the view has appeared | ||
| self.parentViewController.view.window.userInteractionEnabled = false; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Please check one more thing - when you have some push screens and opened modal, when you dismiss the modal and quickly click the back button, will it work correctly (I'm not sure if viewWillAppear will be called)?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
And another thing - when I tested main (previous solution), this happened:
Simulator.Screen.Recording.-.iPhone.16.Pro.-.2025-09-04.at.10.44.06.mov
Please check if this solution prevents this but I managed to also reproduce the bug on iOS 18. If this isn't fixed by this PR, let's create a ticket to investigate this.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
when you dismiss the modal and quickly click the back button
It actually broke again in situation: stack1_screenA / stack2_screen A / stack2_screen B / stack2_modal C; when quickly dismissing modal and screen, the whole stack was dismissed. I managed to fix that, but it's not pretty. The main challenge was to find a reference to correct window without using sharedApplication.
As for dismissing multiple modals, native UIKit prevents dismissing more than 2 at once, blocking the third one mid swipe. I ended up blocking every consecutive dismissal similar to regular screens.
And another thing - when I tested main (previous solution), this happened:
I didn't notice anything on my PR
|
Paper appears to work, too. One other thing I found is that when UIWindow has interactions disabled and we still try to input gestures, this log is shown
I suspect that's because there is nothing above the window to handle the gesture, but I don't know really. There is not that much info online, other that people having this error when app breaks with black screen, which doesn't happen here. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The general direction of this PR is good, but I'm unsure of the details.
Didn't we agree that there should be a kill switch, that disables the interactiveContentPopGestureRecognizer?
This PR touches following props:
fullScreenSwipeEnabled,fullScreenSwipeShadowEnabled,gestureResponseDistance,gestureEnabled
Can I ask you to provide a extended description of current behaviour of these props on iOS <= 18 & detailed description of their behaviour & interactions on iOS 26+?
| _controller.interactivePopGestureRecognizer.delegate = self; | ||
| #if RNS_IPHONE_OS_VERSION_AVAILABLE(26_0) | ||
| if (@available(iOS 26, *)) { | ||
| _controller.interactiveContentPopGestureRecognizer.delegate = self; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm kinda unsure about this line. Is the delegate nil before we assign self? Or do we override some behaviour?
The docs explicitly mention to not do anything but set up failure requirements with this little one.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes they do, but sadly they don't explain anything and give no alternatives. On native UIKit test app, the gesture doesn't seem to work without having the delegate set to our controller, but that could be my lack of knowledge about the interactions in native UIKit.
Setting the delegate is necessary to hook the gesture into our logic, have it stop on gestureEnabled: false, etc., and it doesn't work here without setting the delegate either.
For regular edge swipe gesture, we have the same call just above this one, and it, too, doesn't work without it.
I also didn't notice any side effects
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Okay, I see. Thank you for explanation.
I'll want to revisit this logic one more time before we land it (I'll do it today).
| _isFullWidthSwipingWithPanGesture = YES; | ||
| [self cancelTouchesInParent]; | ||
| return YES; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We fallback into legacy logic here, but why don't we use isInGestureResponseDistance? Do we just ignore it? Is it intended?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
AFAIK it's intended. On iOS 26, we want the new gesture to work on the whole screen. The old one is kept only for when user sets custom animations, because we can't animate the native one easily. And in this scenario, the old one should behave as close as the new native one, having only the difference in animation, and without any customization that is exclusive for it.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Okay. That makes sense & complies with weekly conclusions. My suggestion would be to put this reasoning as a comment inside the code. When reading the code I found it "weird" to ignore the gestureResponseDistance in that case.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
While testing I've also noticed a couple of interesting behaviours. I describe them in follow-up comments 👇🏻
Header back button is exchanged "too quickly"I do not know yet whether this is a native behaviour (UIKit bug) or maybe intended behaviour, however it looks ugly.
|
White background is visible on fast swipewhite-background-visible-on-fast-swipe.movThis is most likely because third & further screens on the stack are frozen or detached. If they're frozen - we might be able to fix it. If they're detached - then it is UIKit who should attach them :/ |
|
Re: "White background is visible on fast swipe" This is a native "rubberband" effect, the white color is actually the background color of |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I've spent some time researching why do we have to set ourselves as delegates of interactiveContentPopGestureRecognizer for it to work. It boggles me, because we don't do it in new stack implementation and it works out of the box. Hence, simple conclusion - something prevents it from working. Can't tell what yet.
We should consider supporting gesture response distance with the new gesture. There is no reason to not support it, as this should be straightforward. I want you to create ticket for this.
Also, before this PR (using our custom pan gesture recognizer), user could disable full screen swipe, but leave edge-swipe gesture working. This PR eliminates this granularity. Why don't we change fullScreenSwipeEnabled internal type from boolean to 3-valued enum. If we get undefined, we convert it to system-default or something & on iOS < 26 we disable full screen gesture, >= 26 we enable the full screen gesture. This leaves the granularity in hand of the user.
I think we can proceed here, since this PR is needed, but we should do all above in follow-ups.
|
Okay, I've found out what makes it work w/o setting the delegate explicitly. This method: - (id<UIViewControllerAnimatedTransitioning>)navigationController:(UINavigationController *)navigationController
animationControllerForOperation:(UINavigationControllerOperation)operation
fromViewController:(UIViewController *)fromVC
toViewController:(UIViewController *)toVC
{ ... }In case where it is NOT overwritten, the gestures do work out of the box. If the method is overwritten & we return We can't really resign from overwriting this method, as it's only entry point for animation customisation ==> therefore we're forced to overwrite the delegates. Let me just note, that this is obviously undocumented UIKit behaviour. |
|
It seems like this change breaks horizontal scrollviews. Is there anything we can do to avoid this? |
|
Thanks for info, I'll look into it ASAP. |
Fixes #3173 (comment) ## Description #3173 introduced a bug that makes interactiveContentPopGesture dismiss a Screen with content ScrollView immediately, instead of actually scrolling the contents. ~From my testing, the native behavior is to prevent dismissing the screen with such ScrollView entirely.~ The screen should prevent dismissing the screen, only allowing it by swiping from the edge. This change aims to match this behavior. During testing, I also found out, that the previous recognizer works unreliably (sometimes it allows for scrolling, sometimes it dismisses the screen). I'll look into that in separate issue. ## Changes Modified `gestureRecognizer:shouldBeRequiredToFailByGestureRecognizer`, added `gestureRecognizer:shouldRequireFailureOfGestureRecognizer` (only in iOS >= 26). ## Before / After | before | after | | --- | --- | | <video src="https://github.com/user-attachments/assets/00b91d0c-d303-4e88-aef5-ec9659ed9121" /> | <video src="https://github.com/user-attachments/assets/f661d12d-4d8a-48ef-897e-d095b5778531" /> |
|
As on my side, full screen swipe do nothing with iOS 26 / Xcode 26 / v4.17.1 |
|
Will check this in 1h & get back to you @huextrat |
I have a repro here : https://github.com/huextrat/repro-screens-fullscreen-swipe When there is a |
|
I can confirm that full screen swipe does not work when you try to swipe inside a vertical scroll view. |
Description
Resolves https://github.com/software-mansion/react-native-screens-labs/issues/369, might resolve #3161, reverts #3141, #3142
This PR attempts to enable interactiveContentPopGestureRecognizer for iOS 26 to achieve native screen popping behavior. Until 26, the default was to swipe from the edge of the screen. We had the option to do fullscreen switch, which was controlled by a
fullScreenSwipeEnabledprop. Since the default behavior has changed, this prop, along withgestureResponseDistance, is being ignored from now on.New iOS allows for popping multiple screens almost at once, which we still cannot support due to asynchronous nature of stack updates coming from host to JS that would create a "feedback loop" in situation like the following: host pops 1. screen + sends update, pops 2. screen + sends update -> JS acknowledges 1. update + sends updated state -> host gets 2. screen from JS and pushes it again. This PR attempts to block more than 1 pop at once by removing interactions from the whole screen. As a (desired) side effect, this also disables interactions on the screen below the one that is popped until the transition finishes.
Changes
RNSPanGestureRecognizerfrom iOS 26 build and replace it with nativeinteractiveContentPopGestureRecognizerTest code and steps to reproduce
Use Test3173 to test swipe and interactions on bare screens API & compare with any other test that uses react-navigation stack, i.e Test3093. Use Test3093 with additional screenOptions:
to test custom animations on swipe back.