Skip to content

Commit 35e9851

Browse files
authored
fix(Android, FormSheet): Apply workaround for controlling insets on BottomSheetBehavior (#3611)
## Description This PR introduces a workaround to prevent `BottomSheetBehavior` from handling system window insets that interfere with our custom layout logic. By manually controlling `isGestureInsetBottomIgnored` we're bypassing `BottomSheetBehavior's` internal `setWindowInsetsListener`, we eliminate unintended top padding caused by default `insetTop` calculations, taking the responsibility for handling it on our side by `sheetShouldOverflowTopInset`. Using the `sheetShouldOverflowTopInset` prop, we take full control over how the BottomSheet interacts with the system bars, ensuring more control over system insets. Additionally, there was a 2nd issue related to this problem: `expandedOffsetFromTop` shouldn't account for the system inset when we're overflowing it. The proper flag for hangling that case for 3 detents was passed to our calculations for expandedOffset. Closes: software-mansion/react-native-screens-labs#926 ## Changes - Added a workaround that disables `isGestureInsetBottomIgnored` determined by `sheetShouldOverflowTopInset` value - Updated `expandedOffsetFromTop` calculations in `SheetDetents` class ## Before & after - visual documentation | Before | After | | --- | --- | | <video src="https://github.com/user-attachments/assets/5ebc19d0-5318-4d46-875d-633e8bb5c9f7" />) | <video src="https://github.com/user-attachments/assets/f5755193-80aa-45aa-a3eb-c60dd2742b9d" /> | ## Test plan Added a new TC, regression testing on previously added TC for FormSheets & TestFormSheet. ## Checklist - [x] Included code example that can be used to test this change. - [ ] Updated / created local changelog entries in relevant test files. - [x] For visual changes, included screenshots / GIFs / recordings documenting the change. - [ ] For API changes, updated relevant public types. - [x] Ensured that CI passes
1 parent dc07ca4 commit 35e9851

File tree

5 files changed

+187
-6
lines changed

5 files changed

+187
-6
lines changed

android/src/main/java/com/swmansion/rnscreens/bottomsheet/BottomSheetBehaviorExt.kt

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ internal fun <T : View> BottomSheetBehavior<T>.updateMetrics(
1919
internal fun <T : View> BottomSheetBehavior<T>.useSingleDetent(
2020
maxAllowedHeight: Int? = null,
2121
forceExpandedState: Boolean = true,
22+
enableManualInsetsAdjustmentWorkaround: Boolean? = null,
2223
): BottomSheetBehavior<T> {
2324
this.skipCollapsed = true
2425
this.isFitToContents = true
@@ -28,19 +29,22 @@ internal fun <T : View> BottomSheetBehavior<T>.useSingleDetent(
2829
maxAllowedHeight?.let {
2930
this.maxHeight = maxAllowedHeight
3031
}
32+
maybeApplyManualInsetsAdjustmentWorkaround(enableManualInsetsAdjustmentWorkaround)
3133
return this
3234
}
3335

3436
internal fun <T : View> BottomSheetBehavior<T>.useTwoDetents(
3537
@BottomSheetBehavior.StableState state: Int? = null,
3638
firstHeight: Int? = null,
3739
maxAllowedHeight: Int? = null,
40+
enableManualInsetsAdjustmentWorkaround: Boolean? = null,
3841
): BottomSheetBehavior<T> {
3942
this.skipCollapsed = false
4043
this.isFitToContents = true
4144
state?.let { this.state = state }
4245
firstHeight?.let { this.peekHeight = firstHeight }
4346
maxAllowedHeight?.let { this.maxHeight = maxAllowedHeight }
47+
maybeApplyManualInsetsAdjustmentWorkaround(enableManualInsetsAdjustmentWorkaround)
4448
return this
4549
}
4650

@@ -50,6 +54,7 @@ internal fun <T : View> BottomSheetBehavior<T>.useThreeDetents(
5054
maxAllowedHeight: Int? = null,
5155
halfExpandedRatio: Float? = null,
5256
expandedOffsetFromTop: Int? = null,
57+
enableManualInsetsAdjustmentWorkaround: Boolean? = null,
5358
): BottomSheetBehavior<T> {
5459
this.skipCollapsed = false
5560
this.isFitToContents = false
@@ -58,9 +63,29 @@ internal fun <T : View> BottomSheetBehavior<T>.useThreeDetents(
5863
halfExpandedRatio?.let { this.halfExpandedRatio = halfExpandedRatio }
5964
expandedOffsetFromTop?.let { this.expandedOffset = expandedOffsetFromTop }
6065
maxAllowedHeight?.let { this.maxHeight = maxAllowedHeight }
66+
maybeApplyManualInsetsAdjustmentWorkaround(enableManualInsetsAdjustmentWorkaround)
6167
return this
6268
}
6369

70+
private fun <T : View> BottomSheetBehavior<T>.maybeApplyManualInsetsAdjustmentWorkaround(enableManualInsetsAdjustmentWorkaround: Boolean?) {
71+
/**
72+
* WORKAROUND: We manually control 'isGestureInsetBottomIgnored' to bypass the internal
73+
* 'setWindowInsetsListener' logic in BottomSheetBehavior.
74+
*
75+
* The default implementation of 'setWindowInsetsListener' performs wide-ranging
76+
* adjustments, including calculating 'insetTop' from system bars. This internal
77+
* 'insetTop' value can trigger unwanted top padding during the layout pass.
78+
*
79+
* By using the 'sheetShouldOverflowTopInset' prop, we're taking full responsibility for
80+
* handling both status bar coverage and avoidance. Enabling this workaround
81+
* prevents the system from interfering with our custom overflow logic,
82+
* ensuring the sheet renders correctly in both states.
83+
*/
84+
enableManualInsetsAdjustmentWorkaround?.let {
85+
this.isGestureInsetBottomIgnored = it
86+
}
87+
}
88+
6489
internal fun <T : View> BottomSheetBehavior<T>.fitToContentsSheetHeight(): Int {
6590
// In fitToContents only a single detent is allowed, and the actual
6691
// sheet height is stored in this field.

android/src/main/java/com/swmansion/rnscreens/bottomsheet/SheetDelegate.kt

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -183,7 +183,7 @@ class SheetDelegate(
183183
// has been reduced by this inset.
184184
val expandedOffsetFromTop =
185185
when (screen.sheetDetents.count) {
186-
3 -> screen.sheetDetents.expandedOffsetFromTop(containerHeight, lastTopInset)
186+
3 -> screen.sheetDetents.expandedOffsetFromTop(containerHeight, lastTopInset, screen.sheetShouldOverflowTopInset)
187187
else -> null
188188
}
189189

@@ -221,7 +221,10 @@ class SheetDelegate(
221221
} else {
222222
screen.sheetDetents.maxAllowedHeight(containerHeight)
223223
}
224-
useSingleDetent(maxAllowedHeight = height)
224+
useSingleDetent(
225+
maxAllowedHeight = height,
226+
enableManualInsetsAdjustmentWorkaround = screen.sheetShouldOverflowTopInset,
227+
)
225228
}
226229

227230
2 ->
@@ -232,6 +235,7 @@ class SheetDelegate(
232235
),
233236
firstHeight = screen.sheetDetents.firstHeight(containerHeight),
234237
maxAllowedHeight = screen.sheetDetents.maxAllowedHeight(containerHeight),
238+
enableManualInsetsAdjustmentWorkaround = screen.sheetShouldOverflowTopInset,
235239
)
236240

237241
3 ->
@@ -243,7 +247,13 @@ class SheetDelegate(
243247
firstHeight = screen.sheetDetents.firstHeight(containerHeight),
244248
halfExpandedRatio = screen.sheetDetents.halfExpandedRatio(),
245249
maxAllowedHeight = screen.sheetDetents.maxAllowedHeight(containerHeight),
246-
expandedOffsetFromTop = screen.sheetDetents.expandedOffsetFromTop(containerHeight, lastTopInset),
250+
expandedOffsetFromTop =
251+
screen.sheetDetents.expandedOffsetFromTop(
252+
containerHeight,
253+
lastTopInset,
254+
screen.sheetShouldOverflowTopInset,
255+
),
256+
enableManualInsetsAdjustmentWorkaround = screen.sheetShouldOverflowTopInset,
247257
)
248258

249259
else -> throw IllegalStateException(
@@ -306,21 +316,32 @@ class SheetDelegate(
306316
} else {
307317
screen.sheetDetents.maxAllowedHeight(containerHeight)
308318
}
309-
useSingleDetent(maxAllowedHeight = height, forceExpandedState = false)
319+
useSingleDetent(
320+
maxAllowedHeight = height,
321+
forceExpandedState = false,
322+
enableManualInsetsAdjustmentWorkaround = screen.sheetShouldOverflowTopInset,
323+
)
310324
}
311325

312326
2 ->
313327
behavior.useTwoDetents(
314328
firstHeight = screen.sheetDetents.firstHeight(containerHeight),
315329
maxAllowedHeight = screen.sheetDetents.maxAllowedHeight(containerHeight),
330+
enableManualInsetsAdjustmentWorkaround = screen.sheetShouldOverflowTopInset,
316331
)
317332

318333
3 ->
319334
behavior.useThreeDetents(
320335
firstHeight = screen.sheetDetents.firstHeight(containerHeight),
321336
halfExpandedRatio = screen.sheetDetents.halfExpandedRatio(),
322337
maxAllowedHeight = screen.sheetDetents.maxAllowedHeight(containerHeight),
323-
expandedOffsetFromTop = screen.sheetDetents.expandedOffsetFromTop(containerHeight, lastTopInset),
338+
expandedOffsetFromTop =
339+
screen.sheetDetents.expandedOffsetFromTop(
340+
containerHeight,
341+
lastTopInset,
342+
screen.sheetShouldOverflowTopInset,
343+
),
344+
enableManualInsetsAdjustmentWorkaround = screen.sheetShouldOverflowTopInset,
324345
)
325346

326347
else -> throw IllegalStateException(

android/src/main/java/com/swmansion/rnscreens/bottomsheet/SheetDetents.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,9 +73,10 @@ class SheetDetents(
7373
internal fun expandedOffsetFromTop(
7474
containerHeight: Int,
7575
topInset: Int = 0,
76+
shouldOverflowTopInset: Boolean = false,
7677
): Int {
7778
if (count < 3) throw IllegalStateException("[RNScreens] At least 3 detents required for expandedOffsetFromTop.")
78-
return ((1 - at(2)) * containerHeight).toInt() + topInset
79+
return ((1 - at(2)) * containerHeight).toInt() + (if (shouldOverflowTopInset) 0 else topInset)
7980
}
8081

8182
internal fun peekHeight(containerHeight: Int): Int = heightAt(0, containerHeight)
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
import React from 'react';
2+
import { createNativeStackNavigator } from '@react-navigation/native-stack';
3+
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
4+
import { NavigationContainer } from '@react-navigation/native';
5+
import { Button, ScrollView, Text, TextInput, View } from 'react-native';
6+
import Colors from '../../shared/styling/Colors';
7+
import { SafeAreaView } from 'react-native-screens/experimental';
8+
9+
const TEST_CASES = [
10+
{ id: '1Detent07', detents: [0.7], label: '1 Detent: 0.7' },
11+
{ id: '1Detent10', detents: [1.0], label: '1 Detent: 1.0' },
12+
{ id: '2Detents03_07', detents: [0.3, 0.7], label: '2 Detents: 0.3, 0.7' },
13+
{ id: '2Detents03_10', detents: [0.3, 1.0], label: '2 Detents: 0.3, 1.0' },
14+
{
15+
id: '3Detents02_04_07',
16+
detents: [0.2, 0.4, 0.7],
17+
label: '3 Detents: 0.2, 0.4, 0.7',
18+
},
19+
{
20+
id: '3Detents02_04_10',
21+
detents: [0.2, 0.4, 1.0],
22+
label: '3 Detents: 0.2, 0.4, 1.0',
23+
},
24+
] as const;
25+
26+
type StackParamList = {
27+
Main: undefined;
28+
} & {
29+
[K in (typeof TEST_CASES)[number]['id'] as `${K}_OverflowEnabled`]: undefined;
30+
} & {
31+
[K in (typeof TEST_CASES)[number]['id'] as `${K}_OverflowDisabled`]: undefined;
32+
};
33+
34+
const Stack = createNativeStackNavigator<StackParamList>();
35+
36+
function Main({
37+
navigation,
38+
}: {
39+
navigation: NativeStackNavigationProp<StackParamList>;
40+
}) {
41+
return (
42+
<ScrollView style={{ flex: 1, padding: 16 }}>
43+
{TEST_CASES.map(test => (
44+
<View key={test.id} style={{ marginBottom: 16 }}>
45+
<Text style={{ fontWeight: 'bold', marginBottom: 8 }}>
46+
{test.label}
47+
</Text>
48+
<Button
49+
title="Overflow ENABLED"
50+
onPress={() => navigation.navigate(`${test.id}_OverflowEnabled`)}
51+
/>
52+
<View style={{ height: 8 }} />
53+
<Button
54+
title="Overflow DISABLED"
55+
color="gray"
56+
onPress={() =>
57+
navigation.navigate(`${test.id}_OverflowDisabled` as any)
58+
}
59+
/>
60+
</View>
61+
))}
62+
</ScrollView>
63+
);
64+
}
65+
66+
function Footer() {
67+
return (
68+
<View
69+
style={{
70+
height: 40,
71+
justifyContent: 'center',
72+
alignItems: 'center',
73+
backgroundColor: Colors.PurpleDark40,
74+
borderBottomColor: Colors.PurpleDark140,
75+
borderBottomWidth: 5,
76+
}}>
77+
<Text>Footer</Text>
78+
</View>
79+
);
80+
}
81+
82+
function FormSheetBase() {
83+
return (
84+
<SafeAreaView style={{ flex: 1 }} edges={{ bottom: true }}>
85+
<View style={{ flex: 1, justifyContent: 'space-between'}}>
86+
<View style={{padding: 20}}>
87+
<Text style={{ marginBottom: 10 }}>Content Area</Text>
88+
<TextInput
89+
style={{ borderWidth: 1, height: 40, paddingHorizontal: 8 }}
90+
placeholder="placeholder"
91+
/>
92+
</View>
93+
<Footer />
94+
</View>
95+
</SafeAreaView>
96+
);
97+
}
98+
99+
export default function App() {
100+
return (
101+
<NavigationContainer>
102+
<Stack.Navigator>
103+
<Stack.Screen name="Main" component={Main} />
104+
{TEST_CASES.flatMap(test => [
105+
<Stack.Screen
106+
key={`${test.id}_on`}
107+
name={`${test.id}_OverflowEnabled`}
108+
component={FormSheetBase}
109+
options={{
110+
presentation: 'formSheet',
111+
headerShown: false,
112+
contentStyle: { backgroundColor: Colors.GreenLight100 },
113+
sheetShouldOverflowTopInset: true,
114+
sheetAllowedDetents: test.detents as any,
115+
}}
116+
/>,
117+
<Stack.Screen
118+
key={`${test.id}_off`}
119+
name={`${test.id}_OverflowDisabled`}
120+
component={FormSheetBase}
121+
options={{
122+
presentation: 'formSheet',
123+
headerShown: false,
124+
contentStyle: { backgroundColor: Colors.GreenLight100 },
125+
sheetShouldOverflowTopInset: false,
126+
sheetAllowedDetents: test.detents as any,
127+
}}
128+
/>,
129+
])}
130+
</Stack.Navigator>
131+
</NavigationContainer>
132+
);
133+
}

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,7 @@ export { default as Test3564 } from './Test3564';
180180
export { default as Test3566 } from './Test3566';
181181
export { default as Test3576 } from './Test3576';
182182
export { default as Test3596 } from './Test3596';
183+
export { default as Test3611 } from './Test3611';
183184
export { default as Test3617 } from './Test3617';
184185
export { default as TestScreenAnimation } from './TestScreenAnimation';
185186
// The following test was meant to demo the "go back" gesture using Reanimated

0 commit comments

Comments
 (0)