Skip to content

Commit 1608811

Browse files
authored
feat(Android, Stack v4): add disable*InsetApplication props (#4220)
## Description This PR adds per-edge control over header inset application on Android. Previously only the top inset could be opted out, via the already-existing `disableTopInsetApplication` prop. This PR introduces three new sibling props - `disableLeftInsetApplication`, `disableRightInsetApplication` and `disableBottomInsetApplication` — so that an app can disable inset handling on any edge of the native header. The top inset is a single shared space at the top of the screen, so in nested stacks it must be coordinated - only the topmost visible header consumes it (to avoid doubled spacing). The top inset is additionally anchored to the decor view in `applyDecorViewTopInsetIfNeeded()`, so it survives a custom `SafeAreaView` consuming the incoming `WindowInsets` somewhere in the subtree. Left/right/bottom are per-edge: each header applies them on its own edges, so there is nothing to coordinate, and — unlike the top — they have no decor-view fallback. They are read solely from the incoming `WindowInsets` and applied per-header, which is why a `SafeAreaView` in the subtree can consume them per-subtree. The context only propagates the opt-out down the subtree (set the prop once on the outer stack, inner headers inherit it). Closes software-mansion/react-native-screens-labs#1535 ## Changes - Added public props `disableLeftInsetApplication`, `disableRightInsetApplication` and `disableBottomInsetApplication` to `ScreenStackHeaderConfigProps` (`src/types.tsx`) - Added `consumeLeftInset` / `consumeRightInset` / `consumeBottomInset` to the native component spec (`ScreenStackHeaderConfigNativeComponent.ts`) and the corresponding setters on the Android view manager / config (`ScreenStackHeaderConfigViewManager.kt`, `ScreenStackHeaderConfig.kt`) - Introduced `EdgeInsetApplicationContext` carrying the left/right/bottom opt-out down the subtree, and wired the providers in `ScreenStackItem.tsx` and the consumer in `ScreenStackHeaderConfig.tsx` - Reworked the inset computation in `CustomToolbar.kt`: each edge (top/left/right/bottom) is now evaluated independently - Added an issue test ## After - visual documentation https://github.com/user-attachments/assets/1f747d3b-00d2-46f1-9058-b0bce3f02d61 https://github.com/user-attachments/assets/279e1b58-429d-4183-947a-ffa68e4129bb https://github.com/user-attachments/assets/5fee3e50-d519-4fc1-b694-9b0465ea28a0 https://github.com/user-attachments/assets/26b33fea-5486-4e82-9f8b-01c39ee8c9e9 ## Test plan Run the 4220 issue test on different devices and check if insets are disabled correctly. ## Checklist - [x] Included code example that can be used to test this change. - [x] For visual changes, included screenshots / GIFs / recordings documenting the change. - [ ] For API changes, updated relevant public types. - [ ] Ensured that CI passes
1 parent 1aa22f2 commit 1608811

11 files changed

Lines changed: 439 additions & 60 deletions

File tree

android/src/main/java/com/swmansion/rnscreens/CustomToolbar.kt

Lines changed: 29 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -146,35 +146,42 @@ open class CustomToolbar(
146146
val systemBarInsets =
147147
resolveInsetsOrZero(WindowInsetsCompat.Type.systemBars(), unhandledInsets)
148148

149-
// This seems to work fine in all tested configurations, because cutout & system bars overlap
150-
// only in portrait mode & top inset is controlled separately, therefore we don't count
151-
// any insets twice.
152-
val horizontalInsets =
153-
InsetsCompat.of(
154-
cutoutInsets.left + systemBarInsets.left,
155-
0,
156-
cutoutInsets.right + systemBarInsets.right,
157-
0,
158-
)
159-
160149
val shouldHandleTopInset = if (config.legacyTopInsetBehavior) true else config.consumeTopInset
150+
val shouldHandleLeftInset = config.consumeLeftInset
151+
val shouldHandleRightInset = config.consumeRightInset
152+
val shouldHandleBottomInset = config.consumeBottomInset
153+
154+
// The top inset is coordinated across nested headers and anchored to the decor view,
155+
// while left/right/bottom are applied per-header from the incoming WindowInsets. See
156+
// https://github.com/software-mansion/react-native-screens/pull/4220 for the rationale.
161157

162-
if (!shouldHandleTopInset) {
158+
// Each edge is controlled independently. If no edge should be handled, we reset
159+
// everything and bail out early.
160+
if (!shouldHandleTopInset && !shouldHandleLeftInset && !shouldHandleRightInset && !shouldHandleBottomInset) {
163161
resetInsetsState()
164162
clearPaddingIfNeeded()
165163
return unhandledInsets
166164
}
167165

168-
// We want to handle display cutout always, no matter the HeaderConfig prop values.
169-
// If there are no cutout displays, we want to apply the additional padding to
170-
// respect the status bar.
171-
val verticalInsets =
172-
InsetsCompat.of(
173-
0,
174-
max(cutoutInsets.top, if (shouldApplyTopInset) systemBarInsets.top else 0),
175-
0,
176-
max(cutoutInsets.bottom, 0),
177-
)
166+
// This seems to work fine in all tested configurations, because cutout & system bars overlap
167+
// only in portrait mode & top inset is controlled separately, therefore we don't count
168+
// any insets twice.
169+
val leftInset = if (shouldHandleLeftInset) cutoutInsets.left + systemBarInsets.left else 0
170+
val rightInset = if (shouldHandleRightInset) cutoutInsets.right + systemBarInsets.right else 0
171+
val horizontalInsets = InsetsCompat.of(leftInset, 0, rightInset, 0)
172+
173+
// We want to handle display cutout, no matter the HeaderConfig prop values, as long as the
174+
// respective edge is not opted out. If there are no cutout displays, we want to apply the
175+
// additional top padding to respect the status bar. Top and bottom are controlled
176+
// independently so that disabling one does not silently drop the other.
177+
val topInset =
178+
if (shouldHandleTopInset) {
179+
max(cutoutInsets.top, if (shouldApplyTopInset) systemBarInsets.top else 0)
180+
} else {
181+
0
182+
}
183+
val bottomInset = if (shouldHandleBottomInset) cutoutInsets.bottom else 0
184+
val verticalInsets = InsetsCompat.of(0, topInset, 0, bottomInset)
178185

179186
val newInsets = InsetsCompat.add(horizontalInsets, verticalInsets)
180187

android/src/main/java/com/swmansion/rnscreens/ScreenStackHeaderConfig.kt

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,24 @@ class ScreenStackHeaderConfig(
4343
}
4444
}
4545

46+
var consumeLeftInset by Delegates.observable(true) { _, oldValue, newValue ->
47+
if (oldValue != newValue && isAttachedToWindow) {
48+
toolbar.requestApplyInsets()
49+
}
50+
}
51+
52+
var consumeRightInset by Delegates.observable(true) { _, oldValue, newValue ->
53+
if (oldValue != newValue && isAttachedToWindow) {
54+
toolbar.requestApplyInsets()
55+
}
56+
}
57+
58+
var consumeBottomInset by Delegates.observable(true) { _, oldValue, newValue ->
59+
if (oldValue != newValue && isAttachedToWindow) {
60+
toolbar.requestApplyInsets()
61+
}
62+
}
63+
4664
var legacyTopInsetBehavior by Delegates.observable(false) { _, oldValue, newValue ->
4765
if (oldValue != newValue && isAttachedToWindow) {
4866
toolbar.requestApplyInsets()

android/src/main/java/com/swmansion/rnscreens/ScreenStackHeaderConfigViewManager.kt

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,27 @@ class ScreenStackHeaderConfigViewManager :
8989
config.consumeTopInset = consumeTopInset
9090
}
9191

92+
override fun setConsumeLeftInset(
93+
config: ScreenStackHeaderConfig,
94+
consumeLeftInset: Boolean,
95+
) {
96+
config.consumeLeftInset = consumeLeftInset
97+
}
98+
99+
override fun setConsumeRightInset(
100+
config: ScreenStackHeaderConfig,
101+
consumeRightInset: Boolean,
102+
) {
103+
config.consumeRightInset = consumeRightInset
104+
}
105+
106+
override fun setConsumeBottomInset(
107+
config: ScreenStackHeaderConfig,
108+
consumeBottomInset: Boolean,
109+
) {
110+
config.consumeBottomInset = consumeBottomInset
111+
}
112+
92113
override fun setLegacyTopInsetBehavior(
93114
config: ScreenStackHeaderConfig,
94115
legacyTopInsetBehavior: Boolean,
Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
1+
import React, { useState } from 'react';
2+
import {
3+
View,
4+
Text,
5+
Button,
6+
StyleSheet,
7+
Alert,
8+
Pressable,
9+
Switch,
10+
Platform,
11+
ScrollView,
12+
} from 'react-native';
13+
import {
14+
ScreenStack,
15+
ScreenStackItem,
16+
ScreenStackHeaderLeftView,
17+
ScreenStackHeaderRightView,
18+
} from 'react-native-screens';
19+
import { SafeAreaProvider, SafeAreaView } from 'react-native-safe-area-context';
20+
21+
interface Config {
22+
disableTop: boolean;
23+
disableLeft: boolean;
24+
disableRight: boolean;
25+
disableBottom: boolean;
26+
}
27+
28+
const INITIAL_CONFIG: Config = {
29+
disableTop: false,
30+
disableLeft: false,
31+
disableRight: false,
32+
disableBottom: false,
33+
};
34+
35+
export function ControlPanel({
36+
config,
37+
setConfig,
38+
}: {
39+
config: Config;
40+
setConfig: React.Dispatch<React.SetStateAction<Config>>;
41+
}) {
42+
const rows: { key: keyof Config; label: string }[] = [
43+
{ key: 'disableTop', label: 'disableTopInsetApplication' },
44+
{ key: 'disableLeft', label: 'disableLeftInsetApplication' },
45+
{ key: 'disableRight', label: 'disableRightInsetApplication' },
46+
{ key: 'disableBottom', label: 'disableBottomInsetApplication' },
47+
];
48+
49+
return (
50+
<ScrollView
51+
style={styles.panel}
52+
contentContainerStyle={styles.panelContent}>
53+
<Text style={styles.text}>Details Screen</Text>
54+
<Text style={styles.subtext}>
55+
Native back button + Action button above. This header covers the Home
56+
header (only one is visible at a time).
57+
</Text>
58+
59+
{rows.map(({ key, label }) => (
60+
<View key={key} style={styles.row}>
61+
<Text style={styles.rowLabel}>{label}</Text>
62+
<Switch
63+
value={config[key]}
64+
onValueChange={value =>
65+
setConfig(prev => ({ ...prev, [key]: value }))
66+
}
67+
/>
68+
</View>
69+
))}
70+
71+
{Platform.OS !== 'android' && (
72+
<Text style={styles.warning}>
73+
These props only take effect on Android.
74+
</Text>
75+
)}
76+
</ScrollView>
77+
);
78+
}
79+
80+
export function TestContent() {
81+
const [config, setConfig] = useState<Config>(INITIAL_CONFIG);
82+
const [showDetails, setShowDetails] = useState(false);
83+
84+
const headerInsetProps = {
85+
disableTopInsetApplication: config.disableTop,
86+
disableLeftInsetApplication: config.disableLeft,
87+
disableRightInsetApplication: config.disableRight,
88+
disableBottomInsetApplication: config.disableBottom,
89+
};
90+
91+
return (
92+
<SafeAreaView style={styles.safeArea}>
93+
<ScreenStack style={styles.container}>
94+
<ScreenStackItem
95+
screenId="home"
96+
headerConfig={{
97+
title: 'My App',
98+
backgroundColor: '#f4511e',
99+
color: '#fff',
100+
...headerInsetProps,
101+
}}>
102+
<View style={styles.screen}>
103+
<Text style={styles.text}>Home Screen</Text>
104+
<Button
105+
title="Go to Details"
106+
onPress={() => setShowDetails(true)}
107+
/>
108+
</View>
109+
</ScreenStackItem>
110+
111+
{showDetails && (
112+
<ScreenStackItem
113+
screenId="details"
114+
headerConfig={{
115+
title: 'Details',
116+
backgroundColor: '#f4511e',
117+
color: '#fff',
118+
...headerInsetProps,
119+
children: (
120+
<>
121+
<ScreenStackHeaderLeftView>
122+
<Pressable
123+
onPress={() => setShowDetails(false)}
124+
style={styles.backButton}>
125+
<Text style={styles.backArrow}></Text>
126+
<Text style={styles.backLabel}>Back</Text>
127+
</Pressable>
128+
</ScreenStackHeaderLeftView>
129+
<ScreenStackHeaderRightView>
130+
<Button
131+
onPress={() => Alert.alert('Action triggered!')}
132+
title="Action"
133+
color="#fff"
134+
/>
135+
</ScreenStackHeaderRightView>
136+
</>
137+
),
138+
}}>
139+
<View style={styles.detailsScreen}>
140+
<ControlPanel config={config} setConfig={setConfig} />
141+
</View>
142+
</ScreenStackItem>
143+
)}
144+
</ScreenStack>
145+
</SafeAreaView>
146+
);
147+
}
148+
149+
export default function App() {
150+
return (
151+
<SafeAreaProvider>
152+
<TestContent />
153+
</SafeAreaProvider>
154+
);
155+
}
156+
157+
const styles = StyleSheet.create({
158+
safeArea: {
159+
flex: 1,
160+
padding: 10,
161+
},
162+
container: {
163+
flex: 1,
164+
},
165+
screen: {
166+
flex: 1,
167+
alignItems: 'center',
168+
justifyContent: 'center',
169+
backgroundColor: '#ffffff',
170+
},
171+
detailsScreen: {
172+
flex: 1,
173+
backgroundColor: '#e0f7fa',
174+
},
175+
panel: {
176+
flex: 1,
177+
},
178+
panelContent: {
179+
padding: 20,
180+
},
181+
text: {
182+
fontSize: 18,
183+
fontWeight: 'bold',
184+
marginBottom: 10,
185+
},
186+
subtext: {
187+
fontSize: 14,
188+
color: '#666',
189+
marginBottom: 16,
190+
},
191+
row: {
192+
flexDirection: 'row',
193+
justifyContent: 'space-between',
194+
alignItems: 'center',
195+
marginVertical: 8,
196+
},
197+
rowLabel: {
198+
fontSize: 15,
199+
flexShrink: 1,
200+
marginRight: 12,
201+
},
202+
backButton: {
203+
flexDirection: 'row',
204+
alignItems: 'center',
205+
marginRight: 15,
206+
},
207+
backArrow: {
208+
color: '#fff',
209+
fontSize: 24,
210+
marginRight: 5,
211+
},
212+
backLabel: {
213+
color: '#fff',
214+
fontSize: 16,
215+
},
216+
warning: {
217+
marginTop: 16,
218+
fontSize: 13,
219+
color: '#b00',
220+
},
221+
});

apps/src/tests/issue-tests/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,7 @@ export { default as Test4064 } from './Test4064';
197197
export { default as Test4090 } from './Test4090';
198198
export { default as Test4155 } from './Test4155';
199199
export { default as Test4161 } from './Test4161';
200+
export { default as Test4220 } from './Test4220';
200201
export { default as TestScreenAnimation } from './TestScreenAnimation';
201202
// The following test was meant to demo the "go back" gesture using Reanimated
202203
// but the associated PR in react-navigation is currently put on hold

src/components/ScreenStackHeaderConfig.tsx

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ import ScreenStackHeaderSubviewNativeComponent, {
2525
} from '../fabric/ScreenStackHeaderSubviewNativeComponent';
2626
import { prepareHeaderBarButtonItems } from './helpers/prepareHeaderBarButtonItems';
2727
import { isHeaderBarButtonsAvailableForCurrentPlatform } from '../utils';
28-
import { useTopInsetApplication } from './contexts/TopInsetApplicationContext';
28+
import { useEdgeInsetApplication } from './contexts/EdgeInsetApplicationContext';
2929

3030
export const ScreenStackHeaderSubview: React.ComponentType<ScreenStackHeaderSubviewNativeProps> =
3131
ScreenStackHeaderSubviewNativeComponent;
@@ -34,9 +34,18 @@ export const ScreenStackHeaderConfig = React.forwardRef<
3434
View,
3535
ScreenStackHeaderConfigProps
3636
>((props, ref) => {
37-
const { appliesTopInset, useLegacyBehavior } = useTopInsetApplication(
37+
const {
38+
appliesTopInset,
39+
useLegacyBehavior,
40+
consumeLeftInset,
41+
consumeRightInset,
42+
consumeBottomInset,
43+
} = useEdgeInsetApplication(
3844
!props.hidden,
3945
props.disableTopInsetApplication ?? false,
46+
props.disableLeftInsetApplication ?? false,
47+
props.disableRightInsetApplication ?? false,
48+
props.disableBottomInsetApplication ?? false,
4049
);
4150

4251
const { headerLeftBarButtonItems, headerRightBarButtonItems } = props;
@@ -130,6 +139,9 @@ export const ScreenStackHeaderConfig = React.forwardRef<
130139
featureFlags.experiment.synchronousHeaderConfigUpdatesEnabled
131140
}
132141
consumeTopInset={appliesTopInset}
142+
consumeLeftInset={consumeLeftInset}
143+
consumeRightInset={consumeRightInset}
144+
consumeBottomInset={consumeBottomInset}
133145
legacyTopInsetBehavior={useLegacyBehavior}
134146
/>
135147
);

0 commit comments

Comments
 (0)