diff --git a/android/src/main/java/com/swmansion/rnscreens/bottomsheet/SheetAnimationCoordinator.kt b/android/src/main/java/com/swmansion/rnscreens/bottomsheet/SheetAnimationCoordinator.kt
index 20889eb722..337436ed61 100644
--- a/android/src/main/java/com/swmansion/rnscreens/bottomsheet/SheetAnimationCoordinator.kt
+++ b/android/src/main/java/com/swmansion/rnscreens/bottomsheet/SheetAnimationCoordinator.kt
@@ -33,6 +33,8 @@ internal class SheetAnimationCoordinator(
private var lastScreenContainerBottomOffset: Int = 0
private val screenContainerRect = android.graphics.Rect()
+ private var lastBottomSystemBarInset: Int = 0
+
internal fun createSheetEnterAnimator(sheetAnimationContext: SheetDelegate.SheetAnimationContext): Animator {
val animatorSet = AnimatorSet()
@@ -224,6 +226,7 @@ internal class SheetAnimationCoordinator(
internal fun handleKeyboardInsetsProgress(insets: WindowInsetsCompat) {
lastKeyboardBottomOffset = insets.getInsets(WindowInsetsCompat.Type.ime()).bottom
+ lastBottomSystemBarInset = insets.getInsets(WindowInsetsCompat.Type.systemBars()).bottom
// Prioritize enter/exit animations over direct keyboard inset reactions.
// We store the latest keyboard offset in `lastKeyboardBottomOffset`
// so that it can always be respected when applying translations in `updateSheetTranslationY`.
@@ -280,10 +283,27 @@ internal class SheetAnimationCoordinator(
}
private fun updateSheetTranslationY(baseTranslationY: Float) {
- val effectiveKeyboardHeight = maxOf(0, lastKeyboardBottomOffset - lastScreenContainerBottomOffset)
- val bottomOffset = computeSheetOffsetYWithIMEPresent(effectiveKeyboardHeight).toFloat()
+ screen.translationY = baseTranslationY - calculateKeyboardShiftHidingBottomInset()
+ }
+
+ private fun calculateKeyboardShiftHidingBottomInset(): Float {
+ // Calculate how much of the keyboard physically intersects with our container.
+ val keyboardHeightInContainerBounds = maxOf(0, lastKeyboardBottomOffset - lastScreenContainerBottomOffset)
+
+ if (keyboardHeightInContainerBounds <= 0) {
+ return 0f
+ }
+
+ // Determine the maximum upward shift needed to keep the content above the keyboard.
+ val maxAllowedUpwardShift = computeSheetOffsetYWithIMEPresent(keyboardHeightInContainerBounds).toFloat()
+
+ // Determine how much of the navigation bar is located within our container's bounds.
+ val navigationBarHeightWithinContainer = maxOf(0, lastBottomSystemBarInset - lastScreenContainerBottomOffset)
- screen.translationY = baseTranslationY - bottomOffset
+ // By subtracting the nav bar height from our shift, we let that empty padding slide behind
+ // the keyboard. This keeps the sheet's actual content flush against the keyboard's top edge
+ // without requiring size updates.
+ return maxOf(0f, maxAllowedUpwardShift - navigationBarHeightWithinContainer.toFloat())
}
private fun createSheetSlideInAnimator(): ValueAnimator {
diff --git a/android/src/main/java/com/swmansion/rnscreens/bottomsheet/SheetDelegate.kt b/android/src/main/java/com/swmansion/rnscreens/bottomsheet/SheetDelegate.kt
index 37102b1038..800407998e 100644
--- a/android/src/main/java/com/swmansion/rnscreens/bottomsheet/SheetDelegate.kt
+++ b/android/src/main/java/com/swmansion/rnscreens/bottomsheet/SheetDelegate.kt
@@ -7,7 +7,6 @@ import android.view.View
import android.view.WindowManager
import android.view.inputmethod.InputMethodManager
import androidx.coordinatorlayout.widget.CoordinatorLayout
-import androidx.core.graphics.Insets
import androidx.core.view.OnApplyWindowInsetsListener
import androidx.core.view.WindowInsetsCompat
import androidx.lifecycle.Lifecycle
@@ -373,20 +372,10 @@ class SheetDelegate(
isKeyboardVisible = false
}
- val newBottomInset = if (!isImeVisible) systemBarsInsets.bottom else 0
-
- // Note: We do not manipulate the top inset manually. Therefore, if SafeAreaView has top insets enabled,
- // we must retain the top inset even if the formSheet does not currently overflow into the status bar.
- // This is important because in some specific edge cases - for example, when the keyboard slides in -
- // the formSheet might overlap the status bar. If we ignored the top inset and it suddenly became necessary,
- // it would result in a noticeable visual content jump. To ensure consistency and avoid layout shifts,
- // we always include the top inset upfront, which can be disabled from the application perspective.
- return WindowInsetsCompat
- .Builder(insets)
- .setInsets(
- WindowInsetsCompat.Type.systemBars(),
- Insets.of(systemBarsInsets.left, systemBarsInsets.top, systemBarsInsets.right, newBottomInset),
- ).build()
+ // This listener runs at the decor-view level (via InsetsObserverProxy), so anything we
+ // return here propagates to the entire view hierarchy. We therefore return the insets
+ // unmodified and we should never consume insets globally.
+ return insets
}
private fun shouldDismissSheetInState(
diff --git a/apps/src/tests/issue-tests/Test4244.tsx b/apps/src/tests/issue-tests/Test4244.tsx
new file mode 100644
index 0000000000..2080691cb3
--- /dev/null
+++ b/apps/src/tests/issue-tests/Test4244.tsx
@@ -0,0 +1,86 @@
+import React, { useState } from 'react';
+import { View, Text, Button, StyleSheet, TextInput } from 'react-native';
+import { NavigationContainer } from '@react-navigation/native';
+import { createNativeStackNavigator } from '@react-navigation/native-stack';
+import { SafeAreaView } from 'react-native-screens/experimental';
+import { Colors } from '@apps/shared/styling';
+
+const Stack = createNativeStackNavigator();
+
+function MainScreen({ navigation }: any) {
+ return (
+
+ Main Screen inside Stack
+
+ );
+}
+
+function FormSheetScreen({ navigation }: any) {
+ const [text, setText] = useState('');
+
+ return (
+
+
+ FormSheet Modal
+
+
+
+ );
+}
+
+export default function App() {
+ return (
+
+
+
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ alignItems: 'center',
+ justifyContent: 'center',
+ gap: 16,
+ paddingTop: 24,
+ paddingHorizontal: 24,
+ backgroundColor: Colors.White,
+ },
+ input: {
+ width: '80%',
+ height: 45,
+ borderColor: Colors.offBackground,
+ borderWidth: 1,
+ borderRadius: 8,
+ paddingHorizontal: 12,
+ backgroundColor: Colors.White,
+ },
+});
diff --git a/apps/src/tests/issue-tests/index.ts b/apps/src/tests/issue-tests/index.ts
index b565c06191..f03d0930f5 100644
--- a/apps/src/tests/issue-tests/index.ts
+++ b/apps/src/tests/issue-tests/index.ts
@@ -199,6 +199,7 @@ export { default as Test4155 } from './Test4155';
export { default as Test4161 } from './Test4161';
export { default as Test4220 } from './Test4220';
export { default as Test4240 } from './Test4240';
+export { default as Test4244 } from './Test4244';
export { default as TestScreenAnimation } from './TestScreenAnimation';
// The following test was meant to demo the "go back" gesture using Reanimated
// but the associated PR in react-navigation is currently put on hold