Skip to content

Commit 0c68869

Browse files
authored
fix(iOS 26, Stack v4): fix RNSScreen being partially obstructed by UINavigationBar on iOS 26 (#3111)
## Description Adds a workaround for UIKit `edgesForExtendedLayout` bug on iOS 26 (beta 5). On iOS 26 (beta 5), content is rendered partially behind the header even with top edge being excluded from `edgesForExtendedLayout`. This happens because header's size has changed and now UINavigationBar is offset from the parent but it seems like `edgesForExtendedLayout` does not account for this. Apple advises to use `safeAreaLayoutGuide` instead of `edgesForExtendedLayout` but `edgesForExtendedLayout` is not deprecated yet therefore we hope that Apple fixes this in the near future. If this happens, workaround should be removed - I created a ticket for this: software-mansion/react-native-screens-labs#358. Fixes software-mansion/react-native-screens-labs#332 (also contains more information about the bug). Fixes software-mansion/react-native-screens-labs#363. Fixes #3113. Fixes one of the bugs mentioned here: software-mansion/react-native-screens-labs#364. ## Changes - use `safeAreaLayoutGuide` when header is shown (and it's not transparent) ## Screenshots / GIFs | before | after | | --- | --- | | <img width="1206" height="2622" alt="Simulator Screenshot - iPhone 16 Pro - 2025-08-12 at 11 47 21" src="https://github.com/user-attachments/assets/4a42d5ff-bcc3-45d1-94b3-ec6b79f2b5a6" /> | <img width="1206" height="2622" alt="Simulator Screenshot - iPhone 16 Pro - 2025-08-12 at 11 44 31" src="https://github.com/user-attachments/assets/22cb7a25-23f6-4e0f-a3b7-e2c96b3a299c" /> | ## Test code and steps to reproduce Run: - `Test3111` - `TestModalNavigation`, open Screen1, change orientation to landscape and verify that headers do not have a gap between them - `BottomTabsAndStack`, change orientation to landscape and verify that top padding is the same size as in portrait (content is not obstructed by header). ## Checklist - [x] Included code example that can be used to test this change - [ ] Ensured that CI passes
1 parent 452421f commit 0c68869

File tree

4 files changed

+316
-0
lines changed

4 files changed

+316
-0
lines changed

apps/src/tests/Test3111.tsx

Lines changed: 266 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,266 @@
1+
import React from 'react';
2+
import { NavigationContainer, ParamListBase } from '@react-navigation/native';
3+
import {
4+
NativeStackNavigationProp,
5+
createNativeStackNavigator,
6+
} from '@react-navigation/native-stack';
7+
import { Button, View, Text, ScrollView } from 'react-native';
8+
import PressableWithFeedback from '../shared/PressableWithFeedback';
9+
10+
type RouteParamList = {
11+
Main: undefined;
12+
PushWithoutHeader: undefined;
13+
PushWithTransparentHeader: undefined;
14+
ModalWithView: undefined;
15+
ModalWithViewAndNoHeader: undefined;
16+
ModalWithViewAndTransparentHeader: undefined;
17+
ModalWithScrollView: undefined;
18+
ModalWithScrollViewAndNoHeader: undefined;
19+
ModalWithScrollViewAndTransparentHeader: undefined;
20+
ModalWithScrollViewBehaviorAutomatic: undefined;
21+
ModalWithScrollViewBehaviorAutomaticAndNoHeader: undefined;
22+
ModalWithScrollViewBehaviorAutomaticAndTransparentHeader: undefined;
23+
};
24+
25+
type NavigationProp<ParamList extends ParamListBase> = {
26+
navigation: NativeStackNavigationProp<ParamList>;
27+
};
28+
29+
type StackNavigationProp = NavigationProp<RouteParamList>;
30+
31+
const Stack = createNativeStackNavigator<RouteParamList>();
32+
33+
function Screen1({ navigation }: StackNavigationProp) {
34+
return (
35+
<View style={{ padding: 10 }}>
36+
<Text style={{ fontSize: 30, fontWeight: 'bold' }}>Push</Text>
37+
<Button
38+
title="WithoutHeader"
39+
onPress={() => navigation.push('PushWithoutHeader')}
40+
/>
41+
<Button
42+
title="TransparentHeader"
43+
onPress={() => navigation.push('PushWithTransparentHeader')}
44+
/>
45+
<Text style={{ fontSize: 30, fontWeight: 'bold', marginTop: 10 }}>
46+
Modal + View
47+
</Text>
48+
<Button title="Normal" onPress={() => navigation.push('ModalWithView')} />
49+
<Button
50+
title="WithoutHeader"
51+
onPress={() => navigation.push('ModalWithViewAndNoHeader')}
52+
/>
53+
<Button
54+
title="TransparentHeader"
55+
onPress={() => navigation.push('ModalWithViewAndTransparentHeader')}
56+
/>
57+
<Text style={{ fontSize: 30, fontWeight: 'bold', marginTop: 10 }}>
58+
Modal + ScrollView
59+
</Text>
60+
<Button
61+
title="Normal"
62+
onPress={() => navigation.push('ModalWithScrollView')}
63+
/>
64+
<Button
65+
title="WithoutHeader"
66+
onPress={() => navigation.push('ModalWithScrollViewAndNoHeader')}
67+
/>
68+
<Button
69+
title="TransparentHeader"
70+
onPress={() =>
71+
navigation.push('ModalWithScrollViewAndTransparentHeader')
72+
}
73+
/>
74+
<Text style={{ fontSize: 30, fontWeight: 'bold', marginTop: 10 }}>
75+
Modal + ScrollView
76+
</Text>
77+
<Text style={{ fontStyle: 'italic' }}>
78+
with contentInsetAdjustmentBehavior='automatic'
79+
</Text>
80+
<Button
81+
title="Normal"
82+
onPress={() => navigation.push('ModalWithScrollViewBehaviorAutomatic')}
83+
/>
84+
<Button
85+
title="WithoutHeader"
86+
onPress={() =>
87+
navigation.push('ModalWithScrollViewBehaviorAutomaticAndNoHeader')
88+
}
89+
/>
90+
<Button
91+
title="TransparentHeader"
92+
onPress={() =>
93+
navigation.push(
94+
'ModalWithScrollViewBehaviorAutomaticAndTransparentHeader',
95+
)
96+
}
97+
/>
98+
</View>
99+
);
100+
}
101+
102+
function Screen2() {
103+
return (
104+
<View style={{ gap: 20, paddingHorizontal: 30, paddingVertical: 10 }}>
105+
{[...Array(30).keys()].map(index => (
106+
<PressableWithFeedback
107+
key={index + 1}
108+
onPress={() => console.log(`Pressed #${index + 1}`)}
109+
style={{
110+
paddingVertical: 10,
111+
paddingHorizontal: 20,
112+
}}>
113+
<Text>Pressable #{index + 1}</Text>
114+
</PressableWithFeedback>
115+
))}
116+
</View>
117+
);
118+
}
119+
120+
function Screen3() {
121+
return (
122+
<ScrollView
123+
contentContainerStyle={{
124+
gap: 20,
125+
paddingHorizontal: 30,
126+
paddingVertical: 10,
127+
}}>
128+
{[...Array(30).keys()].map(index => (
129+
<PressableWithFeedback
130+
key={index + 1}
131+
onPress={() => console.log(`Pressed #${index + 1}`)}
132+
style={{
133+
paddingVertical: 10,
134+
paddingHorizontal: 20,
135+
}}>
136+
<Text>Pressable #{index + 1}</Text>
137+
</PressableWithFeedback>
138+
))}
139+
</ScrollView>
140+
);
141+
}
142+
143+
function Screen4() {
144+
return (
145+
<ScrollView
146+
contentInsetAdjustmentBehavior="automatic"
147+
contentContainerStyle={{
148+
gap: 20,
149+
paddingHorizontal: 30,
150+
paddingVertical: 10,
151+
}}>
152+
{[...Array(30).keys()].map(index => (
153+
<PressableWithFeedback
154+
key={index + 1}
155+
onPress={() => console.log(`Pressed #${index + 1}`)}
156+
style={{
157+
paddingVertical: 10,
158+
paddingHorizontal: 20,
159+
}}>
160+
<Text>Pressable #{index + 1}</Text>
161+
</PressableWithFeedback>
162+
))}
163+
</ScrollView>
164+
);
165+
}
166+
167+
export default function App() {
168+
return (
169+
<NavigationContainer>
170+
<Stack.Navigator
171+
screenOptions={{
172+
headerRight: () => (
173+
<PressableWithFeedback
174+
onPress={() => console.log('Pressed headerRight')}>
175+
<Text>Pressable</Text>
176+
</PressableWithFeedback>
177+
),
178+
}}>
179+
<Stack.Screen name="Main" component={Screen1} />
180+
<Stack.Screen
181+
name="PushWithoutHeader"
182+
component={Screen2}
183+
options={{
184+
headerShown: false,
185+
}}
186+
/>
187+
<Stack.Screen
188+
name="PushWithTransparentHeader"
189+
component={Screen2}
190+
options={{
191+
headerTransparent: true,
192+
}}
193+
/>
194+
<Stack.Screen
195+
name="ModalWithView"
196+
component={Screen2}
197+
options={{
198+
presentation: 'modal',
199+
}}
200+
/>
201+
<Stack.Screen
202+
name="ModalWithViewAndNoHeader"
203+
component={Screen2}
204+
options={{
205+
presentation: 'modal',
206+
headerShown: false,
207+
}}
208+
/>
209+
<Stack.Screen
210+
name="ModalWithViewAndTransparentHeader"
211+
component={Screen2}
212+
options={{
213+
presentation: 'modal',
214+
headerTransparent: true,
215+
}}
216+
/>
217+
<Stack.Screen
218+
name="ModalWithScrollView"
219+
component={Screen3}
220+
options={{
221+
presentation: 'modal',
222+
}}
223+
/>
224+
<Stack.Screen
225+
name="ModalWithScrollViewAndNoHeader"
226+
component={Screen3}
227+
options={{
228+
presentation: 'modal',
229+
headerShown: false,
230+
}}
231+
/>
232+
<Stack.Screen
233+
name="ModalWithScrollViewAndTransparentHeader"
234+
component={Screen3}
235+
options={{
236+
presentation: 'modal',
237+
headerTransparent: true,
238+
}}
239+
/>
240+
<Stack.Screen
241+
name="ModalWithScrollViewBehaviorAutomatic"
242+
component={Screen4}
243+
options={{
244+
presentation: 'modal',
245+
}}
246+
/>
247+
<Stack.Screen
248+
name="ModalWithScrollViewBehaviorAutomaticAndNoHeader"
249+
component={Screen4}
250+
options={{
251+
presentation: 'modal',
252+
headerShown: false,
253+
}}
254+
/>
255+
<Stack.Screen
256+
name="ModalWithScrollViewBehaviorAutomaticAndTransparentHeader"
257+
component={Screen4}
258+
options={{
259+
presentation: 'modal',
260+
headerTransparent: true,
261+
}}
262+
/>
263+
</Stack.Navigator>
264+
</NavigationContainer>
265+
);
266+
}

apps/src/tests/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,7 @@ export { default as Test2963 } from './Test2963'; // [E2E created](iOS): issue r
144144
export { default as Test3004 } from './Test3004';
145145
export { default as Test3045 } from './Test3045';
146146
export { default as Test3093 } from './Test3093';
147+
export { default as Test3111 } from './Test3111';
147148
export { default as Test3115 } from './Test3115';
148149
export { default as TestScreenAnimation } from './TestScreenAnimation';
149150
export { default as TestScreenAnimationV5 } from './TestScreenAnimationV5';

ios/RNSScreenContentWrapper.mm

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,11 @@ + (void)load
189189
{
190190
[super load];
191191
}
192+
193+
+ (BOOL)shouldBeRecycled
194+
{
195+
return NO;
196+
}
192197
#endif // RCT_NEW_ARCH_ENABLED
193198

194199
@end

ios/RNSScreenStackHeaderConfig.mm

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,14 @@ @interface RNSScreenStackHeaderConfig () <RCTMountingTransactionObserving>
7171

7272
@implementation RNSScreenStackHeaderConfig {
7373
NSMutableArray<RNSScreenStackHeaderSubview *> *_reactSubviews;
74+
75+
// Workaround for UIKit edgesForExtendedLayout bug on iOS 26.
76+
// On iOS 26, there is additional offset for UINavigationBar that is not
77+
// accounted for when using edgesForExtendedLayout. That's why we additionaly
78+
// use safeAreaLayoutGuide when header is visible. When bug gets fixed, we can
79+
// get rid of all code related to this workaround.
80+
// More information: https://github.com/software-mansion/react-native-screens/pull/3111
81+
NSArray<NSLayoutConstraint *> *_safeAreaConstraints;
7482
#ifdef RCT_NEW_ARCH_ENABLED
7583
BOOL _initialPropsSet;
7684

@@ -106,6 +114,7 @@ - (instancetype)initWithFrame:(CGRect)frame
106114
_translucent = NO;
107115
_addedReactSubviewsInCurrentTransaction = false;
108116
_lastSendState = react::RNSScreenStackHeaderConfigState(react::Size{}, react::EdgeInsets{});
117+
_safeAreaConstraints = nil;
109118
[self initProps];
110119
}
111120
return self;
@@ -559,13 +568,44 @@ + (void)updateViewController:(UIViewController *)vc
559568
BOOL wasHidden = navctr.navigationBarHidden;
560569
BOOL shouldHide = config == nil || !config.shouldHeaderBeVisible;
561570

571+
// See comment above _safeAreaConstraints declaration for reason why this is necessary.
572+
RNSScreenContentWrapper *contentWrapper = nil;
573+
if (@available(iOS 26, *)) {
574+
if (vc.view.subviews.count > 0 && [vc.view.subviews[0] isKindOfClass:[RNSScreenContentWrapper class]]) {
575+
contentWrapper = static_cast<RNSScreenContentWrapper *>(vc.view.subviews[0]);
576+
}
577+
}
578+
562579
if (!shouldHide && !config.translucent) {
563580
// when nav bar is not translucent we change edgesForExtendedLayout to avoid system laying out
564581
// the screen underneath navigation controllers
565582
vc.edgesForExtendedLayout = UIRectEdgeAll - UIRectEdgeTop;
583+
584+
// See comment above _safeAreaConstraints declaration for reason why this is necessary.
585+
if (contentWrapper != nil) {
586+
// Use auto-layout
587+
contentWrapper.translatesAutoresizingMaskIntoConstraints = NO;
588+
589+
if (config->_safeAreaConstraints == nil) {
590+
config->_safeAreaConstraints = @[
591+
[contentWrapper.topAnchor constraintEqualToAnchor:vc.view.safeAreaLayoutGuide.topAnchor],
592+
[contentWrapper.bottomAnchor constraintEqualToAnchor:vc.view.bottomAnchor],
593+
[contentWrapper.leadingAnchor constraintEqualToAnchor:vc.view.leadingAnchor],
594+
[contentWrapper.trailingAnchor constraintEqualToAnchor:vc.view.trailingAnchor]
595+
];
596+
}
597+
[NSLayoutConstraint activateConstraints:config->_safeAreaConstraints];
598+
}
566599
} else {
567600
// system default is UIRectEdgeAll
568601
vc.edgesForExtendedLayout = UIRectEdgeAll;
602+
603+
// See comment above _safeAreaConstraints declaration for reason why this is necessary.
604+
if (contentWrapper != nil) {
605+
[NSLayoutConstraint deactivateConstraints:config->_safeAreaConstraints];
606+
config->_safeAreaConstraints = nil;
607+
contentWrapper.translatesAutoresizingMaskIntoConstraints = YES;
608+
}
569609
}
570610

571611
[navctr setNavigationBarHidden:shouldHide animated:animated];
@@ -959,6 +999,10 @@ - (void)prepareForRecycle
959999
[super prepareForRecycle];
9601000
_initialPropsSet = NO;
9611001

1002+
// See comment above _safeAreaConstraints declaration for reason why this is necessary.
1003+
[NSLayoutConstraint deactivateConstraints:_safeAreaConstraints];
1004+
_safeAreaConstraints = nil;
1005+
9621006
#ifdef RCT_NEW_ARCH_ENABLED
9631007
_lastSendState = react::RNSScreenStackHeaderConfigState(react::Size{}, react::EdgeInsets{});
9641008
#else

0 commit comments

Comments
 (0)