diff --git a/android/build.gradle b/android/build.gradle index 14e992f..d6a3caf 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -67,5 +67,6 @@ android { dependencies { implementation "com.facebook.react:react-android" + implementation "androidx.activity:activity:1.6.0" implementation "androidx.dynamicanimation:dynamicanimation:1.0.0" } diff --git a/android/src/main/java/com/swmansion/reactnativebottomsheet/BottomSheetHostView.kt b/android/src/main/java/com/swmansion/reactnativebottomsheet/BottomSheetHostView.kt index a9e1e0e..938fe4b 100644 --- a/android/src/main/java/com/swmansion/reactnativebottomsheet/BottomSheetHostView.kt +++ b/android/src/main/java/com/swmansion/reactnativebottomsheet/BottomSheetHostView.kt @@ -31,6 +31,7 @@ import kotlin.math.roundToInt private enum class DetentKind { POINTS, + PERCENTAGE, CONTENT, } @@ -55,6 +56,8 @@ interface BottomSheetViewListener { fun onSettle(index: Int) fun onPositionChange(position: Double, index: Double) + + fun onRequestClose() } class BottomSheetHostView(context: Context) : ReactViewGroup(context), NestedScrollingParent3 { @@ -84,6 +87,17 @@ class BottomSheetHostView(context: Context) : ReactViewGroup(context), NestedScr */ var interactionListener: ((Boolean) -> Unit)? = null + /** + * Notifies the presentation coordinator whenever the target becomes a valid open or closed + * detent. This deliberately reports the target state rather than a copied detent height: a portal + * Back callback must be re-enabled as soon as React retargets a closed sheet to an open detent. + */ + var requestCloseTargetChangedListener: (() -> Unit)? = null + set(value) { + field = value + value?.invoke() + } + // MARK: - State private var rawDetentSpecs: List = emptyList() @@ -144,9 +158,12 @@ class BottomSheetHostView(context: Context) : ReactViewGroup(context), NestedScr private var pendingInitialContentDetentObserver: ViewTreeObserver? = null private var pendingInitialContentDetentPreDrawListener: ViewTreeObserver.OnPreDrawListener? = null private var pendingInitialContentDetentFrames = 0 + private var detentResolutionReady = false private val contentHeightMarkerLayoutListener = - View.OnLayoutChangeListener { _, _, _, _, _, _, _, _, _ -> refreshDetentsFromLayout() } + View.OnLayoutChangeListener { _, _, _, _, _, _, _, _, _ -> + refreshDetentsFromLayout() + } init { clipChildren = false @@ -222,6 +239,11 @@ class BottomSheetHostView(context: Context) : ReactViewGroup(context), NestedScr layoutSheetContainer(width, height) } } + private val resolveDetentsAfterAttach = Runnable { + if (isAttachedToWindow && !detentResolutionReady && width > 0 && height > 0) { + resolveHostLayout(width, height) + } + } override fun requestLayout() { super.requestLayout() @@ -236,11 +258,18 @@ class BottomSheetHostView(context: Context) : ReactViewGroup(context), NestedScr override fun onAttachedToWindow() { super.onAttachedToWindow() + detentResolutionReady = false + notifyRequestCloseTargetChanged() // Native geometry (cap, frame) is derived from the window; recompute on // (re)attach — including the inline<->overlay reparent — and ask for a - // fresh insets pass. + // fresh insets pass. Fabric may assign unchanged bounds before attaching + // the view, in which case Android does not owe us another onSizeChanged or + // onLayout callback. Schedule the same resolution pass explicitly so + // close-request eligibility cannot remain invalid after the attach. requestApplyInsets() recomputeNativeGeometry() + removeCallbacks(resolveDetentsAfterAttach) + post(resolveDetentsAfterAttach) // A re-attach gives us a fresh, live ViewTreeObserver; the previous one was // dropped on detach. Resume observing if the initial snap is still pending. if (pendingInitialContentDetentSnap) { @@ -260,6 +289,9 @@ class BottomSheetHostView(context: Context) : ReactViewGroup(context), NestedScr } override fun onDetachedFromWindow() { + removeCallbacks(resolveDetentsAfterAttach) + detentResolutionReady = false + notifyRequestCloseTargetChanged() // Release the listener from the soon-to-be-replaced observer and clear our // references so a later re-attach registers on the new live observer. removePendingInitialContentDetentObserver() @@ -272,11 +304,17 @@ class BottomSheetHostView(context: Context) : ReactViewGroup(context), NestedScr val h = bottom - top if (w <= 0 || h <= 0) return + resolveHostLayout(w, h) + } + + private fun resolveHostLayout(w: Int, h: Int) { // The cap depends on this view's window position (top-inset overlap), // which can change without a resize. recomputeNativeGeometry() refreshContentHeightMarker() refreshDetentsFromLayout() + detentResolutionReady = true + notifyRequestCloseTargetChanged() layoutSheetContainer(w, h) if (!hasLaidOut && detentSpecs.isNotEmpty()) { @@ -353,17 +391,19 @@ class BottomSheetHostView(context: Context) : ReactViewGroup(context), NestedScr // MARK: - Prop setters fun setDetents(raw: List>) { - rawDetentSpecs = - raw.mapNotNull { dict -> - val value = (dict["value"] as? Number)?.toDouble() ?: return@mapNotNull null - val kind = - when ((dict["kind"] as? String)?.lowercase()) { - "content" -> DetentKind.CONTENT - else -> DetentKind.POINTS - } - val programmatic = dict["programmatic"] as? Boolean ?: false - RawDetentSpec(value = (value * density).toFloat(), kind = kind, programmatic = programmatic) - } + rawDetentSpecs = raw.mapNotNull { dict -> + val value = (dict["value"] as? Number)?.toDouble() ?: return@mapNotNull null + val kind = + when (dict["kind"] as? String) { + "content" -> DetentKind.CONTENT + "percentage" -> DetentKind.PERCENTAGE + else -> DetentKind.POINTS + } + val programmatic = dict["programmatic"] as? Boolean ?: false + val nativeValue = + if (kind == DetentKind.POINTS) (value * density).toFloat() else value.toFloat() + RawDetentSpec(value = nativeValue, kind = kind, programmatic = programmatic) + } refreshDetentsFromLayout() } @@ -373,6 +413,7 @@ class BottomSheetHostView(context: Context) : ReactViewGroup(context), NestedScr if (!hasLaidOut) { pendingIndex = newIndex targetIndex = newIndex + notifyRequestCloseTargetChanged() return } @@ -382,6 +423,7 @@ class BottomSheetHostView(context: Context) : ReactViewGroup(context), NestedScr // reflected, then either complete it now (e.g. a points detent that is // already resolvable) or keep waiting for the content to measure. targetIndex = newIndex.coerceIn(0, detentSpecs.size - 1) + notifyRequestCloseTargetChanged() if (!trySnapPendingInitialContentDetent()) { observePendingInitialContentDetent() } @@ -428,6 +470,7 @@ class BottomSheetHostView(context: Context) : ReactViewGroup(context), NestedScr val height = when (spec.kind) { DetentKind.POINTS -> spec.value + DetentKind.PERCENTAGE -> maxHeight * spec.value DetentKind.CONTENT -> measuredContentHeight ?: unresolvedContentDetentHeight(index, maxHeight) }.coerceIn(0f, maxHeight) @@ -444,9 +487,16 @@ class BottomSheetHostView(context: Context) : ReactViewGroup(context), NestedScr } private fun unresolvedContentDetentHeight(index: Int, maxHeight: Float): Float { - val nextPointHeight = - rawDetentSpecs.drop(index + 1).firstOrNull { it.kind == DetentKind.POINTS }?.value - return (nextPointHeight ?: maxHeight).coerceIn(0f, maxHeight) + val nextBound = + rawDetentSpecs.drop(index + 1).firstOrNull { it.kind != DetentKind.CONTENT } + ?: return maxHeight + val nextBoundHeight = + when (nextBound.kind) { + DetentKind.POINTS -> nextBound.value + DetentKind.PERCENTAGE -> maxHeight * nextBound.value + DetentKind.CONTENT -> maxHeight + } + return nextBoundHeight.coerceIn(0f, maxHeight) } private fun refreshDetentsFromLayout() { @@ -455,12 +505,14 @@ class BottomSheetHostView(context: Context) : ReactViewGroup(context), NestedScr activeDragDetentSpecs = null } if (hasLaidOut && isInvalidContentDetentTarget(targetIndex)) { + notifyRequestCloseTargetChanged() updateScrim() return } val resolvedDetents = resolveDetentSpecs() if (resolvedDetents == detentSpecs && resolvedMaxDetentHeight() == lastAppliedMaxDetentHeight) { + notifyRequestCloseTargetChanged() if (trySnapPendingInitialContentDetent()) { return } @@ -487,6 +539,7 @@ class BottomSheetHostView(context: Context) : ReactViewGroup(context), NestedScr if (hasLaidOut && !isPanning) { targetIndex = targetIndex.coerceIn(0, detentSpecs.size - 1) + notifyRequestCloseTargetChanged() val newMaxHeight = resolvedMaxDetentHeight() val targetTy = translationY(targetIndex) if (trySnapPendingInitialContentDetent()) { @@ -548,6 +601,7 @@ class BottomSheetHostView(context: Context) : ReactViewGroup(context), NestedScr } requestLayout() + notifyRequestCloseTargetChanged() updateScrim() } @@ -591,23 +645,22 @@ class BottomSheetHostView(context: Context) : ReactViewGroup(context), NestedScr if (!observer.isAlive) return pendingInitialContentDetentFrames = 0 - val listener = - ViewTreeObserver.OnPreDrawListener { - refreshContentHeightMarker() - refreshDetentsFromLayout() - // refreshDetentsFromLayout() completes and stops observing via - // trySnapPendingInitialContentDetent() once the target is measurable. - // If it is still pending, this frame was unproductive: bound how many - // such frames we spend so a content detent that never becomes - // measurable cannot keep us redrawing forever. - if ( - pendingInitialContentDetentSnap && - ++pendingInitialContentDetentFrames >= MAX_PENDING_INITIAL_CONTENT_DETENT_FRAMES - ) { - removePendingInitialContentDetentObserver() - } - true + val listener = ViewTreeObserver.OnPreDrawListener { + refreshContentHeightMarker() + refreshDetentsFromLayout() + // refreshDetentsFromLayout() completes and stops observing via + // trySnapPendingInitialContentDetent() once the target is measurable. + // If it is still pending, this frame was unproductive: bound how many + // such frames we spend so a content detent that never becomes + // measurable cannot keep us redrawing forever. + if ( + pendingInitialContentDetentSnap && + ++pendingInitialContentDetentFrames >= MAX_PENDING_INITIAL_CONTENT_DETENT_FRAMES + ) { + removePendingInitialContentDetentObserver() } + true + } // Keep the exact observer instance used for registration. Android can // replace a ViewTreeObserver across attach/detach boundaries, and listeners @@ -683,6 +736,29 @@ class BottomSheetHostView(context: Context) : ReactViewGroup(context), NestedScr private val isTargetingClosedDetent: Boolean get() = closedIndex?.let { targetIndex == it } == true + // Close-request eligibility follows this resolved target—not the transient + // animated position—so targeting zero disables input immediately while a + // closing animation is still visible. Keep this as a host-owned computed + // value instead of mirroring the height in BottomSheetView: the latter can + // miss the closed -> open retarget during a portal update. + val isRequestCloseTargetOpen: Boolean + get() { + if ( + !detentResolutionReady || + !isAttachedToWindow || + width <= 0 || + height <= 0 || + isInvalidContentDetentTarget(targetIndex) + ) { + return false + } + return detentSpecs.getOrNull(targetIndex)?.height?.let { it > 0f } == true + } + + private fun notifyRequestCloseTargetChanged() { + requestCloseTargetChangedListener?.invoke() + } + private fun snapCandidateIndices(includeIndex: Int? = null): List { val indices = detentSpecs.indices.filter { !detentSpecs[it].programmatic }.toMutableList() if ( @@ -867,6 +943,7 @@ class BottomSheetHostView(context: Context) : ReactViewGroup(context), NestedScr ) { if (index < 0 || index >= detentSpecs.size) return targetIndex = index + notifyRequestCloseTargetChanged() if (!isTargetingClosedDetent) { suppressScrimForClosingTarget = false } @@ -1551,6 +1628,7 @@ class BottomSheetHostView(context: Context) : ReactViewGroup(context), NestedScr velocityTracker = null removeCallbacks(sheetChildrenLayoutPass) sheetChildrenLayoutEnqueued = false + removeCallbacks(resolveDetentsAfterAttach) nativeCapPx = Float.NaN lastAppliedMaxDetentHeight = Float.NaN lastGeometryStateWidth = 0 @@ -1563,6 +1641,8 @@ class BottomSheetHostView(context: Context) : ReactViewGroup(context), NestedScr rawDetentSpecs = emptyList() detentSpecs = emptyList() targetIndex = 0 + detentResolutionReady = false + notifyRequestCloseTargetChanged() pendingIndex = null hasLaidOut = false isPanning = false @@ -1580,6 +1660,8 @@ class BottomSheetHostView(context: Context) : ReactViewGroup(context), NestedScr sheetContainer.removeAllViews() stateWrapper = null lastShadowOffsetY = Float.NaN + requestCloseTargetChangedListener = null + listener = null } private fun updateScrim(position: Float = currentSheetHeight()) { diff --git a/android/src/main/java/com/swmansion/reactnativebottomsheet/BottomSheetView.kt b/android/src/main/java/com/swmansion/reactnativebottomsheet/BottomSheetView.kt index 53d9e7c..4875f42 100644 --- a/android/src/main/java/com/swmansion/reactnativebottomsheet/BottomSheetView.kt +++ b/android/src/main/java/com/swmansion/reactnativebottomsheet/BottomSheetView.kt @@ -5,10 +5,12 @@ package com.swmansion.reactnativebottomsheet import android.annotation.SuppressLint import android.app.Activity import android.content.Context +import android.content.DialogInterface import android.graphics.Color import android.graphics.drawable.ColorDrawable import android.os.Build import android.view.Gravity +import android.view.KeyEvent import android.view.MotionEvent import android.view.View import android.view.ViewGroup @@ -18,7 +20,10 @@ import androidx.activity.ComponentActivity import androidx.activity.ComponentDialog import androidx.activity.OnBackPressedCallback import androidx.core.view.WindowCompat +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver import com.facebook.react.bridge.LifecycleEventListener +import com.facebook.react.common.LifecycleState import com.facebook.react.config.ReactFeatureFlags import com.facebook.react.uimanager.JSPointerDispatcher import com.facebook.react.uimanager.JSTouchDispatcher @@ -49,13 +54,39 @@ class BottomSheetView(context: Context) : ReactViewGroup(context), LifecycleEven private var overlayDialog: ComponentDialog? = null private var overlayRoot: BottomSheetDialogRootView? = null private var nativeOverlay = false + private var overlayPresentationFailed = false // Cached only while a dialog is present, so per-frame interaction callbacks // don't thrash the window flags. private var overlayInteractive: Boolean? = null + private var overlayFocusable: Boolean? = null + private var overlayFallbackBackCallback: OnBackPressedCallback? = null + private var overlayRequestCloseBackCallback: OnBackPressedCallback? = null + private var overlayHostActivity: ComponentActivity? = null + + private var requestCloseEnabled = false + private var requestCloseEligible = false + private var isViewAttached = false + private var isHostActive = themedReactContext?.lifecycleState == LifecycleState.RESUMED + + private var portalRequestCloseActivity: ComponentActivity? = null + private var portalBackCallback: OnBackPressedCallback? = null + private var portalEscapeWindowRegistration: PortalEscapeWindowCallbackRegistration? = null + private val escapeRequestCloseDispatcher = EscapeRequestCloseDispatcher() + private val portalLifecycleObserver = LifecycleEventObserver { owner, event -> + if (event == Lifecycle.Event.ON_DESTROY && owner === portalRequestCloseActivity) { + clearPortalRequestCloseActivity() + } + updateRequestCloseHandling() + } + private val portalEscapeWindowListener = { event: KeyEvent -> + dispatchPortalEscape(event) + } + private val syncPortalActivityRunnable = Runnable { syncPortalRequestCloseActivity() } init { pointerEvents = PointerEvents.BOX_NONE host.interactionListener = { interactive -> updateOverlayTouchability(interactive) } + host.requestCloseTargetChangedListener = ::updateRequestCloseHandling attachHostInline() // The overlay dialog's window is bound to the host activity, so we follow the // activity lifecycle: tear the window down before the activity is destroyed @@ -121,6 +152,7 @@ class BottomSheetView(context: Context) : ReactViewGroup(context), LifecycleEven get() = host.modal set(value) { host.modal = value + updateRequestCloseHandling() } var scrollableExpandNegotiation: Int @@ -145,14 +177,64 @@ class BottomSheetView(context: Context) : ReactViewGroup(context), LifecycleEven fun setScrimOpacities(values: List) = host.setScrimOpacities(values) + fun setRequestCloseEnabled(value: Boolean) { + if (value == requestCloseEnabled) return + requestCloseEnabled = value + updateRequestCloseHandling() + } + fun setNativeOverlay(value: Boolean) { - if (value == nativeOverlay) return + if (value == nativeOverlay) { + if (!value && overlayPresentationFailed) { + overlayPresentationFailed = false + updateRequestCloseHandling() + } + return + } nativeOverlay = value + overlayPresentationFailed = false if (value) presentOverlay() else dismissOverlay() + syncPortalRequestCloseActivity() + updateRequestCloseHandling() } // MARK: - Inline vs overlay presentation + override fun onAttachedToWindow() { + super.onAttachedToWindow() + isViewAttached = true + syncPortalRequestCloseActivity() + post(syncPortalActivityRunnable) + updateRequestCloseHandling() + overlayDialog?.let { dialog -> + installOverlayInputHandlers(dialog) + } + if (nativeOverlay && overlayDialog == null) { + presentOverlay() + } + } + + override fun onDetachedFromWindow() { + isViewAttached = false + removeCallbacks(syncPortalActivityRunnable) + clearPortalRequestCloseActivity() + clearOverlayInputHandlers(overlayDialog) + escapeRequestCloseDispatcher.clear() + updateRequestCloseHandling() + super.onDetachedFromWindow() + } + + override fun onWindowFocusChanged(hasWindowFocus: Boolean) { + super.onWindowFocusChanged(hasWindowFocus) + syncPortalRequestCloseActivity() + updateRequestCloseHandling() + } + + override fun dispatchKeyEvent(event: KeyEvent): Boolean { + if (!nativeOverlay && dispatchPortalEscape(event)) return true + return super.dispatchKeyEvent(event) + } + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { super.onMeasure(widthMeasureSpec, heightMeasureSpec) if (host.parent === this) { @@ -181,7 +263,10 @@ class BottomSheetView(context: Context) : ReactViewGroup(context), LifecycleEven val activity = reactContext?.currentActivity if (activity == null || activity.isFinishing || activity.isDestroyed) { // Without an activity there is no window to host the dialog; stay inline. + overlayHostActivity = null nativeOverlay = false + overlayPresentationFailed = true + updateRequestCloseHandling() return } (host.parent as? ViewGroup)?.removeView(host) @@ -208,49 +293,49 @@ class BottomSheetView(context: Context) : ReactViewGroup(context), LifecycleEven ) dialog.setCancelable(false) dialog.window?.let { configureOverlayWindow(it, activity) } - // Stay unopinionated about the system back gesture, exactly like the inline - // (portal-based) modal, which registers no back handling and leaves it to the - // consumer. The dialog is a separate focusable window that would otherwise - // consume the back press and dismiss itself, so instead of acting on it we - // forward it to the host activity. That runs whatever the consumer wired up - // (JS `BackHandler`, React Navigation, …), just as a back press would for an - // inline modal. - dialog.onBackPressedDispatcher.addCallback( - dialog, - object : OnBackPressedCallback(true) { - override fun handleOnBackPressed() { - (activity as? ComponentActivity)?.onBackPressedDispatcher?.onBackPressed() - } - }, - ) overlayInteractive = null + overlayFocusable = null overlayRoot = root overlayDialog = dialog + overlayHostActivity = activity as? ComponentActivity + installOverlayInputHandlers(dialog) try { dialog.show() + overlayPresentationFailed = false dialog.window?.let { configureOverlayWindow(it, activity) } + updateRequestCloseHandling() } catch (_: RuntimeException) { // Show failed (e.g. the activity went away mid-present). Dismiss so the // partially-created window can't leak, then fall back to inline. + clearOverlayInputHandlers(dialog) runCatching { if (dialog.isShowing) dialog.dismiss() } overlayDialog = null overlayRoot = null overlayInteractive = null + overlayFocusable = null + overlayHostActivity = null nativeOverlay = false + overlayPresentationFailed = true (host.parent as? ViewGroup)?.removeView(host) attachHostInline() + updateRequestCloseHandling() } } private fun dismissOverlay() { - overlayDialog?.let { dialog -> + val dialog = overlayDialog + clearOverlayInputHandlers(dialog) + dialog?.let { (host.parent as? ViewGroup)?.removeView(host) - if (dialog.isShowing) dialog.dismiss() + if (it.isShowing) it.dismiss() } overlayDialog = null overlayRoot = null overlayInteractive = null + overlayFocusable = null + overlayHostActivity = null attachHostInline() + updateRequestCloseHandling() } private fun currentEventDispatcher(): EventDispatcher? = @@ -274,8 +359,13 @@ class BottomSheetView(context: Context) : ReactViewGroup(context), LifecycleEven // until the sheet animates open. Keep the dialog window alpha at 0 while it // is non-interactive so Android's untrusted-touch occlusion check does not // treat the full-screen dialog as covering the IME. - window.addFlags(NON_INTERACTIVE_FLAGS) + window.addFlags(WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE) + window.addFlags(WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE) window.setOverlayWindowAlpha(interactive = false) + window.setSoftInputMode( + WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE or + WindowManager.LayoutParams.SOFT_INPUT_STATE_UNCHANGED + ) window.setLayout( WindowManager.LayoutParams.MATCH_PARENT, WindowManager.LayoutParams.MATCH_PARENT, @@ -292,15 +382,14 @@ class BottomSheetView(context: Context) : ReactViewGroup(context), LifecycleEven WindowCompat.setDecorFitsSystemWindows(this, false) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { - attributes = - attributes.apply { - layoutInDisplayCutoutMode = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_ALWAYS - } else { - WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES - } - } + attributes = attributes.apply { + layoutInDisplayCutoutMode = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_ALWAYS + } else { + WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES + } + } } @Suppress("DEPRECATION") @@ -329,41 +418,293 @@ class BottomSheetView(context: Context) : ReactViewGroup(context), LifecycleEven } } - /** - * Toggles the overlay window's touchability/focusability with the sheet's interactivity. While - * the sheet is closed the window is transparent to touch and focus, so the screen behind stays - * usable; once it animates open or shows its scrim the window captures input (the scrim handles - * dismissal). - */ + /** Keeps touch pass-through tied only to the existing sheet/scrim interaction state. */ private fun updateOverlayTouchability(interactive: Boolean) { - val window = overlayDialog?.window ?: return if (interactive == overlayInteractive) return overlayInteractive = interactive - if (interactive) { - window.clearFlags(NON_INTERACTIVE_FLAGS) - } else { - window.addFlags(NON_INTERACTIVE_FLAGS) - } - window.setOverlayWindowAlpha(interactive) + updateOverlayWindowInputFlags() } private fun Window.setOverlayWindowAlpha(interactive: Boolean) { attributes = attributes.apply { alpha = if (interactive) 1f else 0f } } + // MARK: - Request close + + /** + * Emits a controlled request only. Native input handling must never dismiss the dialog, select a + * detent, or mutate the sheet's target. + */ + fun emitRequestCloseIfEligible(): Boolean { + if (!isRequestCloseEligible(requestCloseEligibilityState())) { + updateRequestCloseHandling() + return false + } + val currentListener = listener ?: return false + currentListener.onRequestClose() + return true + } + + private fun updateRequestCloseHandling() { + val state = requestCloseEligibilityState() + requestCloseEligible = isRequestCloseEligible(state) + updatePortalBackHandler() + updatePortalEscapeWindowListener() + overlayRequestCloseBackCallback?.isEnabled = requestCloseEligible + updateOverlayWindowInputFlags() + } + + private fun requestCloseEligibilityState(): RequestCloseEligibility { + val presentationAttached = + if (nativeOverlay) { + overlayDialog?.isShowing == true + } else { + !overlayPresentationFailed + } + val lifecycleOwner = + if (nativeOverlay) { + overlayDialog + } else { + portalRequestCloseActivity + } + val lifecycleActive = + lifecycleOwner?.lifecycle?.currentState?.isAtLeast(Lifecycle.State.STARTED) == true + + return RequestCloseEligibility( + isAttached = isViewAttached && presentationAttached, + isActive = isHostActive && lifecycleActive, + isModal = modal, + isEnabled = requestCloseEnabled, + isTargetOpen = host.isRequestCloseTargetOpen, + ) + } + + private fun syncPortalRequestCloseActivity() { + val activity = + (themedReactContext?.currentActivity as? ComponentActivity)?.takeIf { + isViewAttached && + !nativeOverlay && + !it.isFinishing && + !it.isDestroyed && + it.lifecycle.currentState != Lifecycle.State.DESTROYED + } + if (activity !== portalRequestCloseActivity) { + removePortalBackHandler() + removePortalEscapeWindowListener() + portalRequestCloseActivity?.lifecycle?.removeObserver(portalLifecycleObserver) + portalRequestCloseActivity = activity + activity?.lifecycle?.addObserver(portalLifecycleObserver) + } + updateRequestCloseHandling() + } + + private fun clearPortalRequestCloseActivity() { + removePortalBackHandler() + removePortalEscapeWindowListener() + portalRequestCloseActivity?.lifecycle?.removeObserver(portalLifecycleObserver) + portalRequestCloseActivity = null + } + + /** + * Portal Back must be registered with the Activity dispatcher. On target SDK 36 a committed + * predictive Back can go straight to this dispatcher without producing React Native's + * `hardwareBackPress` event or a hierarchy key event. Register only while fully eligible so this + * callback is newer than the screen/navigation callbacks it temporarily supersedes. + */ + private fun updatePortalBackHandler() { + val activity = portalRequestCloseActivity + val required = !nativeOverlay && !overlayPresentationFailed && requestCloseEligible + if (required && activity != null) { + if (portalBackCallback != null) return + val callback = + object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + // Eligibility can change after this callback was selected for a Back gesture. The + // committed gesture is still ours, but must become a no-op instead of being + // re-dispatched and disrupting predictive Back. + emitRequestCloseIfEligible() + } + } + portalBackCallback = callback + activity.onBackPressedDispatcher.addCallback(activity, callback) + return + } + removePortalBackHandler() + } + + private fun removePortalBackHandler() { + val callback = portalBackCallback ?: return + portalBackCallback = null + callback.remove() + } + + private fun updatePortalEscapeWindowListener() { + val required = + !nativeOverlay && + !overlayPresentationFailed && + (requestCloseEligible || escapeRequestCloseDispatcher.hasCapturedPress) + val window = portalRequestCloseActivity?.window + if (required && window != null) { + val currentRegistration = portalEscapeWindowRegistration + if (currentRegistration?.window === window) return + removePortalEscapeWindowListener() + portalEscapeWindowRegistration = + PortalEscapeWindowCallbackRegistry.register(window, portalEscapeWindowListener) + return + } + removePortalEscapeWindowListener() + } + + private fun removePortalEscapeWindowListener() { + portalEscapeWindowRegistration?.remove() + portalEscapeWindowRegistration = null + // The portal and dialog share the sequence handler. Removing a stale portal registration + // while a native overlay is active must not abandon a press owned by the dialog window. + if (!nativeOverlay) { + escapeRequestCloseDispatcher.clear() + } + } + + private fun dispatchPortalEscape(event: KeyEvent): Boolean { + if (nativeOverlay || overlayPresentationFailed) return false + return dispatchEscape(event) + } + + private fun dispatchEscape(event: KeyEvent): Boolean { + val hadCapturedPress = escapeRequestCloseDispatcher.hasCapturedPress + val handled = + escapeRequestCloseDispatcher.dispatch( + eventToken = requestCloseKeyEventToken(event), + pressToken = requestCloseKeyPressToken(event), + keyCode = event.keyCode, + action = event.action, + repeatCount = event.repeatCount, + hasModifiers = !event.hasNoModifiers(), + isCanceled = event.isCanceled, + isRequestCloseEligible = { + isRequestCloseEligible(requestCloseEligibilityState()) + }, + emitRequestCloseIfEligible = ::emitRequestCloseIfEligible, + ) + + if (hadCapturedPress && !escapeRequestCloseDispatcher.hasCapturedPress) { + updatePortalEscapeWindowListener() + updateOverlayWindowInputFlags() + } + return handled + } + + private fun requestCloseKeyEventToken(event: KeyEvent) = + RequestCloseKeyEventToken( + downTime = event.downTime, + eventTime = event.eventTime, + deviceId = event.deviceId, + source = event.source, + keyCode = event.keyCode, + scanCode = event.scanCode, + action = event.action, + repeatCount = event.repeatCount, + metaState = event.metaState, + flags = event.flags, + ) + + private fun requestCloseKeyPressToken(event: KeyEvent) = + RequestCloseKeyPressToken( + downTime = event.downTime, + deviceId = event.deviceId, + source = event.source, + keyCode = event.keyCode, + scanCode = event.scanCode, + ) + + /** + * Touchability stays coupled to the existing interaction/scrim state. Keyboard focusability is + * independently enabled for an eligible request, including when the configured scrim opacity is + * zero. A captured Escape keeps focus until its terminal up. Returning false for every non-Escape + * event lets focused inputs keep normal key routing. + */ + private fun updateOverlayWindowInputFlags() { + val window = overlayDialog?.window ?: return + val touchable = overlayInteractive == true + val focusable = + touchable || + (nativeOverlay && (requestCloseEligible || escapeRequestCloseDispatcher.hasCapturedPress)) + + if (touchable) { + window.clearFlags(WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE) + } else { + window.addFlags(WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE) + } + + if (focusable != overlayFocusable) { + overlayFocusable = focusable + if (focusable) { + window.clearFlags(WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE) + } else { + window.addFlags(WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE) + } + } + + window.setOverlayWindowAlpha(touchable || (nativeOverlay && requestCloseEligible)) + } + + private fun clearOverlayInputHandlers(dialog: ComponentDialog?) { + dialog?.setOnKeyListener(null) + overlayFallbackBackCallback?.remove() + overlayFallbackBackCallback = null + overlayRequestCloseBackCallback?.remove() + overlayRequestCloseBackCallback = null + escapeRequestCloseDispatcher.clear() + } + + private fun installOverlayInputHandlers(dialog: ComponentDialog) { + clearOverlayInputHandlers(dialog) + val fallbackBackCallback = + object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + overlayHostActivity?.onBackPressedDispatcher?.onBackPressed() + } + } + overlayFallbackBackCallback = fallbackBackCallback + dialog.onBackPressedDispatcher.addCallback(dialog, fallbackBackCallback) + + val requestCloseBackCallback = + object : OnBackPressedCallback(false) { + override fun handleOnBackPressed() { + // A focusable dialog owns Back. If eligibility changed after the gesture began, keep + // the non-cancelable overlay open and finish handling as a no-op. + emitRequestCloseIfEligible() + } + } + overlayRequestCloseBackCallback = requestCloseBackCallback + dialog.onBackPressedDispatcher.addCallback(dialog, requestCloseBackCallback) + requestCloseBackCallback.isEnabled = requestCloseEligible + dialog.setOnKeyListener(DialogInterface.OnKeyListener { _, _, event -> dispatchEscape(event) }) + } + // MARK: - Activity lifecycle override fun onHostResume() { + isHostActive = true // Restore the overlay if it was torn down while the activity was gone but the // sheet should still be presented above it. if (nativeOverlay && overlayDialog == null) { presentOverlay() } + syncPortalRequestCloseActivity() + updateRequestCloseHandling() } - override fun onHostPause() {} + override fun onHostPause() { + isHostActive = false + updateRequestCloseHandling() + } override fun onHostDestroy() { + isHostActive = false + removeCallbacks(syncPortalActivityRunnable) + clearPortalRequestCloseActivity() + updateRequestCloseHandling() // Dismiss before the activity's window token is destroyed to avoid a leaked // window. `nativeOverlay` is left intact so `onHostResume` can restore it; // the host falls back to inline parenting in the meantime. @@ -375,19 +716,27 @@ class BottomSheetView(context: Context) : ReactViewGroup(context), LifecycleEven // MARK: - Cleanup fun destroy() { + isViewAttached = false + isHostActive = false + requestCloseEnabled = false + removeCallbacks(syncPortalActivityRunnable) + clearPortalRequestCloseActivity() themedReactContext?.removeLifecycleEventListener(this) host.interactionListener = null - overlayDialog?.let { if (it.isShowing) it.dismiss() } + host.requestCloseTargetChangedListener = null + val dialog = overlayDialog + clearOverlayInputHandlers(dialog) + dialog?.let { + if (it.isShowing) it.dismiss() + } overlayDialog = null overlayRoot = null overlayInteractive = null + overlayFocusable = null + overlayHostActivity = null + escapeRequestCloseDispatcher.clear() host.destroy() } - - private companion object { - const val NON_INTERACTIVE_FLAGS = - WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE or WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE - } } private class BottomSheetDialogRootView(context: ThemedReactContext) : diff --git a/android/src/main/java/com/swmansion/reactnativebottomsheet/BottomSheetViewManager.kt b/android/src/main/java/com/swmansion/reactnativebottomsheet/BottomSheetViewManager.kt index f65e320..00d5904 100644 --- a/android/src/main/java/com/swmansion/reactnativebottomsheet/BottomSheetViewManager.kt +++ b/android/src/main/java/com/swmansion/reactnativebottomsheet/BottomSheetViewManager.kt @@ -52,6 +52,10 @@ class BottomSheetViewManager : } dispatchEvent(view, "topPositionChange", event) } + + override fun onRequestClose() { + dispatchEvent(view, "topRequestClose", Arguments.createMap()) + } } return view } @@ -94,6 +98,7 @@ class BottomSheetViewManager : "topIndexChange" to mapOf("registrationName" to "onIndexChange"), "topSettle" to mapOf("registrationName" to "onSettle"), "topPositionChange" to mapOf("registrationName" to "onPositionChange"), + "topRequestClose" to mapOf("registrationName" to "onRequestClose"), ) } @@ -139,6 +144,11 @@ class BottomSheetViewManager : view.setNativeOverlay(value) } + @ReactProp(name = "requestCloseEnabled") + override fun setRequestCloseEnabled(view: BottomSheetView, value: Boolean) { + view.setRequestCloseEnabled(value) + } + @ReactProp(name = "extendUnderStatusBar") override fun setExtendUnderStatusBar(view: BottomSheetView, value: Boolean) { view.extendUnderStatusBar = value diff --git a/android/src/main/java/com/swmansion/reactnativebottomsheet/PortalEscapeWindowCallback.kt b/android/src/main/java/com/swmansion/reactnativebottomsheet/PortalEscapeWindowCallback.kt new file mode 100644 index 0000000..f0348bb --- /dev/null +++ b/android/src/main/java/com/swmansion/reactnativebottomsheet/PortalEscapeWindowCallback.kt @@ -0,0 +1,80 @@ +package com.swmansion.reactnativebottomsheet + +import android.view.KeyEvent +import android.view.Window +import java.util.WeakHashMap + +/** + * Installs one callback wrapper per Activity window and keeps portal listeners in registration + * order. The most recently registered eligible portal receives Escape first. + */ +internal object PortalEscapeWindowCallbackRegistry { + private class Entry( + val listeners: RequestCloseKeyEventListenerStack, + val wrapper: PortalEscapeWindowCallback, + ) + + private val entries = WeakHashMap() + + fun register( + window: Window, + listener: (KeyEvent) -> Boolean, + ): PortalEscapeWindowCallbackRegistration? { + val currentCallback = window.callback ?: return null + val entry = entries[window] ?: createEntry(window, currentCallback) + entry.listeners.add(listener) + return PortalEscapeWindowCallbackRegistration(window, listener) + } + + fun unregister( + window: Window, + listener: (KeyEvent) -> Boolean, + ) { + val entry = entries[window] ?: return + entry.listeners.remove(listener) + if (!entry.listeners.isEmpty) return + + entries.remove(window) + // A later integration may have replaced or wrapped our callback. Do not discard it while + // removing the last bottom-sheet listener. + if (window.callback === entry.wrapper) { + window.callback = entry.wrapper.delegate + } + } + + private fun createEntry( + window: Window, + currentCallback: Window.Callback, + ): Entry { + val listeners = RequestCloseKeyEventListenerStack() + return Entry( + listeners = listeners, + wrapper = PortalEscapeWindowCallback(currentCallback, listeners), + ) + .also { + entries[window] = it + window.callback = it.wrapper + } + } +} + +internal class PortalEscapeWindowCallbackRegistration( + val window: Window, + private val listener: (KeyEvent) -> Boolean, +) { + private var removed = false + + fun remove() { + if (removed) return + removed = true + PortalEscapeWindowCallbackRegistry.unregister(window, listener) + } +} + +private class PortalEscapeWindowCallback( + val delegate: Window.Callback, + private val listeners: RequestCloseKeyEventListenerStack, +) : Window.Callback by delegate { + override fun dispatchKeyEvent(event: KeyEvent): Boolean = + listeners.dispatch(event, delegate::dispatchKeyEvent) +} diff --git a/android/src/main/java/com/swmansion/reactnativebottomsheet/RequestClose.kt b/android/src/main/java/com/swmansion/reactnativebottomsheet/RequestClose.kt new file mode 100644 index 0000000..02d2f56 --- /dev/null +++ b/android/src/main/java/com/swmansion/reactnativebottomsheet/RequestClose.kt @@ -0,0 +1,149 @@ +package com.swmansion.reactnativebottomsheet + +import android.view.KeyEvent + +internal data class RequestCloseEligibility( + val isAttached: Boolean, + val isActive: Boolean, + val isModal: Boolean, + val isEnabled: Boolean, + val isTargetOpen: Boolean, +) + +internal fun isRequestCloseEligible(state: RequestCloseEligibility): Boolean = + state.isAttached && state.isActive && state.isModal && state.isEnabled && state.isTargetOpen + +internal data class RequestCloseKeyEventToken( + val downTime: Long, + val eventTime: Long, + val deviceId: Int, + val source: Int, + val keyCode: Int, + val scanCode: Int, + val action: Int, + val repeatCount: Int, + val metaState: Int, + val flags: Int, +) + +internal data class RequestCloseKeyPressToken( + val downTime: Long, + val deviceId: Int, + val source: Int, + val keyCode: Int, + val scanCode: Int, +) + +/** + * Owns an eligible Escape press from its initial down through its terminal up. Event tokens + * deduplicate the hierarchy and window-callback paths if the same [KeyEvent] reaches both. + */ +internal class EscapeRequestCloseDispatcher { + private var consumedEvent: Any? = null + private var capturedPress: Any? = null + private val unclaimedPresses = mutableSetOf() + + val hasCapturedPress: Boolean + get() = capturedPress != null + + fun dispatch( + eventToken: Any, + pressToken: Any, + keyCode: Int, + action: Int, + repeatCount: Int, + hasModifiers: Boolean, + isCanceled: Boolean, + isRequestCloseEligible: () -> Boolean, + emitRequestCloseIfEligible: () -> Boolean, + ): Boolean { + if (eventToken == consumedEvent) return true + if (keyCode != KeyEvent.KEYCODE_ESCAPE) return false + + if (pressToken in unclaimedPresses) { + if (action == KeyEvent.ACTION_UP) { + unclaimedPresses.remove(pressToken) + } + return false + } + + val currentPress = capturedPress + if (currentPress != null) { + if (pressToken != currentPress) { + rememberUnclaimedInitialDown(pressToken, action, repeatCount) + return false + } + + return when (action) { + KeyEvent.ACTION_DOWN -> consume(eventToken) + KeyEvent.ACTION_UP -> { + capturedPress = null + consume(eventToken) + if (!isCanceled && !hasModifiers) { + emitRequestCloseIfEligible() + } + true + } + else -> false + } + } + + if (action != KeyEvent.ACTION_DOWN || repeatCount != 0) { + return false + } + + if (hasModifiers || !isRequestCloseEligible()) { + unclaimedPresses += pressToken + return false + } + + capturedPress = pressToken + return consume(eventToken) + } + + private fun consume(eventToken: Any): Boolean { + consumedEvent = eventToken + return true + } + + private fun rememberUnclaimedInitialDown( + pressToken: Any, + action: Int, + repeatCount: Int, + ) { + if (action == KeyEvent.ACTION_DOWN && repeatCount == 0) { + unclaimedPresses += pressToken + } + } + + fun clear() { + consumedEvent = null + capturedPress = null + unclaimedPresses.clear() + } +} + +internal class RequestCloseKeyEventListenerStack { + private val listeners = mutableListOf<(Event) -> Boolean>() + + val isEmpty: Boolean + get() = listeners.isEmpty() + + fun add(listener: (Event) -> Boolean) { + listeners += listener + } + + fun remove(listener: (Event) -> Boolean) { + listeners.remove(listener) + } + + fun dispatch( + event: Event, + delegate: (Event) -> Boolean, + ): Boolean { + for (index in listeners.lastIndex downTo 0) { + if (listeners[index](event)) return true + } + return delegate(event) + } +} diff --git a/docs/content/detents-and-index.mdx b/docs/content/detents-and-index.mdx index 5bf8e9d..c28af1d 100644 --- a/docs/content/detents-and-index.mdx +++ b/docs/content/detents-and-index.mdx @@ -4,17 +4,35 @@ title: Detents and index # Detents and index -Detents are the points to which the sheet snaps. Each detent is either a number -(a fixed height in pixels) or `'content'` (the sheet’s content height, capped by -the available screen height). The default detents are `[0, 'content']`. Pass -detents in ascending order, from shortest to tallest. Fixed detents can be -taller than the measured content height, so `[0, 'content', 600]` lets a compact -content-sized sheet expand to a larger surface. - -Sheet children are laid out in a flex container. For a full-height -sheet, apply `flex: 1` to your content and use the `'content'` detent. -`surface` is sized by the library, so `flex: 1` only ever belongs on your -content, never on the surface: +Detents are the heights to which the sheet snaps. Each detent is a number (a +fixed height in React Native density-independent layout units—points on iOS and +dp on Android), a percentage string such as `'30%'`, or `'content'` (the +measured content height, capped by the available sheet height). The default +detents are `[0, 'content']`. + +For example, `[0, 'content']` creates a content-sized sheet, `[0, 300]` keeps +the open height fixed, and `['0%', '30%', '80%']` creates responsive heights. + +Percentage values from `0%` through `100%`, including decimals such as `12.5%`, +resolve against the available sheet height and update when that height changes. +The percentage syntax is unsigned and does not allow whitespace, so values such +as `'-10%'` are invalid. + +`detents` must contain at least one item. Numeric detents must be finite and +non-negative; the library throws an `Error` for values such as `-10`, `NaN`, or +`Infinity` instead of correcting them. A numeric detent may be taller than the +measured content or exceed the available height; native layout caps its resolved +height to the available geometry. + +Pass detents in ascending order, from shortest to tallest. Detent forms can be +mixed, but they must remain ordered by their resolved heights at every layout +size your app supports. The library validates the resolved order during native +layout. + +Sheet children are laid out in a flex container. For a full-height sheet, apply +`flex: 1` to your content and use the `'content'` detent. `surface` is sized by +the library, so `flex: 1` only ever belongs on your content, never on the +surface: ```tsx ``` +## Android close requests + +`onRequestClose` is **Android only**. When the sheet is open, Android system +Back, a committed predictive Back gesture, and a complete, unmodified +external-keyboard Escape press invoke the same callback. + +`onRequestClose` is emitted only after a predictive Back gesture commits. +Gesture progress does not currently animate the sheet, and cancelling the +gesture does not invoke the callback. The request-close Back callbacks are +enabled and disabled with the current close-request eligibility state, +following Android’s [predictive Back callback best +practices](https://developer.android.com/guide/navigation/custom-back/predictive-back-gesture#best-practices). + +Escape is claimed on its initial key-down and emits once on the matching key-up. +Holding the key does not emit repeated requests. A cancelled key-up, a key-up +with modifiers, or a press that never completes does not emit a request. A +modified press or one that starts while the sheet is ineligible is not claimed. + +The input transport depends on how the sheet is presented. A portal sheet +registers a native Back callback with the host Activity’s dispatcher. While the +sheet is eligible, that callback consumes Back before React Native emits a +`hardwareBackPress` event to its JavaScript `BackHandler`. A `nativeOverlay` has +a separate window and therefore handles Back through its dialog dispatcher. In +portal mode, Escape is intercepted by the Activity window callback before the +focused view; a `nativeOverlay` handles it through the dialog window’s key +listener. + +When multiple portal sheets stay mounted, `BottomSheetProvider` coordinates +their visual stack. Only the last open portal in render order can enable its +Back/Escape request-close callback. A portal below another open portal keeps its +callback inactive, even if the lower portal was reopened most recently. The +topmost portal participates in this ordering whether or not it has an +`onRequestClose` handler: if it has none, Back propagates to the Activity +dispatcher and Escape remains unclaimed instead of invoking a lower sheet’s +handler. `nativeOverlay` uses its separate native window and does not +participate in this portal coordination. + +When a portal is ineligible, Back continues through the remaining Activity +callbacks to navigation, React Native’s `BackHandler`, or the Activity’s default +behavior. A focusable `nativeOverlay` uses a lower-priority dialog callback to +forward an ineligible, committed Back to the dispatcher of the Activity that +owns the dialog. It can therefore reach React Navigation, React Native’s +`BackHandler`, or the Activity’s default behavior. This fallback does not +natively dismiss the non-cancelable dialog. Because the event crosses windows, +only the committed Back is forwarded; predictive gesture start, progress, +cancellation, and their animations are not forwarded to the Activity. An +ineligible Escape is not claimed by the library; in a focusable `nativeOverlay` +it remains in the dialog window’s key routing, not the Activity’s. + +The callback is a controlled request, not a native dismissal. The sheet does not +change its index, choose a detent, use the scrim-dismiss path, or dismiss a +`nativeOverlay` dialog. To close it, update your controlled `index` to a +zero-height detent: + +```tsx +const [index, setIndex] = useState(1); + + setIndex(0)} +> + {/* content */} +; +``` + +With this setup, the first Back closes the sheet without leaving the current +screen. Both presentation modes disable their request-close callback as soon as +the controlled update resolves to a zero-height target. In portal mode, the next +Back continues through the remaining Activity callbacks. If a `nativeOverlay` +dialog is still focusable while its closing animation runs, the next committed +Back reaches the same Activity dispatcher through the dialog fallback; once the +dialog becomes non-focusable, Back reaches the Activity directly. In either +case, it can continue to navigation, React Native’s `BackHandler`, or the +Activity’s default behavior. Reopening the sheet enables the request-close +callback again. + +A no-op handler consumes a qualifying Back or Escape request and leaves the +sheet open: + +```tsx + { + // Confirm with the user, or intentionally keep the sheet open. + }} +> + {/* ... */} + +``` + +Close-request eligibility in both presentation modes is based on the resolved +native target, not its index or the sheet’s animated position. This means +`index={0}` remains eligible for `detents={[300, 600]}`. Targets that resolve to +zero—including numeric `0`, `0%`, and `programmatic(0)`—are closed. A `content` +target becomes eligible after its content is measured and resolves to a height +greater than zero. While the view is detached, no close request is emitted and +its native input handlers are removed until it reattaches. While the attached +target is not yet resolved, a portal leaves Back and Escape unclaimed, while a +focusable `nativeOverlay` forwards committed Back through the dialog fallback +and keeps Escape in the dialog window’s key routing. A sheet whose detents all +resolve to a nonzero height remains eligible at every resolved target: + +```tsx + { + // There is no closed detent. Unmount the sheet or replace the detents if + // this request should make it disappear. + }} +> + {/* ... */} + +``` + +When `onRequestClose` is omitted, or while the native target is unresolved or +resolves to zero height, Escape remains unclaimed by the library. In portal mode +Back continues to React Navigation, another `BackHandler`, or the Activity. A +focusable `nativeOverlay` forwards committed Back to its host Activity’s +dispatcher without natively closing the dialog. + +Scrim taps, drag dismissal, accessibility dismiss actions, and programmatic +index changes keep their existing independent behavior and do not invoke this +callback. iOS physical-keyboard Escape, `accessibilityPerformEscape`, and inline +`BottomSheet` are outside this API. + ## Native overlay By default `ModalBottomSheet` renders through `BottomSheetProvider`’s portal. diff --git a/example/app/_layout.tsx b/example/app/_layout.tsx index 8c896df..2894bc4 100644 --- a/example/app/_layout.tsx +++ b/example/app/_layout.tsx @@ -54,6 +54,20 @@ export default function RootLayout() { headerShown: false, }} /> + + + { const [index, setIndex] = useState(0); const [middleDetent, setMiddleDetent] = useState(200); + const [usesShortDetents, setUsesShortDetents] = useState(false); const [position, setPosition] = useState(0); const sheetBottomPadding = useSheetBottomPadding(0); - const detents = useMemo( - () => [0, middleDetent, 'content'] as const, - [middleDetent] + const detents = useMemo( + () => (usesShortDetents ? [0, middleDetent] : [0, middleDetent, 'content']), + [middleDetent, usesShortDetents] ); + const shortenDetents = () => { + setIndex(1); + setUsesShortDetents(true); + }; + + const restoreContentDetent = () => { + setIndex(2); + setUsesShortDetents(false); + }; + return ( { >