diff --git a/android/src/main/java/com/swmansion/rnscreens/ScreenStackFragment.kt b/android/src/main/java/com/swmansion/rnscreens/ScreenStackFragment.kt index e372ca3e6a..f7f654ac98 100644 --- a/android/src/main/java/com/swmansion/rnscreens/ScreenStackFragment.kt +++ b/android/src/main/java/com/swmansion/rnscreens/ScreenStackFragment.kt @@ -1,11 +1,10 @@ package com.swmansion.rnscreens import android.animation.Animator -import android.animation.AnimatorSet -import android.animation.ValueAnimator import android.annotation.SuppressLint import android.graphics.Color import android.graphics.drawable.ColorDrawable +import android.os.Build import android.os.Bundle import android.view.LayoutInflater import android.view.Menu @@ -31,12 +30,9 @@ import com.google.android.material.shape.ShapeAppearanceModel import com.swmansion.rnscreens.bottomsheet.DimmingViewManager import com.swmansion.rnscreens.bottomsheet.SheetDelegate import com.swmansion.rnscreens.bottomsheet.usesFormSheetPresentation -import com.swmansion.rnscreens.events.ScreenAnimationDelegate import com.swmansion.rnscreens.events.ScreenDismissedEvent -import com.swmansion.rnscreens.events.ScreenEventEmitter import com.swmansion.rnscreens.ext.recycle import com.swmansion.rnscreens.stack.views.ScreensCoordinatorLayout -import com.swmansion.rnscreens.transition.ExternalBoundaryValuesEvaluator import com.swmansion.rnscreens.utils.DeviceUtils import com.swmansion.rnscreens.utils.resolveBackgroundColor import kotlin.math.max @@ -242,20 +238,34 @@ class ScreenStackFragment : ) coordinatorLayout.layout(0, 0, container.width, container.height) - // Replace InsetsAnimationCallback created by BottomSheetBehavior with empty - // implementation so it does not interfere with our custom formSheet entering animation - // More details: https://github.com/software-mansion/react-native-screens/pull/2909 - ViewCompat.setWindowInsetsAnimationCallback( - screen, + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) { + ViewCompat.setOnApplyWindowInsetsListener(screen) { _, windowInsets -> + sheetDelegate.handleKeyboardInsetsProgress(windowInsets) + windowInsets + } + } + + val insetsAnimationCallback = object : WindowInsetsAnimationCompat.Callback( - DISPATCH_MODE_STOP, + WindowInsetsAnimationCompat.Callback.DISPATCH_MODE_STOP, ) { + // Replace InsetsAnimationCallback created by BottomSheetBehavior + // to avoid interfering with custom animations. + // See: https://github.com/software-mansion/react-native-screens/pull/2909 override fun onProgress( insets: WindowInsetsCompat, runningAnimations: MutableList, - ): WindowInsetsCompat = insets - }, - ) + ): WindowInsetsCompat { + // On API 30+, we handle keyboard inset animation progress here. + // On lower APIs, we rely on ViewCompat.setOnApplyWindowInsetsListener instead. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + sheetDelegate.handleKeyboardInsetsProgress(insets) + } + return insets + } + } + + ViewCompat.setWindowInsetsAnimationCallback(screen, insetsAnimationCallback) } return coordinatorLayout @@ -287,64 +297,37 @@ class ScreenStackFragment : return null } - val animatorSet = AnimatorSet() + return if (enter) createSheetEnterAnimator() else createSheetExitAnimator() + } + + private fun createSheetEnterAnimator(): Animator { + val sheetDelegate = requireSheetDelegate() val dimmingDelegate = requireDimmingDelegate() - if (enter) { - val alphaAnimator = - ValueAnimator.ofFloat(0f, dimmingDelegate.maxAlpha).apply { - addUpdateListener { anim -> - val animatedValue = anim.animatedValue as? Float - animatedValue?.let { dimmingDelegate.dimmingView.alpha = it } - } - } - val startValueCallback = { initialStartValue: Number? -> screen.height.toFloat() } - val evaluator = ExternalBoundaryValuesEvaluator(startValueCallback, { 0f }) - val slideAnimator = - ValueAnimator.ofObject(evaluator, screen.height.toFloat(), 0f).apply { - addUpdateListener { anim -> - val animatedValue = anim.animatedValue as? Float - animatedValue?.let { screen.translationY = it } - } - } + val sheetAnimationContext = + SheetDelegate.SheetAnimationContext( + this, + this.screen, + this.coordinatorLayout, + dimmingDelegate, + ) - animatorSet - .play(slideAnimator) - .takeIf { - dimmingDelegate.willDimForDetentIndex( - screen, - screen.sheetInitialDetentIndex, - ) - }?.with(alphaAnimator) - } else { - val alphaAnimator = - ValueAnimator.ofFloat(dimmingDelegate.dimmingView.alpha, 0f).apply { - addUpdateListener { anim -> - val animatedValue = anim.animatedValue as? Float - animatedValue?.let { dimmingDelegate.dimmingView.alpha = it } - } - } - val slideAnimator = - ValueAnimator.ofFloat(0f, (coordinatorLayout.bottom - screen.top).toFloat()).apply { - addUpdateListener { anim -> - val animatedValue = anim.animatedValue as? Float - animatedValue?.let { screen.translationY = it } - } - } - animatorSet.play(alphaAnimator).with(slideAnimator) - } - animatorSet.addListener( - ScreenAnimationDelegate( + return sheetDelegate.createSheetEnterAnimator(sheetAnimationContext) + } + + private fun createSheetExitAnimator(): Animator { + val sheetDelegate = requireSheetDelegate() + val dimmingDelegate = requireDimmingDelegate() + + val sheetAnimationContext = + SheetDelegate.SheetAnimationContext( this, - ScreenEventEmitter(this.screen), - if (enter) { - ScreenAnimationDelegate.AnimationType.ENTER - } else { - ScreenAnimationDelegate.AnimationType.EXIT - }, - ), - ) - return animatorSet + this.screen, + this.coordinatorLayout, + dimmingDelegate, + ) + + return sheetDelegate.createSheetExitAnimator(sheetAnimationContext) } private fun createBottomSheetBehaviour(): BottomSheetBehavior = BottomSheetBehavior() 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 d86e8df9e1..df9b9d6aff 100644 --- a/android/src/main/java/com/swmansion/rnscreens/bottomsheet/SheetDelegate.kt +++ b/android/src/main/java/com/swmansion/rnscreens/bottomsheet/SheetDelegate.kt @@ -1,10 +1,15 @@ package com.swmansion.rnscreens.bottomsheet +import android.animation.Animator +import android.animation.AnimatorListenerAdapter +import android.animation.AnimatorSet +import android.animation.ValueAnimator import android.content.Context import android.os.Build 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 @@ -19,6 +24,9 @@ import com.swmansion.rnscreens.KeyboardState import com.swmansion.rnscreens.KeyboardVisible import com.swmansion.rnscreens.Screen import com.swmansion.rnscreens.ScreenStackFragment +import com.swmansion.rnscreens.events.ScreenAnimationDelegate +import com.swmansion.rnscreens.events.ScreenEventEmitter +import com.swmansion.rnscreens.transition.ExternalBoundaryValuesEvaluator class SheetDelegate( val screen: Screen, @@ -27,6 +35,10 @@ class SheetDelegate( private var isKeyboardVisible: Boolean = false private var keyboardState: KeyboardState = KeyboardNotVisible + private var isSheetAnimationInProgress: Boolean = false + + private var lastKeyboardBottomOffset: Int = 0 + var lastStableDetentIndex: Int = screen.sheetInitialDetentIndex private set @@ -175,6 +187,8 @@ class SheetDelegate( } is KeyboardVisible -> { + val isOnScreenKeyboardVisible = keyboardState.height != 0 + when (screen.sheetDetents.count()) { 1 -> behavior.apply { @@ -183,17 +197,25 @@ class SheetDelegate( 2 -> behavior.apply { - useTwoDetents( - state = BottomSheetBehavior.STATE_EXPANDED, - ) + if (isOnScreenKeyboardVisible) { + useTwoDetents( + state = BottomSheetBehavior.STATE_EXPANDED, + ) + } else { + useTwoDetents() + } addBottomSheetCallback(keyboardHandlerCallback) } 3 -> behavior.apply { - useThreeDetents( - state = BottomSheetBehavior.STATE_EXPANDED, - ) + if (isOnScreenKeyboardVisible) { + useThreeDetents( + state = BottomSheetBehavior.STATE_EXPANDED, + ) + } else { + useThreeDetents() + } addBottomSheetCallback(keyboardHandlerCallback) } @@ -211,10 +233,23 @@ class SheetDelegate( behavior.removeBottomSheetCallback(keyboardHandlerCallback) when (screen.sheetDetents.count()) { 1 -> - behavior.useSingleDetent( - height = (screen.sheetDetents.first() * containerHeight).toInt(), - forceExpandedState = false, - ) + behavior.apply { + val height = + if (screen.isSheetFitToContents()) { + screen.contentWrapper?.let { contentWrapper -> + contentWrapper.height.takeIf { + // subtree might not be laid out, e.g. after fragment reattachment + // and view recreation, however since it is retained by + // react-native it has its height cached. We want to use it. + // Otherwise we would have to trigger RN layout manually. + contentWrapper.isLaidOutOrHasCachedLayout() + } + } + } else { + (screen.sheetDetents.first() * containerHeight).toInt() + } + useSingleDetent(height = height, forceExpandedState = false) + } 2 -> behavior.useTwoDetents( @@ -237,6 +272,36 @@ class SheetDelegate( } } + // This function calculates the Y offset to which the FormSheet should animate + // when appearing (entering) or disappearing (exiting) with the on-screen keyboard (IME) present. + // Its purpose is to ensure the FormSheet does not exceed the top edge of the screen. + // It tries to display the FormSheet fully above the keyboard when there's enough space. + // Otherwise, it shifts the sheet as high as possible, even if it means part of its content + // will remain hidden behind the keyboard. + internal fun computeSheetOffsetYWithIMEPresent(keyboardHeight: Int): Int { + val containerHeight = tryResolveContainerHeight() + check(containerHeight != null) { + "[RNScreens] Failed to find window height during bottom sheet behaviour configuration" + } + + if (screen.isSheetFitToContents()) { + val contentHeight = screen.contentWrapper?.height ?: 0 + val offsetFromTop = containerHeight - contentHeight + return minOf(offsetFromTop, keyboardHeight) + } + + val detents = screen.sheetDetents + if (detents.isEmpty()) { + throw IllegalStateException("[RNScreens] Cannot determine sheet detent - detents list is empty") + } + + val detentValue = detents[detents.size - 1].coerceIn(0.0, 1.0) + val sheetHeight = (detentValue * containerHeight).toInt() + val offsetFromTop = containerHeight - sheetHeight + + return minOf(offsetFromTop, keyboardHeight) + } + // This is listener function, not the view's. override fun onApplyWindowInsets( v: View, @@ -270,7 +335,6 @@ class SheetDelegate( this.configureBottomSheetBehaviour(it, KeyboardDidHide) } else if (keyboardState != KeyboardNotVisible) { this.configureBottomSheetBehaviour(it, KeyboardNotVisible) - } else { } } @@ -317,6 +381,129 @@ class SheetDelegate( return null } + // Sheet entering/exiting animations + + internal fun createSheetEnterAnimator(sheetAnimationContext: SheetAnimationContext): Animator { + val animatorSet = AnimatorSet() + + val dimmingDelegate = sheetAnimationContext.dimmingDelegate + val screenStackFragment = sheetAnimationContext.fragment + + val alphaAnimator = createDimmingViewAlphaAnimator(0f, dimmingDelegate.maxAlpha, dimmingDelegate) + val slideAnimator = createSheetSlideInAnimator() + + animatorSet + .play(slideAnimator) + .takeIf { + dimmingDelegate.willDimForDetentIndex(screen, screen.sheetInitialDetentIndex) + }?.with(alphaAnimator) + + attachCommonListeners(animatorSet, isEnter = true, screenStackFragment) + + return animatorSet + } + + internal fun createSheetExitAnimator(sheetAnimationContext: SheetAnimationContext): Animator { + val animatorSet = AnimatorSet() + + val coordinatorLayout = sheetAnimationContext.coordinatorLayout + val dimmingDelegate = sheetAnimationContext.dimmingDelegate + val screenStackFragment = sheetAnimationContext.fragment + + val alphaAnimator = + createDimmingViewAlphaAnimator(dimmingDelegate.dimmingView.alpha, 0f, dimmingDelegate) + val slideAnimator = createSheetSlideOutAnimator(coordinatorLayout) + + animatorSet.play(alphaAnimator).with(slideAnimator) + + attachCommonListeners(animatorSet, isEnter = false, screenStackFragment) + + return animatorSet + } + + private fun createDimmingViewAlphaAnimator( + from: Float, + to: Float, + dimmingDelegate: DimmingViewManager, + ): ValueAnimator = + ValueAnimator.ofFloat(from, to).apply { + addUpdateListener { animator -> + (animator.animatedValue as? Float)?.let { + dimmingDelegate.dimmingView.alpha = it + } + } + } + + private fun createSheetSlideInAnimator(): ValueAnimator { + val startValueCallback = { _: Number? -> screen.height.toFloat() } + val evaluator = ExternalBoundaryValuesEvaluator(startValueCallback, { 0f }) + + return ValueAnimator.ofObject(evaluator, screen.height.toFloat(), 0f).apply { + addUpdateListener { updateSheetTranslationY(it.animatedValue as Float) } + } + } + + private fun createSheetSlideOutAnimator(coordinatorLayout: CoordinatorLayout): ValueAnimator { + val endValue = (coordinatorLayout.bottom - screen.top - screen.translationY) + + return ValueAnimator.ofFloat(0f, endValue).apply { + addUpdateListener { + updateSheetTranslationY(it.animatedValue as Float) + } + } + } + + private fun updateSheetTranslationY(baseTranslationY: Float) { + val keyboardCorrection = lastKeyboardBottomOffset + val bottomOffset = computeSheetOffsetYWithIMEPresent(keyboardCorrection).toFloat() + + screen.translationY = baseTranslationY - bottomOffset + } + + internal fun handleKeyboardInsetsProgress(insets: WindowInsetsCompat) { + lastKeyboardBottomOffset = insets.getInsets(WindowInsetsCompat.Type.ime()).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`. + // + // This approach allows screen translation to be triggered from two sources, but without messing them together: + // - During enter/exit animations, while accounting for the keyboard height. + // - While interacting with a TextInput inside the bottom sheet, to handle keyboard show/hide events. + if (!isSheetAnimationInProgress) { + updateSheetTranslationY(0f) + } + } + + private fun attachCommonListeners( + animatorSet: AnimatorSet, + isEnter: Boolean, + screenStackFragment: ScreenStackFragment, + ) { + animatorSet.addListener( + ScreenAnimationDelegate( + screenStackFragment, + ScreenEventEmitter(screen), + if (isEnter) { + ScreenAnimationDelegate.AnimationType.ENTER + } else { + ScreenAnimationDelegate.AnimationType.EXIT + }, + ), + ) + + animatorSet.addListener( + object : AnimatorListenerAdapter() { + override fun onAnimationStart(animation: Animator) { + isSheetAnimationInProgress = true + } + + override fun onAnimationEnd(animation: Animator) { + isSheetAnimationInProgress = false + } + }, + ) + } + private inner class KeyboardHandler : BottomSheetBehavior.BottomSheetCallback() { override fun onStateChanged( bottomSheet: View, @@ -363,6 +550,13 @@ class SheetDelegate( ) = Unit } + internal data class SheetAnimationContext( + val fragment: ScreenStackFragment, + val screen: Screen, + val coordinatorLayout: CoordinatorLayout, + val dimmingDelegate: DimmingViewManager, + ) + companion object { const val TAG = "SheetDelegate" } diff --git a/apps/src/tests/Test3248.tsx b/apps/src/tests/Test3248.tsx new file mode 100644 index 0000000000..bbf5094068 --- /dev/null +++ b/apps/src/tests/Test3248.tsx @@ -0,0 +1,171 @@ +import React from 'react'; +import { createNativeStackNavigator } from '@react-navigation/native-stack'; +import type { + NativeStackNavigationOptions, + NativeStackNavigationProp, +} from '@react-navigation/native-stack'; +import { NavigationContainer } from '@react-navigation/native'; +import { Button, TextInput, View } from 'react-native'; + +type StackParamList = { + Main: undefined; + FormSheetWithFitToContents: undefined; + FormSheetWithSmallDetent: undefined; + FormSheetWithMediumDetent: undefined; + FormSheetWithLargeDetent: undefined; + FormSheetWithTwoDetents: undefined; + FormSheetWithThreeDetents: undefined; + FormSheetWithAutoFocusAndFitToContents: undefined; + FormSheetWithAutoFocusAndDetent: undefined; +}; + +type MainProps = { + navigation: NativeStackNavigationProp; +}; + +const Stack = createNativeStackNavigator(); + +function Main({ navigation }: MainProps) { + return ( + +