Skip to content

Conversation

@kmichalikk
Copy link
Collaborator

@kmichalikk kmichalikk commented Aug 29, 2025

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 fullScreenSwipeEnabled prop. Since the default behavior has changed, this prop, along with gestureResponseDistance, 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

  • removed RNSPanGestureRecognizer from iOS 26 build and replace it with native interactiveContentPopGestureRecognizer
  • disabled all interactions when screens are in transition
  • updated docs

Test 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:

{
        animation: 'slide_from_bottom',
        animationMatchesGesture: true,
}

to test custom animations on swipe back.

@kmichalikk kmichalikk force-pushed the @kmichalikk/disable-screen-interaction-on-transition branch from 9a80b45 to 468b1fe Compare September 2, 2025 06:16
@kmichalikk kmichalikk marked this pull request as ready for review September 2, 2025 07:05
Comment on lines 1539 to 1544
if (@available(iOS 26, *)) {
// Reenable interactions, see viewWillAppear
[self findReactRootViewController].view.userInteractionEnabled = true;
}
Copy link
Contributor

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?

Copy link
Collaborator Author

@kmichalikk kmichalikk Sep 3, 2025

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:
image
We are covered by SecondScreen going back on top

When using swipe and not dismissing fully:
image
It's the same

When using swipe and actually dismissing SecondScreen (preventRemove still on):
image
Which is different ( 🤔 ) but works for our case

and without preventRemove it's like this:
image

so in every case at least one of the screens goes through the whole process

@kmichalikk kmichalikk force-pushed the @kmichalikk/disable-screen-interaction-on-transition branch from 08ccb30 to 267ff70 Compare September 3, 2025 06:01
@kmichalikk kmichalikk force-pushed the @kmichalikk/disable-screen-interaction-on-transition branch from 267ff70 to 852ed1f Compare September 3, 2025 06:19
@kmichalikk kmichalikk requested a review from kligarski September 3, 2025 06:23
Copy link
Contributor

@kligarski kligarski left a 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).

@kmichalikk kmichalikk requested a review from kligarski September 3, 2025 13:08
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;
Copy link
Contributor

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)?

Copy link
Contributor

@kligarski kligarski Sep 4, 2025

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.

Copy link
Collaborator Author

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

@kmichalikk
Copy link
Collaborator Author

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

Unexpected nil window in latent system gesture client update: windowServerHitTestWindow: <UIWindow: 0x101b05640; frame = (0 0; 402 874); autoresize = W+H; gestureRecognizers = <NSArray: 0x600001829800>; layer = <UIWindowLayer: 0x60000171f100>>, touch: <UITouch: 0x10e788d40> type: Direct; phase: Stationary; is pointer: NO; tap count: 1; force: 0.000; window: (null); responder: (null);

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.

@kmichalikk kmichalikk requested a review from kligarski September 5, 2025 09:31
Copy link
Member

@kkafar kkafar left a 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:

  1. fullScreenSwipeEnabled,
  2. fullScreenSwipeShadowEnabled,
  3. gestureResponseDistance,
  4. 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;
Copy link
Member

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.

Copy link
Collaborator Author

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

Copy link
Member

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).

Comment on lines +926 to +928
_isFullWidthSwipingWithPanGesture = YES;
[self cancelTouchesInParent];
return YES;
Copy link
Member

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?

Copy link
Collaborator Author

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.

Copy link
Member

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.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Member

@kkafar kkafar left a 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 👇🏻

@kkafar
Copy link
Member

kkafar commented Sep 16, 2025

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.

Long text Regular text
header-changes-too-quickly-2.mov
header-changes-too-quickly.mov

@kkafar
Copy link
Member

kkafar commented Sep 16, 2025

White background is visible on fast swipe

white-background-visible-on-fast-swipe.mov

This 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 :/

@kmichalikk
Copy link
Collaborator Author

Re: "White background is visible on fast swipe"

This is a native "rubberband" effect, the white color is actually the background color of RCTSurfaceHostingProxyRootView. We can follow up with a way to change it, or - as pointed out - there could be another view inserted which maybe? (needs confirmation) will be probed for the color.

Copy link
Member

@kkafar kkafar left a 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.

@kkafar
Copy link
Member

kkafar commented Sep 22, 2025

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 nil (the default case, haven't tried returning an animator yet; that's something to try tomorrow), the gesture recognizers won't work w/o setting the delegates.

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.

@kmichalikk kmichalikk merged commit 32050b9 into main Sep 23, 2025
6 checks passed
@kmichalikk kmichalikk deleted the @kmichalikk/disable-screen-interaction-on-transition branch September 23, 2025 09:51
@JustJoostNL
Copy link

It seems like this change breaks horizontal scrollviews. Is there anything we can do to avoid this?

@kmichalikk
Copy link
Collaborator Author

Thanks for info, I'll look into it ASAP.

kmichalikk added a commit that referenced this pull request Oct 2, 2025
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"
/> |
@huextrat
Copy link

fullScreenSwipeEnabled should works on iOS 26 / Xcode 26 thanks to this? 🤔

As on my side, full screen swipe do nothing with iOS 26 / Xcode 26 / v4.17.1

@kkafar
Copy link
Member

kkafar commented Oct 16, 2025

Will check this in 1h & get back to you @huextrat

@huextrat
Copy link

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 ScrollView, the fullscreen swipe do not work anymore, it was before v4.17.x
This is not related to Xcode 26 and I can reproduce on Xcode 16 also

@terrysahaidak
Copy link

I can confirm that full screen swipe does not work when you try to swipe inside a vertical scroll view.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Xcode 26] fullScreenGestureEnabled cause bug on scroll

7 participants