package com.swmansion.rnscreens.bottomsheet import android.animation.Animator 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 import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.LifecycleOwner import com.google.android.material.bottomsheet.BottomSheetBehavior import com.swmansion.rnscreens.InsetsObserverProxy import com.swmansion.rnscreens.KeyboardDidHide import com.swmansion.rnscreens.KeyboardNotVisible import com.swmansion.rnscreens.KeyboardState import com.swmansion.rnscreens.KeyboardVisible import com.swmansion.rnscreens.Screen import com.swmansion.rnscreens.ScreenStackFragment import com.swmansion.rnscreens.utils.isSoftKeyboardVisibleOrNull class SheetDelegate( val screen: Screen, ) : LifecycleEventObserver, OnApplyWindowInsetsListener { private var isKeyboardVisible: Boolean = false private var keyboardState: KeyboardState = KeyboardNotVisible private var lastTopInset: Int = 0 var lastStableDetentIndex: Int = screen.sheetInitialDetentIndex private set @BottomSheetBehavior.State var lastStableState: Int = screen.sheetDetents.sheetStateFromIndex( screen.sheetInitialDetentIndex, ) private set private val sheetStateObserver = SheetStateObserver() private val keyboardHandlerCallback = KeyboardHandler() private val sheetBehavior: BottomSheetBehavior? get() = screen.sheetBehavior private val stackFragment: ScreenStackFragment get() = screen.fragment as ScreenStackFragment private fun requireDecorView(): View = checkNotNull(screen.reactContext.currentActivity) { "[RNScreens] Attempt to access activity on detached context" } .window.decorView private var viewToRestoreFocus: View? = null private val inputMethodManager: InputMethodManager? get() = screen.reactContext.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager init { assert(screen.fragment is ScreenStackFragment) { "[RNScreens] Sheets are supported only in native stack" } screen.fragment!!.lifecycle.addObserver(this) checkNotNull(sheetBehavior) { "[RNScreens] Sheet delegate accepts screen with initialized sheet behaviour only." } .addBottomSheetCallback(sheetStateObserver) } // LifecycleEventObserver override fun onStateChanged( source: LifecycleOwner, event: Lifecycle.Event, ) { when (event) { Lifecycle.Event.ON_CREATE -> handleHostFragmentOnCreate() Lifecycle.Event.ON_START -> handleHostFragmentOnStart() Lifecycle.Event.ON_RESUME -> handleHostFragmentOnResume() Lifecycle.Event.ON_PAUSE -> handleHostFragmentOnPause() Lifecycle.Event.ON_DESTROY -> handleHostFragmentOnDestroy() else -> Unit } } private fun handleHostFragmentOnCreate() { preserveBackgroundFocus() } private fun handleHostFragmentOnStart() { InsetsObserverProxy.registerOnView(requireDecorView()) } private fun handleHostFragmentOnResume() { InsetsObserverProxy.addOnApplyWindowInsetsListener(this) } private fun handleHostFragmentOnPause() { InsetsObserverProxy.removeOnApplyWindowInsetsListener(this) } private fun handleHostFragmentOnDestroy() { restoreBackgroundFocus() } private fun onSheetStateChanged(newState: Int) { val isStable = SheetUtils.isStateStable(newState) if (isStable) { lastStableState = newState lastStableDetentIndex = screen.sheetDetents.indexFromSheetState( newState, ) } screen.onSheetDetentChanged(lastStableDetentIndex, isStable) if (shouldDismissSheetInState(newState)) { stackFragment.dismissSelf() } } private fun preserveBackgroundFocus() { val activity = screen.reactContext.currentActivity ?: return activity.currentFocus?.let { focusedView -> activity.window?.decorView?.let { decorView -> if (isSoftKeyboardVisibleOrNull(decorView) == true) { viewToRestoreFocus = focusedView } } screen.requestFocus() inputMethodManager?.hideSoftInputFromWindow(focusedView.windowToken, 0) } } private fun restoreBackgroundFocus() { viewToRestoreFocus?.let { view -> view.requestFocus() inputMethodManager?.showSoftInput(view, 0) } viewToRestoreFocus = null } internal fun updateBottomSheetMetrics(behavior: BottomSheetBehavior) { val containerHeight = tryResolveMaxFormSheetHeight() check(containerHeight != null) { "[RNScreens] Failed to find window height during bottom sheet behaviour configuration" } val maxAllowedHeight = when (screen.isSheetFitToContents()) { true -> 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() } } false -> (screen.sheetDetents.highest() * containerHeight).toInt() } // For 3 detents, we need to add the top inset back here because we are calculating the offset // from the absolute top of the view, but our calculated max height (containerHeight) // has been reduced by this inset. val expandedOffsetFromTop = when (screen.sheetDetents.count) { 3 -> screen.sheetDetents.expandedOffsetFromTop(containerHeight, lastTopInset, screen.sheetShouldOverflowTopInset) else -> null } behavior.updateMetrics(maxAllowedHeight, expandedOffsetFromTop) } internal fun configureBottomSheetBehaviour( behavior: BottomSheetBehavior, keyboardState: KeyboardState = KeyboardNotVisible, selectedDetentIndex: Int = lastStableDetentIndex, ): BottomSheetBehavior { val containerHeight = tryResolveMaxFormSheetHeight() check(containerHeight != null) { "[RNScreens] Failed to find window height during bottom sheet behaviour configuration" } behavior.apply { isHideable = true isDraggable = true } // There is a guard internally that does not allow the callback to be duplicated. behavior.addBottomSheetCallback(sheetStateObserver) screen.footer?.registerWithSheetBehavior(behavior) return when (keyboardState) { is KeyboardNotVisible -> { when (screen.sheetDetents.count) { 1 -> behavior.apply { val height = if (screen.isSheetFitToContents()) { screen.sheetDetents.maxAllowedHeightForFitToContents(screen) } else { screen.sheetDetents.maxAllowedHeight(containerHeight) } useSingleDetent( maxAllowedHeight = height, enableManualInsetsAdjustmentWorkaround = screen.sheetShouldOverflowTopInset, ) } 2 -> behavior.useTwoDetents( state = screen.sheetDetents.sheetStateFromIndex( selectedDetentIndex, ), firstHeight = screen.sheetDetents.firstHeight(containerHeight), maxAllowedHeight = screen.sheetDetents.maxAllowedHeight(containerHeight), enableManualInsetsAdjustmentWorkaround = screen.sheetShouldOverflowTopInset, ) 3 -> behavior.useThreeDetents( state = screen.sheetDetents.sheetStateFromIndex( selectedDetentIndex, ), firstHeight = screen.sheetDetents.firstHeight(containerHeight), halfExpandedRatio = screen.sheetDetents.halfExpandedRatio(), maxAllowedHeight = screen.sheetDetents.maxAllowedHeight(containerHeight), expandedOffsetFromTop = screen.sheetDetents.expandedOffsetFromTop( containerHeight, lastTopInset, screen.sheetShouldOverflowTopInset, ), enableManualInsetsAdjustmentWorkaround = screen.sheetShouldOverflowTopInset, ) else -> throw IllegalStateException( "[RNScreens] Invalid detent count ${screen.sheetDetents.count}. Expected at most 3.", ) } } is KeyboardVisible -> { val isOnScreenKeyboardVisible = keyboardState.height != 0 when (screen.sheetDetents.count) { 1 -> behavior.apply { addBottomSheetCallback(keyboardHandlerCallback) } 2 -> behavior.apply { if (isOnScreenKeyboardVisible) { useTwoDetents( state = BottomSheetBehavior.STATE_EXPANDED, ) } else { useTwoDetents() } addBottomSheetCallback(keyboardHandlerCallback) } 3 -> behavior.apply { if (isOnScreenKeyboardVisible) { useThreeDetents( state = BottomSheetBehavior.STATE_EXPANDED, ) } else { useThreeDetents() } addBottomSheetCallback(keyboardHandlerCallback) } else -> throw IllegalStateException( "[RNScreens] Invalid detent count ${screen.sheetDetents.count}. Expected at most 3.", ) } } is KeyboardDidHide -> { // Here we assume that the keyboard was either closed explicitly by user, // or the user dragged the sheet down. In any case the state should // stay unchanged. behavior.removeBottomSheetCallback(keyboardHandlerCallback) when (screen.sheetDetents.count) { 1 -> behavior.apply { val height = if (screen.isSheetFitToContents()) { screen.sheetDetents.maxAllowedHeightForFitToContents(screen) } else { screen.sheetDetents.maxAllowedHeight(containerHeight) } useSingleDetent( maxAllowedHeight = height, forceExpandedState = false, enableManualInsetsAdjustmentWorkaround = screen.sheetShouldOverflowTopInset, ) } 2 -> behavior.useTwoDetents( firstHeight = screen.sheetDetents.firstHeight(containerHeight), maxAllowedHeight = screen.sheetDetents.maxAllowedHeight(containerHeight), enableManualInsetsAdjustmentWorkaround = screen.sheetShouldOverflowTopInset, ) 3 -> behavior.useThreeDetents( firstHeight = screen.sheetDetents.firstHeight(containerHeight), halfExpandedRatio = screen.sheetDetents.halfExpandedRatio(), maxAllowedHeight = screen.sheetDetents.maxAllowedHeight(containerHeight), expandedOffsetFromTop = screen.sheetDetents.expandedOffsetFromTop( containerHeight, lastTopInset, screen.sheetShouldOverflowTopInset, ), enableManualInsetsAdjustmentWorkaround = screen.sheetShouldOverflowTopInset, ) else -> throw IllegalStateException( "[RNScreens] Invalid detent count ${screen.sheetDetents.count}. Expected at most 3.", ) } } } } // This is listener function, not the view's. override fun onApplyWindowInsets( v: View, insets: WindowInsetsCompat, ): WindowInsetsCompat { val isImeVisible = insets.isVisible(WindowInsetsCompat.Type.ime()) val imeInset = insets.getInsets(WindowInsetsCompat.Type.ime()) val systemBarsInsets = insets.getInsets(WindowInsetsCompat.Type.systemBars()) val displayCutoutInsets = insets.getInsets(WindowInsetsCompat.Type.displayCutout()) // We save the top inset (status bar height or display cutout) to later // subtract it from the window height during sheet size calculations. // This ensures the sheet respects the safe area. lastTopInset = maxOf(systemBarsInsets.top, displayCutoutInsets.top) if (isImeVisible) { isKeyboardVisible = true keyboardState = KeyboardVisible(imeInset.bottom) sheetBehavior?.let { this.configureBottomSheetBehaviour(it, keyboardState) } } else { sheetBehavior?.let { if (isKeyboardVisible) { this.configureBottomSheetBehaviour(it, KeyboardDidHide) } else if (keyboardState != KeyboardNotVisible) { this.configureBottomSheetBehaviour(it, KeyboardNotVisible) } } keyboardState = KeyboardNotVisible 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() } private fun shouldDismissSheetInState( @BottomSheetBehavior.State state: Int, ) = state == BottomSheetBehavior.STATE_HIDDEN internal fun tryResolveMaxFormSheetHeight(): Int? = if (screen.sheetShouldOverflowTopInset) { tryResolveContainerHeight() } else { tryResolveSafeAreaSpaceForSheet() } /** * This method tries to resolve the maximum height available for the sheet content, * accounting for the system top inset. */ private fun tryResolveSafeAreaSpaceForSheet(): Int? = tryResolveContainerHeight()?.let { it - lastTopInset } /** * This method might return slightly different values depending on code path, * but during testing I've found this effect negligible. For practical purposes * this is acceptable. */ private fun tryResolveContainerHeight(): Int? { screen.container?.let { return it.height } val context = screen.reactContext context .resources ?.displayMetrics ?.heightPixels ?.let { return it } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { (context.getSystemService(Context.WINDOW_SERVICE) as? WindowManager) ?.currentWindowMetrics ?.bounds ?.height() ?.let { return it } } return null } // Sheet entering/exiting animations internal fun createSheetEnterAnimator(sheetAnimationContext: SheetAnimationContext): Animator = screen.sheetAnimationCoordinator.createSheetEnterAnimator(sheetAnimationContext) internal fun createSheetExitAnimator(sheetAnimationContext: SheetAnimationContext): Animator = screen.sheetAnimationCoordinator.createSheetExitAnimator(sheetAnimationContext) internal fun handleKeyboardInsetsProgress(insets: WindowInsetsCompat) = screen.sheetAnimationCoordinator.handleKeyboardInsetsProgress(insets) internal fun notifyKeyboardAnimationStart() = screen.sheetAnimationCoordinator.notifyKeyboardAnimationStart() internal fun notifyKeyboardAnimationEnd() = screen.sheetAnimationCoordinator.notifyKeyboardAnimationEnd() private inner class KeyboardHandler : BottomSheetBehavior.BottomSheetCallback() { override fun onStateChanged( bottomSheet: View, newState: Int, ) { if (newState == BottomSheetBehavior.STATE_COLLAPSED) { val isImeVisible = WindowInsetsCompat .toWindowInsetsCompat(bottomSheet.rootWindowInsets) .isVisible(WindowInsetsCompat.Type.ime()) if (isImeVisible) { // Does it not interfere with React Native focus mechanism? In any case I'm not aware // of different way of hiding the keyboard. // https://stackoverflow.com/questions/1109022/how-can-i-close-hide-the-android-soft-keyboard-programmatically // https://developer.android.com/develop/ui/views/touch-and-input/keyboard-input/visibility // I want to be polite here and request focus before dismissing the keyboard, // however even if it fails I want to try to hide the keyboard. This sometimes works... bottomSheet.requestFocus() inputMethodManager?.hideSoftInputFromWindow(bottomSheet.windowToken, 0) } } } override fun onSlide( bottomSheet: View, slideOffset: Float, ) = Unit } private inner class SheetStateObserver : BottomSheetBehavior.BottomSheetCallback() { override fun onStateChanged( bottomSheet: View, newState: Int, ) { this@SheetDelegate.onSheetStateChanged(newState) } override fun onSlide( bottomSheet: View, slideOffset: Float, ) = Unit } internal data class SheetAnimationContext( val fragment: ScreenStackFragment, val screen: Screen, val coordinatorLayout: CoordinatorLayout, val dimmingDelegate: DimmingViewManager, ) companion object { const val TAG = "SheetDelegate" } }