Skip to content

Commit 47e45a6

Browse files
authored
fix(iOS, Stack): Deduplicate contentStyle on Screen and ScreenContentWrapper (#3228)
## Description This PR addresses an issue with the support for LiquidGlass FormSheets on iOS, as detailed in this blog post https://nilcoalescing.com/blog/LiquidGlassSheetsWithNavigationStackAndForm/. Previously, the implementation of the `contentStyle` option led to the background color overlapping. Specifically, with the low opacity styles were duplicated for `Screen` and `ScreenContentWrapper`, which caused visual issues in the content area. To resolve this, I’ve separated the handling of `contentStyle` properties on iOS, which requires applying background color at the level of `Screen` component due to the safe area insets: - The `backgroundColor` is now applied **only** at the RNSScreen level to achieve the desired LiquidGlass translucent effect without interfering with content rendering. - The remaining `contentStyle` properties are applied under the `ScreenContentWrapper`, which is semantically more appropriate. This results in a more accurate and visually correct rendering of translucent backgrounds on iOS FormSheets using LiquidGlass. ## Changes - Separated `contentStyle` to props that should be applied on the `Screen` and `ScreenContentWrapper` level. ## Screenshots / GIFs Here you can add screenshots / GIFs documenting your change. You can add before / after section if you're changing some behavior. ### Before https://github.com/user-attachments/assets/7b65fa0f-e478-44d0-a6b6-eb8f60f8461a ### After https://github.com/user-attachments/assets/c6ec1d34-1459-435b-ac16-886ef9d4a6c8 ## Test code and steps to reproduce Added a new example to `TestFormSheet` ## Checklist - [x] Included code example that can be used to test this change - [x] Ensured that CI passes I'm leaving some follow-up issues and ideas that came during the implementation of this PR. More important: - software-mansion/react-native-screens-labs#452 - it makes more sense to apply some other styles on the `Screen` level due to the safe area on iOS - like borders or filters - we should revisit it and define how the props should be split between `Screen` and `ScreenContentWrapper` - software-mansion/react-native-screens-labs#441 - setting fixed size based on the maximum detent would have 2 significant benefits: we'd have the same model for both Android and iOS and we'd resolve the problem of flickering content on resize without synchronous updates - it has a potential, tested along with this PR, but we're suffering from the lack of API on iOS 15 or lower - APIs for resolving detens is available from iOS 16, what is currently a blocker for us, because we cannot predict the target height for `large` detent, using system detents Less important: - software-mansion/react-native-screens-labs#457 - bug related to the safe area combined with maximum detent on android - software-mansion/react-native-screens-labs#458 - bug related to positioning the content with `flex-end` on android
1 parent e6b63ae commit 47e45a6

File tree

3 files changed

+126
-33
lines changed

3 files changed

+126
-33
lines changed

apps/src/tests/TestFormSheet.tsx

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { NativeStackNavigationProp, createNativeStackNavigator } from '@react-na
33
import React from 'react';
44
import { Button, FlatList, ScrollView, Text, TextInput, View } from 'react-native';
55
import PressableWithFeedback from '../shared/PressableWithFeedback';
6+
import { Spacer } from '../shared';
67

78
type ItemData = {
89
id: number,
@@ -16,6 +17,7 @@ type RouteParamList = {
1617
SecondFormSheet: undefined;
1718
FormSheetWithFlatList: undefined;
1819
FormSheetWithScrollView: undefined;
20+
GlossyFormSheet: undefined;
1921
};
2022

2123
type RouteProps<RouteName extends keyof RouteParamList> = {
@@ -36,6 +38,7 @@ function Home({ navigation }: RouteProps<'Home'>) {
3638
<Button title="Open Second" onPress={() => navigation.navigate('Second')} />
3739
<Button title="Open sheet with FlatList" onPress={() => navigation.navigate('FormSheetWithFlatList')} />
3840
<Button title="Open sheet with ScrollView" onPress={() => navigation.navigate('FormSheetWithScrollView')} />
41+
<Button title="Open glossy form sheet" onPress={() => navigation.navigate('GlossyFormSheet')} />
3942
<PressableWithFeedback>
4043
<View style={{ alignItems: 'center', height: 40, justifyContent: 'center' }}>
4144
<Text>Pressable</Text>
@@ -151,6 +154,28 @@ function FormSheetWithScrollView() {
151154
);
152155
}
153156

157+
function GlossyFormSheet({ navigation }: RouteProps<'GlossyFormSheet'>) {
158+
return (
159+
// When using `fitToContents` you can't use flex: 1. It is you who must provide
160+
// the content size - you can't rely on parent size here.
161+
<View>
162+
<Spacer space={50} />
163+
<PressableWithFeedback>
164+
<View
165+
style={{
166+
alignItems: 'center',
167+
height: 40,
168+
justifyContent: 'center',
169+
}}>
170+
<Text>Pressable</Text>
171+
</View>
172+
</PressableWithFeedback>
173+
<Spacer space={50} />
174+
<Button title="Go back" onPress={() => navigation.goBack()} />
175+
</View>
176+
);
177+
}
178+
154179
// @ts-ignore // uncomment the usage down below if needed
155180
// eslint-disable-next-line @typescript-eslint/no-unused-vars
156181
function FormSheetFooter() {
@@ -212,6 +237,15 @@ export default function App() {
212237
backgroundColor: 'lightblue',
213238
},
214239
}} />
240+
<Stack.Screen name='GlossyFormSheet' component={GlossyFormSheet} options={{
241+
presentation: 'formSheet',
242+
sheetAllowedDetents: [0.3, 0.5, 0.8],
243+
//sheetAllowedDetents: 'fitToContents',
244+
headerShown: false,
245+
contentStyle: {
246+
backgroundColor: '#ff00ff40',
247+
},
248+
}} />
215249
</Stack.Navigator>
216250
</NavigationContainer>
217251
);

src/components/DebugContainer.tsx

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
import * as React from 'react';
2-
import { Platform, type ViewProps } from 'react-native';
2+
import { Platform, StyleProp, ViewStyle, type ViewProps } from 'react-native';
33
// @ts-expect-error importing private component
44

55
import AppContainer from 'react-native/Libraries/ReactNative/AppContainer';
66
import ScreenContentWrapper from './ScreenContentWrapper';
77
import { StackPresentationTypes } from '../types';
88

99
type ContainerProps = ViewProps & {
10+
contentStyle?: StyleProp<ViewStyle>;
1011
stackPresentation: StackPresentationTypes;
1112
children: React.ReactNode;
1213
};
@@ -16,28 +17,32 @@ type ContainerProps = ViewProps & {
1617
* See https://github.com/software-mansion/react-native-screens/pull/1825
1718
* for detailed explanation.
1819
*/
19-
let DebugContainer: React.ComponentType<ContainerProps> = props => {
20-
return <ScreenContentWrapper {...props} />;
20+
let DebugContainer: React.FC<ContainerProps> = ({
21+
contentStyle,
22+
style,
23+
...rest
24+
}) => {
25+
return <ScreenContentWrapper style={[style, contentStyle]} {...rest} />;
2126
};
2227

2328
if (process.env.NODE_ENV !== 'production') {
2429
DebugContainer = (props: ContainerProps) => {
25-
const { stackPresentation, ...rest } = props;
30+
const { contentStyle, stackPresentation, style, ...rest } = props;
31+
32+
const content = (
33+
<ScreenContentWrapper style={[style, contentStyle]} {...rest} />
34+
);
2635

2736
if (
2837
Platform.OS === 'ios' &&
2938
stackPresentation !== 'push' &&
3039
stackPresentation !== 'formSheet'
3140
) {
3241
// This is necessary for LogBox
33-
return (
34-
<AppContainer>
35-
<ScreenContentWrapper {...rest} />
36-
</AppContainer>
37-
);
42+
return <AppContainer>{content}</AppContainer>;
3843
}
3944

40-
return <ScreenContentWrapper {...rest} />;
45+
return content;
4146
};
4247

4348
DebugContainer.displayName = 'DebugContainer';

src/components/ScreenStackItem.tsx

Lines changed: 77 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,11 @@ import {
99
import warnOnce from 'warn-once';
1010

1111
import DebugContainer from './DebugContainer';
12-
import { ScreenProps, ScreenStackHeaderConfigProps } from '../types';
12+
import {
13+
ScreenProps,
14+
ScreenStackHeaderConfigProps,
15+
StackPresentationTypes,
16+
} from '../types';
1317
import { ScreenStackHeaderConfig } from './ScreenStackHeaderConfig';
1418
import Screen from './Screen';
1519
import ScreenStack from './ScreenStack';
@@ -65,19 +69,31 @@ function ScreenStackItem(
6569
headerHiddenPreviousRef.current = headerConfig?.hidden;
6670
}, [headerConfig?.hidden, stackPresentation]);
6771

72+
const debugContainerStyle = getPositioningStyle(
73+
sheetAllowedDetents,
74+
stackPresentation,
75+
);
76+
77+
// For iOS, we need to extract background color and apply it to Screen
78+
// due to the safe area inset at the bottom of ScreenContentWrapper
79+
let internalScreenStyle;
80+
81+
if (
82+
stackPresentation === 'formSheet' &&
83+
Platform.OS === 'ios' &&
84+
contentStyle
85+
) {
86+
const { screenStyles, contentWrapperStyles } =
87+
extractScreenStyles(contentStyle);
88+
internalScreenStyle = screenStyles;
89+
contentStyle = contentWrapperStyles;
90+
}
91+
6892
const content = (
6993
<>
7094
<DebugContainer
71-
style={[
72-
stackPresentation === 'formSheet'
73-
? Platform.OS === 'ios'
74-
? styles.absolute
75-
: sheetAllowedDetents === 'fitToContents'
76-
? null
77-
: styles.container
78-
: styles.container,
79-
contentStyle,
80-
]}
95+
contentStyle={contentStyle}
96+
style={debugContainerStyle}
8197
stackPresentation={stackPresentation ?? 'push'}>
8298
{children}
8399
</DebugContainer>
@@ -100,18 +116,6 @@ function ScreenStackItem(
100116
</>
101117
);
102118

103-
// We take backgroundColor from contentStyle and apply it on Screen.
104-
// This allows to workaround one issue with truncated
105-
// content with formSheet presentation.
106-
let internalScreenStyle;
107-
108-
if (stackPresentation === 'formSheet' && contentStyle) {
109-
const flattenContentStyles = StyleSheet.flatten(contentStyle);
110-
internalScreenStyle = {
111-
backgroundColor: flattenContentStyles?.backgroundColor,
112-
};
113-
}
114-
115119
return (
116120
<Screen
117121
ref={node => {
@@ -164,6 +168,56 @@ function ScreenStackItem(
164168

165169
export default React.forwardRef(ScreenStackItem);
166170

171+
function getPositioningStyle(
172+
allowedDetents: ScreenProps['sheetAllowedDetents'],
173+
presentation?: StackPresentationTypes,
174+
) {
175+
const isIOS = Platform.OS === 'ios';
176+
177+
if (presentation !== 'formSheet') {
178+
return styles.container;
179+
}
180+
181+
if (isIOS) {
182+
if (allowedDetents === 'fitToContents') {
183+
return styles.absolute;
184+
} else {
185+
return styles.container;
186+
}
187+
}
188+
189+
// Other platforms, tested reliably only on Android
190+
if (allowedDetents === 'fitToContents') {
191+
return {};
192+
}
193+
194+
return styles.container;
195+
}
196+
197+
type SplitStyleResult = {
198+
screenStyles: {
199+
backgroundColor?: ViewStyle['backgroundColor'];
200+
};
201+
contentWrapperStyles: StyleProp<ViewStyle>;
202+
};
203+
204+
// TODO: figure out whether other styles, like borders, filters, etc.
205+
// shouldn't be applied on the Screen level on iOS due to the inset.
206+
function extractScreenStyles(style: StyleProp<ViewStyle>): SplitStyleResult {
207+
const flatStyle = StyleSheet.flatten(style);
208+
209+
const { backgroundColor, ...contentWrapperStyles } = flatStyle as ViewStyle;
210+
211+
const screenStyles = {
212+
backgroundColor,
213+
};
214+
215+
return {
216+
screenStyles,
217+
contentWrapperStyles,
218+
};
219+
}
220+
167221
const styles = StyleSheet.create({
168222
container: {
169223
flex: 1,

0 commit comments

Comments
 (0)