Skip to content

Commit 31cb1e6

Browse files
authored
feat(Android, Stack v4): Add prop for manual opt-out from applying inset to header (#3835)
## Description Add a prop for ignoring the potential whitespace caused by the status bar top inset being applied when stack navigators are nested on Android. `disableTopInsetApplication` prop was introduced on `ScreenStackHeaderConfigProps` to opt out from applying insets in a whole subtree, starting from the topmost stack with a visible header. Closes: software-mansion/react-native-screens-labs#1096 ## Changes - added `disableTopInsetApplication` prop ## Before & after - visual documentation | Before | After | | --- | --- | | <video src="https://github.com/user-attachments/assets/b2a81ed6-bbef-4cd2-a093-610db2fe1e8f" /> | <video src="https://github.com/user-attachments/assets/4ebe6b30-f42a-4852-a73f-66b38729019e" /> | ## Test plan Test3835 requires changes in react-navigation `native-stack/src/types.tsx` in `NativeStackNavigationOptions` ```tsx /** * ... */ disableTopInsetApplication?: boolean; ``` `native-stack/src/views/useHeaderConfigProps.tsx` ```tsx export function useHeaderConfigProps({ ... + disableTopInsetApplication, ... return { backButtonInCustomView, ... + disableTopInsetApplication ... ``` ## Checklist - [x] Included code example that can be used to test this change. - [x] For visual changes, included screenshots / GIFs / recordings documenting the change. - [x] For API changes, updated relevant public types. - [ ] Ensured that CI passes
1 parent c231ff0 commit 31cb1e6

8 files changed

Lines changed: 264 additions & 38 deletions

File tree

Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
import React, { createContext, ReactNode, useContext, useState } from 'react';
2+
import { View, Text, Switch, StyleSheet } from 'react-native';
3+
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
4+
import { createNativeStackNavigator } from '@react-navigation/native-stack';
5+
import { NavigationContainer } from '@react-navigation/native';
6+
7+
interface HeaderConfigContextType {
8+
tabShown: boolean;
9+
setTabShown: React.Dispatch<React.SetStateAction<boolean>>;
10+
outerShown: boolean;
11+
setOuterShown: React.Dispatch<React.SetStateAction<boolean>>;
12+
innerShown: boolean;
13+
setInnerShown: React.Dispatch<React.SetStateAction<boolean>>;
14+
disableInset: boolean;
15+
setDisableInset: React.Dispatch<React.SetStateAction<boolean>>;
16+
}
17+
18+
const HeaderConfigContext = createContext<HeaderConfigContextType | null>(null);
19+
20+
const useHeaderConfig = () => {
21+
const context = useContext(HeaderConfigContext);
22+
if (!context) {
23+
throw new Error(
24+
'useHeaderConfig must be used within a HeaderConfigProvider',
25+
);
26+
}
27+
return context;
28+
};
29+
30+
interface HeaderConfigProviderProps {
31+
children: ReactNode;
32+
}
33+
34+
const HeaderConfigProvider = ({ children }: HeaderConfigProviderProps) => {
35+
const [tabShown, setTabShown] = useState(true);
36+
const [outerShown, setOuterShown] = useState(true);
37+
const [innerShown, setInnerShown] = useState(true);
38+
const [disableInset, setDisableInset] = useState(true);
39+
40+
return (
41+
<HeaderConfigContext.Provider
42+
value={{
43+
tabShown,
44+
setTabShown,
45+
outerShown,
46+
setOuterShown,
47+
innerShown,
48+
setInnerShown,
49+
disableInset,
50+
setDisableInset,
51+
}}>
52+
{children}
53+
</HeaderConfigContext.Provider>
54+
);
55+
};
56+
57+
const ControlPanel = () => {
58+
const config = useHeaderConfig();
59+
60+
return (
61+
<View style={styles.panel}>
62+
<Text style={styles.panelTitle}>Headers Config</Text>
63+
64+
<View style={styles.row}>
65+
<Text style={styles.text}>Tab Navigator</Text>
66+
<Switch value={config.tabShown} onValueChange={config.setTabShown} />
67+
</View>
68+
<View style={styles.divider} />
69+
70+
<View style={styles.row}>
71+
<Text style={styles.text}>Outer Stack</Text>
72+
<Switch
73+
value={config.outerShown}
74+
onValueChange={config.setOuterShown}
75+
/>
76+
</View>
77+
<View style={styles.divider} />
78+
79+
<View style={styles.row}>
80+
<Text style={styles.text}>Inner Stack</Text>
81+
<Switch
82+
value={config.innerShown}
83+
onValueChange={config.setInnerShown}
84+
/>
85+
</View>
86+
<View style={styles.divider} />
87+
88+
<View style={styles.row}>
89+
<Text style={styles.text}>Disable Top Inset</Text>
90+
<Switch
91+
value={config.disableInset}
92+
onValueChange={config.setDisableInset}
93+
/>
94+
</View>
95+
</View>
96+
);
97+
};
98+
99+
const Home = () => (
100+
<View style={styles.container}>
101+
<Text style={styles.screenTitle}>Home Screen</Text>
102+
<ControlPanel />
103+
</View>
104+
);
105+
106+
const OuterStack = createNativeStackNavigator();
107+
const InnerStack = createNativeStackNavigator();
108+
const Tab = createBottomTabNavigator();
109+
110+
const NestedStack = () => {
111+
const config = useHeaderConfig();
112+
113+
return (
114+
<InnerStack.Navigator
115+
screenOptions={{
116+
title: 'Inner Stack',
117+
headerShown: config.innerShown,
118+
disableTopInsetApplication: config.disableInset,
119+
}}>
120+
<InnerStack.Screen name="Home" component={Home} />
121+
</InnerStack.Navigator>
122+
);
123+
};
124+
125+
const MainStack = () => {
126+
const config = useHeaderConfig();
127+
128+
return (
129+
<OuterStack.Navigator
130+
screenOptions={{
131+
title: 'Outer Stack',
132+
headerShown: config.outerShown,
133+
disableTopInsetApplication: config.disableInset,
134+
}}>
135+
<OuterStack.Screen name="NestedStack" component={NestedStack} />
136+
</OuterStack.Navigator>
137+
);
138+
};
139+
140+
const AppContent = () => {
141+
const config = useHeaderConfig();
142+
143+
return (
144+
<NavigationContainer>
145+
<Tab.Navigator
146+
screenOptions={{
147+
title: 'Tab Nav',
148+
headerShown: config.tabShown,
149+
}}>
150+
<Tab.Screen name="Tab" component={MainStack} />
151+
</Tab.Navigator>
152+
</NavigationContainer>
153+
);
154+
};
155+
156+
export default function App() {
157+
return (
158+
<HeaderConfigProvider>
159+
<AppContent />
160+
</HeaderConfigProvider>
161+
);
162+
}
163+
164+
const styles = StyleSheet.create({
165+
container: {
166+
flex: 1,
167+
alignItems: 'center',
168+
justifyContent: 'center',
169+
},
170+
screenTitle: {
171+
fontSize: 24,
172+
fontWeight: 'bold',
173+
marginBottom: 20,
174+
},
175+
panel: {
176+
backgroundColor: '#FFF',
177+
padding: 20,
178+
width: '100%',
179+
maxWidth: 350,
180+
},
181+
panelTitle: {
182+
fontSize: 18,
183+
fontWeight: 'bold',
184+
marginBottom: 15,
185+
},
186+
row: {
187+
flexDirection: 'row',
188+
justifyContent: 'space-between',
189+
alignItems: 'center',
190+
marginVertical: 5,
191+
},
192+
text: {
193+
fontSize: 16,
194+
},
195+
divider: {
196+
height: 1,
197+
marginVertical: 10,
198+
},
199+
});

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,7 @@ export { default as Test3770 } from './Test3770';
187187
export { default as Test3793 } from './Test3793';
188188
export { default as Test3816 } from './Test3816';
189189
export { default as Test3833 } from './Test3833';
190+
export { default as Test3835 } from './Test3835';
190191
export { default as TestScreenAnimation } from './TestScreenAnimation';
191192
// The following test was meant to demo the "go back" gesture using Reanimated
192193
// but the associated PR in react-navigation is currently put on hold

guides/GUIDE_FOR_LIBRARY_AUTHORS.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -811,6 +811,14 @@ This prop has been **deprecated** due to [edge-to-edge enforcement starting from
811811

812812
A flag to that lets you opt out of insetting the header. You may want to set this to `false` if you use an opaque status bar. Defaults to `true`.
813813

814+
### `disableTopInsetApplication` (Android only)
815+
816+
When set to `true` on the outermost stack with a **visible** header, disables top inset handling for that header and the entire subtree.
817+
818+
This prop only takes effect on the outermost visible header in the hierarchy. Setting it on an inner stack has no additional impact because a parent stack has already made the decision (whether inset should be applied or not).
819+
820+
Has no effect when `androidLegacyTopInsetBehavior` feature flag is enabled.
821+
814822
### `translucent`
815823

816824
When set to true, it makes native navigation bar semi transparent. It adds blur effect on iOS. The default value is false.

src/components/ScreenStackHeaderConfig.tsx

Lines changed: 4 additions & 3 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 { useTopInsetConsumption } from './contexts/TopInsetConsumptionContext';
28+
import { useTopInsetApplication } from './contexts/TopInsetApplicationContext';
2929

3030
export const ScreenStackHeaderSubview: React.ComponentType<ScreenStackHeaderSubviewNativeProps> =
3131
ScreenStackHeaderSubviewNativeComponent;
@@ -34,8 +34,9 @@ export const ScreenStackHeaderConfig = React.forwardRef<
3434
View,
3535
ScreenStackHeaderConfigProps
3636
>((props, ref) => {
37-
const { consumesTopInset, useLegacyBehavior } = useTopInsetConsumption(
37+
const { appliesTopInset, useLegacyBehavior } = useTopInsetApplication(
3838
!props.hidden,
39+
props.disableTopInsetApplication ?? false,
3940
);
4041

4142
const { headerLeftBarButtonItems, headerRightBarButtonItems } = props;
@@ -128,7 +129,7 @@ export const ScreenStackHeaderConfig = React.forwardRef<
128129
synchronousShadowStateUpdatesEnabled={
129130
featureFlags.experiment.synchronousHeaderConfigUpdatesEnabled
130131
}
131-
consumeTopInset={consumesTopInset}
132+
consumeTopInset={appliesTopInset}
132133
legacyTopInsetBehavior={useLegacyBehavior}
133134
/>
134135
);

src/components/ScreenStackItem.tsx

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,9 @@ import { SafeAreaView } from './safe-area/SafeAreaView';
2424
import { featureFlags } from '../flags';
2525
import { isIOS26OrHigher } from './helpers/PlatformUtils';
2626
import {
27-
TopInsetConsumptionContext,
28-
useTopInsetConsumption,
29-
} from './contexts/TopInsetConsumptionContext';
27+
TopInsetApplicationContext,
28+
useTopInsetApplication,
29+
} from './contexts/TopInsetApplicationContext';
3030

3131
type Props = Omit<
3232
ScreenProps,
@@ -56,7 +56,13 @@ function ScreenStackItem(
5656
}: Props,
5757
ref: React.ForwardedRef<View>,
5858
) {
59-
const { nextContextValue } = useTopInsetConsumption(!headerConfig?.hidden);
59+
const headerVisible = !headerConfig?.hidden;
60+
const headerTopInsetDisabled =
61+
headerConfig?.disableTopInsetApplication ?? false;
62+
const { nextContextValue } = useTopInsetApplication(
63+
headerVisible,
64+
headerTopInsetDisabled,
65+
);
6066

6167
const currentScreenRef = React.useRef<View | null>(null);
6268
const screenRefs = React.useContext(RNSScreensRefContext);
@@ -121,7 +127,7 @@ function ScreenStackItem(
121127

122128
const content = (
123129
<>
124-
<TopInsetConsumptionContext.Provider value={nextContextValue}>
130+
<TopInsetApplicationContext.Provider value={nextContextValue}>
125131
<DebugContainer
126132
contentStyle={contentStyle}
127133
style={debugContainerStyle}
@@ -134,7 +140,7 @@ function ScreenStackItem(
134140
children
135141
)}
136142
</DebugContainer>
137-
</TopInsetConsumptionContext.Provider>
143+
</TopInsetApplicationContext.Provider>
138144
{/**
139145
* `HeaderConfig` needs to be the direct child of `Screen` without any intermediate `View`
140146
* We don't render it conditionally based on visibility to make it possible to dynamically render a custom `header`
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import * as React from 'react';
2+
import { featureFlags } from '../../flags';
3+
4+
export const TopInsetApplicationContext = React.createContext(false);
5+
6+
export function useTopInsetApplication(
7+
headerVisible: boolean,
8+
disableTopInsetApplication: boolean,
9+
) {
10+
const alreadyApplied = React.useContext(TopInsetApplicationContext);
11+
const useLegacyBehavior =
12+
featureFlags.experiment?.androidLegacyTopInsetBehavior ?? false;
13+
14+
// We want to apply the inset if:
15+
// - legacy mode (androidLegacyTopInsetBehavior)
16+
// - or when no ancestor applied it yet and our header is visible
17+
const wantsToApplyInset =
18+
useLegacyBehavior || (!alreadyApplied && headerVisible);
19+
20+
// We apply the padding, only if we want to apply the inset and haven't been told to suppress it
21+
const appliesTopInset = wantsToApplyInset && !disableTopInsetApplication;
22+
23+
// Once found header that may apply the padding, mark it as consumed for the subtree
24+
const nextContextValue = alreadyApplied || wantsToApplyInset;
25+
26+
return { appliesTopInset, useLegacyBehavior, nextContextValue };
27+
}

src/components/contexts/TopInsetConsumptionContext.ts

Lines changed: 0 additions & 29 deletions
This file was deleted.

src/types.tsx

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -811,6 +811,19 @@ export interface ScreenStackHeaderConfigProps extends ViewProps {
811811
* therefore this prop loses its relevance.
812812
*/
813813
topInsetEnabled?: boolean;
814+
/**
815+
* When set to `true` on the outermost stack with a **visible** header, disables top inset
816+
* handling for that header and the entire subtree.
817+
*
818+
* This prop only takes effect on the outermost visible header in the hierarchy.
819+
* Setting it on an inner stack has no additional impact because a parent stack
820+
* has already made the decision (whether inset should be applied or not).
821+
*
822+
* Has no effect when `androidLegacyTopInsetBehavior` feature flag is enabled.
823+
*
824+
* @platform android
825+
*/
826+
disableTopInsetApplication?: boolean;
814827
/**
815828
* Boolean indicating whether the navigation bar is translucent.
816829
*/

0 commit comments

Comments
 (0)