Skip to content

Commit 32050b9

Browse files
kmichalikkkkafar
andauthored
feat(iOS): Handle interactiveContentPopGesture for iOS 26 (#3173)
## Description Resolves software-mansion/react-native-screens-labs#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: ```ts { animation: 'slide_from_bottom', animationMatchesGesture: true, } ``` to test custom animations on swipe back. --------- Co-authored-by: Kacper Kafara <[email protected]>
1 parent cc3ae97 commit 32050b9

File tree

6 files changed

+188
-95
lines changed

6 files changed

+188
-95
lines changed

apps/src/tests/Test3173.tsx

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import * as React from 'react';
2+
import { View, Text, Button } from 'react-native';
3+
import { Screen, ScreenStack, ScreenStackHeaderConfig } from '../../../src';
4+
import { useState } from 'react';
5+
import Colors from '../shared/styling/Colors';
6+
7+
function HomeScreen({ add }: { add: () => void }) {
8+
console.log('Render home');
9+
return (
10+
<View
11+
style={{
12+
flex: 1,
13+
gap: 8,
14+
alignItems: 'center',
15+
justifyContent: 'center',
16+
backgroundColor: Colors.RedLight40,
17+
}}>
18+
<Text>Home!</Text>
19+
<Button
20+
title="Next"
21+
onPress={add}
22+
/>
23+
</View>
24+
);
25+
}
26+
27+
function ProfileScreen({ add, idx }: { add: () => void, idx: number }) {
28+
const colors = [Colors.BlueLight40, Colors.GreenLight40, Colors.YellowLight40];
29+
const [colorIndex, setColorIndex] = useState(0);
30+
console.log('Render another');
31+
return (
32+
<View
33+
style={{
34+
flex: 1,
35+
gap: 8,
36+
alignItems: 'center',
37+
justifyContent: 'center',
38+
backgroundColor: colors[colorIndex]
39+
}}>
40+
<Text>Another! #{idx}</Text>
41+
<Button
42+
title="More"
43+
onPress={add}
44+
/>
45+
<Button
46+
title="Recolor"
47+
onPress={() => setColorIndex(i => (i+1) % 3)}
48+
/>
49+
</View>
50+
);
51+
}
52+
53+
const App = () => {
54+
const [count, setCount] = React.useState(0);
55+
56+
return (
57+
<ScreenStack style={{ flex: 1 }}>
58+
<Screen key='home' activityState={2} isNativeStack onAppear={() => {setCount(0)}}>
59+
<ScreenStackHeaderConfig title='Home'/>
60+
<HomeScreen add={() => setCount(n => n+1)}/>
61+
</Screen>
62+
{ Array.from({length: count}).map((_, i) => (
63+
<Screen key={`prof${i}`} activityState={2} isNativeStack>
64+
<ScreenStackHeaderConfig title={'Another #' + (i+1)}/>
65+
<ProfileScreen add={() => setCount(n => n+1)} idx={i+1}/>
66+
</Screen>
67+
)) }
68+
</ScreenStack>
69+
);
70+
};
71+
export default App;

apps/src/tests/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,7 @@ export { default as Test3093 } from './Test3093';
148148
export { default as Test3111 } from './Test3111';
149149
export { default as Test3115 } from './Test3115';
150150
export { default as Test3168 } from './Test3168';
151+
export { default as Test3173 } from './Test3173';
151152
export { default as TestScreenAnimation } from './TestScreenAnimation';
152153
export { default as TestScreenAnimationV5 } from './TestScreenAnimationV5';
153154
export { default as TestHeader } from './TestHeader';

guides/GUIDE_FOR_LIBRARY_AUTHORS.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,12 +55,15 @@ Defaults to `false`. When `enableFreeze()` is run at the top of the application
5555
### `fullScreenSwipeEnabled` (iOS only)
5656

5757
Boolean indicating whether the swipe gesture should work on whole screen. Swiping with this option results in the same transition animation as `simple_push` by default. It can be changed to other custom animations with `customAnimationOnSwipe` prop, but default iOS swipe animation is not achievable due to usage of custom recognizer. Defaults to `false`.
58+
IMPORTANT: Starting from iOS 26, full screen swipe is handled by native recognizer, and this prop is ignored.
5859

5960
### `fullScreenSwipeShadowEnabled` (iOS only)
6061

6162
Boolean indicating whether the full screen dismiss gesture has shadow under view during transition. The gesture uses custom transition and thus
6263
doesn't have a shadow by default. When enabled, a custom shadow view is added during the transition which tries to mimic the
6364
default iOS shadow. Defaults to `true`.
65+
IMPORTANT: Starting from iOS 26, full screen swipe is handled by native recognizer, and this prop is ignored. We still fallback
66+
to the legacy implementation when when handling custom animations, but we assume `true` for shadows.
6467

6568
### `gestureEnabled` (iOS only)
6669

@@ -69,6 +72,7 @@ When set to `false` the back swipe gesture will be disabled. The default value i
6972
#### `gestureResponseDistance` (iOS only)
7073

7174
Use it to restrict the distance from the edges of screen in which the gesture should be recognized. To be used alongside `fullScreenSwipeEnabled`. The responsive area is covered with 4 values: `start`, `end`, `top`, `bottom`. Example usage:
75+
IMPORTANT: Starting from iOS 26, this prop conflicts with the native behavior of full screen swipe to dismiss, therefore it is ignored.
7276

7377
```tsx
7478
gestureResponseDistance: {

ios/RNSScreen.mm

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,18 @@ - (void)initCommonProps
140140
#endif // RCT_NEW_ARCH_ENABLED
141141
}
142142

143+
- (BOOL)getFullScreenSwipeShadowEnabled
144+
{
145+
if (@available(iOS 26, *)) {
146+
// fullScreenSwipeShadow is tied to RNSPanGestureRecognizer, which, on iOS 26, is used only for custom animations,
147+
// and replaced with native interactiveContentPopGestureRecognizer for everything else.
148+
// We want them to look similar and native-like, so it should default to `YES`.
149+
return YES;
150+
}
151+
152+
return _fullScreenSwipeShadowEnabled;
153+
}
154+
143155
- (UIViewController *)reactViewController
144156
{
145157
return _controller;
@@ -731,9 +743,27 @@ - (void)notifyTransitionProgress:(double)progress closing:(BOOL)closing goingFor
731743
#endif
732744
}
733745

734-
#if !RCT_NEW_ARCH_ENABLED
746+
- (void)willMoveToWindow:(UIWindow *)newWindow
747+
{
748+
if (@available(iOS 26, *)) {
749+
// In iOS 26, as soon as another screen appears in transition, it is interactable
750+
// To avoid glitches resulting from clicking buttons mid transition, we temporarily disable all interactions
751+
// Disabling interactions for parent navigation controller won't be enough in case of nested stack
752+
// Furthermore, a stack put inside a modal will exist in an entirely different hierarchy
753+
// To be sure, we block interactions on the whole window.
754+
// Note that newWindows is nil when moving from instead of moving to, and Obj-C handles nil correctly
755+
newWindow.userInteractionEnabled = false;
756+
}
757+
}
758+
735759
- (void)presentationControllerWillDismiss:(UIPresentationController *)presentationController
736760
{
761+
if (@available(iOS 26, *)) {
762+
// Disable interactions to disallow multiple modals dismissed at once; see willMoveToWindow
763+
presentationController.containerView.window.userInteractionEnabled = false;
764+
}
765+
766+
#if !RCT_NEW_ARCH_ENABLED
737767
// On Paper, we need to call both "cancel" and "reset" here because RN's gesture
738768
// recognizer does not handle the scenario when it gets cancelled by other top
739769
// level gesture recognizer. In this case by the modal dismiss gesture.
@@ -746,8 +776,8 @@ - (void)presentationControllerWillDismiss:(UIPresentationController *)presentati
746776
// down.
747777
[_touchHandler cancel];
748778
[_touchHandler reset];
749-
}
750779
#endif // !RCT_NEW_ARCH_ENABLED
780+
}
751781

752782
- (BOOL)presentationControllerShouldDismiss:(UIPresentationController *)presentationController
753783
{
@@ -759,6 +789,11 @@ - (BOOL)presentationControllerShouldDismiss:(UIPresentationController *)presenta
759789

760790
- (void)presentationControllerDidAttemptToDismiss:(UIPresentationController *)presentationController
761791
{
792+
if (@available(iOS 26, *)) {
793+
// Reenable interactions; see presentationControllerWillDismiss
794+
presentationController.containerView.window.userInteractionEnabled = true;
795+
}
796+
762797
// NOTE(kkafar): We should consider depracating the use of gesture cancel here & align
763798
// with usePreventRemove API of react-navigation v7.
764799
[self notifyGestureCancel];
@@ -769,6 +804,12 @@ - (void)presentationControllerDidAttemptToDismiss:(UIPresentationController *)pr
769804

770805
- (void)presentationControllerDidDismiss:(UIPresentationController *)presentationController
771806
{
807+
if (@available(iOS 26, *)) {
808+
// Reenable interactions; see presentationControllerWillDismiss
809+
// Dismissed screen doesn't hold a reference to window, but presentingViewController.view does
810+
presentationController.presentingViewController.view.window.userInteractionEnabled = true;
811+
}
812+
772813
if ([_reactSuperview respondsToSelector:@selector(presentationControllerDidDismiss:)]) {
773814
[_reactSuperview performSelector:@selector(presentationControllerDidDismiss:) withObject:presentationController];
774815
}
@@ -1529,6 +1570,10 @@ - (void)viewWillDisappear:(BOOL)animated
15291570

15301571
- (void)viewDidAppear:(BOOL)animated
15311572
{
1573+
if (@available(iOS 26, *)) {
1574+
// Reenable interactions, see willMoveToWindow
1575+
self.view.window.userInteractionEnabled = true;
1576+
}
15321577
[super viewDidAppear:animated];
15331578
if (!_isSwiping || _shouldNotify) {
15341579
// we are going forward or dismissing without swipe

0 commit comments

Comments
 (0)