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 +