diff --git a/README.md b/README.md index ab7a958..bbcfa4d 100644 --- a/README.md +++ b/README.md @@ -507,8 +507,11 @@ export const MultipleExample = () => { | `topLeftRightCornerRadius` | `number` | `20` | Radius for top corners | | `backgroundColor` | `string` | `'white'` | Background color of sheet | | `isSystemUILight` | `boolean` | `undefined` | Android only - status bar styling | -| `presentationStyle` | `'bottom' \| 'center'` | `'bottom'` | `'center'` shows the sheet as a dialog card in the vertical center with a full-screen dim; dismiss by swiping down or tapping the dim. Ignores `useInlinePresentation`. | +| `presentationStyle` | `'bottom' \| 'center' \| 'footer'` | `'bottom'` | `'center'` shows the sheet as a dialog card in the vertical center with a full-screen dim; dismiss by swiping down or tapping the dim. Ignores `useInlinePresentation`. `'footer'` (with `useInlinePresentation`) pins the content to the bottom of the screen above inline sheets, with no gestures or dim — for a buy button that must stay put while an inline sheet is dragged, or for full-screen overlays that must cover it. | | `centerAnimation` | `'fade' \| 'slide'` | `'fade'` | Enter animation for `presentationStyle: 'center'`. `'fade'` = fade + scale in; `'slide'` = slide up into center. | +| `dismissOnOverlayTap` | `boolean` | `dismissable` | Tap on the dimmed background dismisses the sheet. `false` with `dismissable: true` keeps swipe-to-dismiss but ignores taps. | +| `overlayOpacity` | `number` | platform default | Background dim opacity, 0..1. `0` with `useInlinePresentation` removes the background and lets touches reach the screen behind. | +| `collapsedHeight` | `number` | — | `useInlinePresentation` only. Lower snap point; the sheet opens there and can be dragged up to its full height. | ### FittedSheetRef Methods diff --git a/android/src/main/java/com/sheet2/AppFittedSheet.kt b/android/src/main/java/com/sheet2/AppFittedSheet.kt index 7cd42dd..1ce44a3 100644 --- a/android/src/main/java/com/sheet2/AppFittedSheet.kt +++ b/android/src/main/java/com/sheet2/AppFittedSheet.kt @@ -14,6 +14,8 @@ import com.facebook.react.bridge.LifecycleEventListener import com.facebook.react.bridge.ReactContext import com.facebook.react.bridge.UiThreadUtil import com.facebook.react.uimanager.PixelUtil.pxToDp +import com.facebook.react.uimanager.PointerEvents +import com.facebook.react.uimanager.ReactPointerEventsView import com.facebook.react.uimanager.StateWrapper import com.facebook.react.uimanager.UIManagerHelper import com.facebook.react.uimanager.events.EventDispatcher @@ -35,7 +37,13 @@ internal fun AppFittedSheet.onSheetDismiss() { private var presentedSheets: MutableList = mutableListOf() -open class AppFittedSheet(context: Context) : ViewGroup(context), LifecycleEventListener { +open class AppFittedSheet(context: Context) : + ViewGroup(context), + LifecycleEventListener, + ReactPointerEventsView { + // The sheet content lives in its own window or overlay; this placeholder must never take touches. + override val pointerEvents: PointerEvents = PointerEvents.NONE + private var stacked = true private val fragmentTag = "CCBottomSheet-${System.currentTimeMillis()}" var mHostView = DialogRootViewGroup(context) @@ -47,6 +55,9 @@ open class AppFittedSheet(context: Context) : ViewGroup(context), LifecycleEvent field = value inlinePresenter.setDismissable(value) } + var dismissOnOverlayTap = true + var overlayOpacity = -1F + var collapsedHeight = 0F var topLeftRightCornerRadius: Float = 0F var _backgroundColor: Int = Color.TRANSPARENT var isSystemUILight: Boolean = false @@ -121,6 +132,10 @@ open class AppFittedSheet(context: Context) : ViewGroup(context), LifecycleEvent InlineSheetPresenter(this, mHostView) } + private val footerPresenter: InlineFooterPresenter by lazy { + InlineFooterPresenter(this, mHostView) + } + init { mHostView.onSheetLayoutChanged = { pushContentOriginOffset() } } @@ -190,9 +205,19 @@ open class AppFittedSheet(context: Context) : ViewGroup(context), LifecycleEvent return } + if (presentationStyle == "footer") { + footerPresenter.show() + return + } + if (useInlinePresentation) { if (!inlinePresenter.isShown) { - inlinePresenter.show(dismissable) { onSheetDismiss() } + inlinePresenter.show( + dismissable = dismissable, + dismissOnOverlayTap = dismissOnOverlayTap, + overlayOpacity = overlayOpacity, + collapsedHeight = collapsedHeight.toInt(), + ) { onSheetDismiss() } } return } @@ -208,9 +233,17 @@ open class AppFittedSheet(context: Context) : ViewGroup(context), LifecycleEvent val fragment = FragmentModalBottomSheet( modalView = mHostView, dismissable = dismissable, + dismissOnOverlayTap = dismissOnOverlayTap, + overlayOpacity = overlayOpacity, isSystemUILight = isSystemUILight ) { dismissAll -> val parent = mHostView.parent as? ViewGroup + SheetTreeLog.log( + context, + "sheet.detachHost", + "host=${SheetTreeLog.tag(mHostView)} parent=${SheetTreeLog.tag(parent)}" + + " parentChildren=${SheetTreeLog.childTags(parent)}", + ) parent?.removeViewAt(0) onSheetDismiss() if (dismissAll) { @@ -262,6 +295,16 @@ open class AppFittedSheet(context: Context) : ViewGroup(context), LifecycleEvent override fun addView(child: View, index: Int) { UiThreadUtil.assertOnUiThread() + // An overlay child is kept out of mHostView while getChildCount keeps + // counting it, so from here on this view's children and React Native's idea + // of them are two different lists. + SheetTreeLog.log( + context, + "sheet.addView", + "child=${SheetTreeLog.tag(child)} index=$index self=${SheetTreeLog.tag(this)}" + + " hostChildren=${SheetTreeLog.childTags(mHostView)}" + + " inline=$useInlinePresentation", + ) if (useInlinePresentation && index > 0) { inlineOverlayView = child inlinePresenter.setOverlayView(child) @@ -289,6 +332,12 @@ open class AppFittedSheet(context: Context) : ViewGroup(context), LifecycleEvent override fun removeView(child: View) { UiThreadUtil.assertOnUiThread() + SheetTreeLog.log( + context, + "sheet.removeView", + "child=${SheetTreeLog.tag(child)} self=${SheetTreeLog.tag(this)}" + + " hostChildren=${SheetTreeLog.childTags(mHostView)}", + ) if (child == inlineOverlayView) { inlineOverlayView = null inlinePresenter.setOverlayView(null) @@ -304,6 +353,12 @@ open class AppFittedSheet(context: Context) : ViewGroup(context), LifecycleEvent override fun removeViewAt(index: Int) { UiThreadUtil.assertOnUiThread() + SheetTreeLog.log( + context, + "sheet.removeViewAt", + "index=$index self=${SheetTreeLog.tag(this)}" + + " hostChildren=${SheetTreeLog.childTags(mHostView)}", + ) if (useInlinePresentation && index >= mHostView.childCount && inlineOverlayView != null) { inlineOverlayView = null inlinePresenter.setOverlayView(null) @@ -317,7 +372,7 @@ open class AppFittedSheet(context: Context) : ViewGroup(context), LifecycleEvent dismiss() } - private fun onDropInstance() { + internal fun onDropInstance() { (context as ReactContext).removeLifecycleEventListener(this) dismiss() } @@ -329,6 +384,11 @@ open class AppFittedSheet(context: Context) : ViewGroup(context), LifecycleEvent centeredDialog = null return } + if (presentationStyle == "footer") { + footerPresenter.dismiss() + onSheetDismiss() + return + } if (useInlinePresentation) { inlinePresenter.dismiss() return diff --git a/android/src/main/java/com/sheet2/CustomBottomSheetDialog.java b/android/src/main/java/com/sheet2/CustomBottomSheetDialog.java index acabf09..6b164a3 100644 --- a/android/src/main/java/com/sheet2/CustomBottomSheetDialog.java +++ b/android/src/main/java/com/sheet2/CustomBottomSheetDialog.java @@ -21,6 +21,7 @@ public class CustomBottomSheetDialog extends AppCompatDialog { private FrameLayout container; boolean dismissWithAnimation; boolean cancelable = true; + public boolean dismissOnOverlayTap = true; protected int startState = BottomSheetBehavior.STATE_EXPANDED; @@ -191,7 +192,7 @@ private View wrapInBottomSheet( coordinator .findViewById(R.id.touch_outside) .setOnClickListener( - view12 -> {if (cancelable && isShowing()) cancel();}); + view12 -> {if (cancelable && dismissOnOverlayTap && isShowing()) cancel();}); // The bottom sheet container spans the full width, but the card content can be // narrower and horizontally centered (maxWidth / landscape). A blanket // `event -> true` here swallows taps that land in the dim strips beside the @@ -202,7 +203,7 @@ private View wrapInBottomSheet( final View card = view; bottomSheet.setOnTouchListener((view1, event) -> { if (event.getActionMasked() == MotionEvent.ACTION_UP - && cancelable && isShowing() && card != null + && cancelable && dismissOnOverlayTap && isShowing() && card != null && !isPointInsideCard(card, event.getRawX(), event.getRawY())) { cancel(); } diff --git a/android/src/main/java/com/sheet2/DialogRootViewGroup.kt b/android/src/main/java/com/sheet2/DialogRootViewGroup.kt index 0f91f37..09b6779 100644 --- a/android/src/main/java/com/sheet2/DialogRootViewGroup.kt +++ b/android/src/main/java/com/sheet2/DialogRootViewGroup.kt @@ -6,7 +6,6 @@ import android.graphics.Outline import android.os.Looper import android.view.View import android.view.ViewOutlineProvider -import com.facebook.react.uimanager.PixelUtil.pxToDp import kotlin.math.max import kotlin.math.min @@ -86,7 +85,6 @@ class DialogRootViewGroup(context: Context) : BaseRNView(context) { if (sheetMaxHeightSize == Float.MAX_VALUE) return val newHeight = allowedHeight val newWidth = allowedWidth - println("😀 DialogRootViewGroup.setVirtualHeight ${newHeight.pxToDp()} :${newWidth.pxToDp()}") ensureLayoutParams() layoutParams?.height = newHeight translationX = centeringTranslationX(newWidth) @@ -105,19 +103,39 @@ class DialogRootViewGroup(context: Context) : BaseRNView(context) { } override fun addView(child: View, index: Int, params: LayoutParams) { - println("😀 DialogRootViewGroup.addView ${child.id}") - if (reactView != null) removeView(reactView) + // Dropping the previous child here is a removal React Native never hears + // about: it goes on counting that child, and its next insert lands at an + // index the group no longer has. + val replaced = reactView + SheetTreeLog.log( + context, + "host.addView", + "child=${SheetTreeLog.tag(child)} index=$index children=${SheetTreeLog.childTags(this)}" + + " replaces=${SheetTreeLog.tag(replaced)}", + ) + if (replaced != null) removeView(replaced) super.addView(child, index, params) reactView = child setVirtualHeight(sheetMaxHeightSize) } override fun removeView(view: View?) { + SheetTreeLog.log( + context, + "host.removeView", + "child=${SheetTreeLog.tag(view)} children=${SheetTreeLog.childTags(this)}", + ) if (view == reactView) releaseReactView() super.removeView(view) } override fun removeViewAt(index: Int) { + SheetTreeLog.log( + context, + "host.removeViewAt", + "index=$index child=${SheetTreeLog.tag(getChildAt(index))}" + + " children=${SheetTreeLog.childTags(this)}", + ) if (getChildAt(index) === reactView) releaseReactView() super.removeViewAt(index) } diff --git a/android/src/main/java/com/sheet2/FragmentModalBottomSheet.kt b/android/src/main/java/com/sheet2/FragmentModalBottomSheet.kt index bd90306..a44218d 100644 --- a/android/src/main/java/com/sheet2/FragmentModalBottomSheet.kt +++ b/android/src/main/java/com/sheet2/FragmentModalBottomSheet.kt @@ -19,6 +19,8 @@ class FragmentModalBottomSheet() : BottomSheetDialogFragment() { private var overlayHost: ViewGroup? = null private var overlayView: View? = null private var dismissable: Boolean = true + private var dismissOnOverlayTap: Boolean = true + private var overlayOpacity: Float = -1F private var isSystemUILight: Boolean = false private var onDismiss: ((dismissAll: Boolean) -> Unit)? = null var dismissAll = false @@ -26,11 +28,15 @@ class FragmentModalBottomSheet() : BottomSheetDialogFragment() { constructor( modalView: ViewGroup, dismissable: Boolean, + dismissOnOverlayTap: Boolean, + overlayOpacity: Float, isSystemUILight: Boolean, onDismiss: (dismissAll: Boolean) -> Unit ) : this() { this.modalView = modalView this.dismissable = dismissable + this.dismissOnOverlayTap = dismissOnOverlayTap + this.overlayOpacity = overlayOpacity this.isSystemUILight = isSystemUILight this.onDismiss = onDismiss } @@ -56,6 +62,8 @@ class FragmentModalBottomSheet() : BottomSheetDialogFragment() { this.isCancelable = dismissable val dialog = CustomBottomSheetDialog(requireContext(), R.style.AppBottomSheetDialog) dialog.setSheetBackgroundColor(Color.TRANSPARENT) + dialog.dismissOnOverlayTap = dismissOnOverlayTap + if (overlayOpacity >= 0) dialog.window?.setDimAmount(overlayOpacity) dialog.window?.let { presentedWindow = WeakReference(it) it.setStatusBarStyle(isSystemUILight) @@ -84,7 +92,16 @@ class FragmentModalBottomSheet() : BottomSheetDialogFragment() { val view = overlayView ?: return val decorView = dialog?.window?.decorView as? ViewGroup ?: return detachOverlayHost() - (view.parent as? ViewGroup)?.removeView(view) + // Same as the inline presenter: the overlay is taken out of whatever parent + // React Native gave it and put into the dialog's own host. + val previousParent = view.parent as? ViewGroup + SheetTreeLog.log( + view.context, + "dialog.attachOverlay", + "overlay=${SheetTreeLog.tag(view)} from=${SheetTreeLog.tag(previousParent)}" + + " fromChildren=${SheetTreeLog.childTags(previousParent)}", + ) + previousParent?.removeView(view) val host = DialogPassThroughFrameLayout(decorView.context).apply { layoutParams = ViewGroup.LayoutParams( diff --git a/android/src/main/java/com/sheet2/InlineFooterPresenter.kt b/android/src/main/java/com/sheet2/InlineFooterPresenter.kt new file mode 100644 index 0000000..22f814a --- /dev/null +++ b/android/src/main/java/com/sheet2/InlineFooterPresenter.kt @@ -0,0 +1,74 @@ +package com.sheet2 + +import android.view.Gravity +import android.view.View +import android.view.ViewGroup +import android.view.ViewParent +import android.widget.FrameLayout + +internal class InlineFooterPresenter( + private val anchor: AppFittedSheet, + private val hostView: View, +) { + + private var overlay: FrameLayout? = null + + val isShown: Boolean get() = overlay != null + + fun show() { + if (isShown) return + val root = findInlineRoot(anchor) ?: return + + val overlayRoot = PassThroughFrameLayout(anchor.context).apply { + layoutParams = ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT, + ) + isClickable = false + isFocusable = false + } + + (hostView.parent as? ViewGroup)?.removeView(hostView) + val size = hostView.layoutParams + hostView.layoutParams = FrameLayout.LayoutParams( + size?.width ?: FrameLayout.LayoutParams.MATCH_PARENT, + size?.height ?: 0, + Gravity.BOTTOM, + ) + overlayRoot.addView(hostView) + + root.addView(overlayRoot) + overlay = overlayRoot + footers.add(overlayRoot) + hostView.post { anchor.pushContentOriginOffset() } + } + + fun dismiss() { + val layout = overlay ?: return + footers.remove(layout) + overlay = null + (layout.parent as? ViewGroup)?.removeView(layout) + (hostView.parent as? ViewGroup)?.removeView(hostView) + } + + companion object { + private val footers = mutableListOf() + + fun bringFootersToFront(root: ViewGroup) { + footers.filter { it.parent === root }.forEach { it.bringToFront() } + } + } +} + +internal fun findInlineRoot(anchor: View): ViewGroup? { + var current: ViewParent? = anchor.parent + var lastGroup: ViewGroup? = null + while (current != null) { + if (current is ViewGroup) lastGroup = current + if (current.javaClass.name == "com.swmansion.rnscreens.Screen") { + return (current.parent as? ViewGroup) ?: (current as? ViewGroup) + } + current = current.parent + } + return lastGroup +} diff --git a/android/src/main/java/com/sheet2/InlineSheetPresenter.kt b/android/src/main/java/com/sheet2/InlineSheetPresenter.kt index 3649a03..dad3194 100644 --- a/android/src/main/java/com/sheet2/InlineSheetPresenter.kt +++ b/android/src/main/java/com/sheet2/InlineSheetPresenter.kt @@ -1,6 +1,8 @@ package com.sheet2 import android.animation.ValueAnimator +import android.content.Context +import android.view.inputmethod.InputMethodManager import android.graphics.Color import android.os.SystemClock import android.view.Gravity @@ -37,24 +39,33 @@ internal class InlineSheetPresenter( private var behavior: BottomSheetBehavior? = null private var scrimAnimator: ValueAnimator? = null private var onDismiss: (() -> Unit)? = null + private var snaps = false val isShown: Boolean get() = overlay != null - fun show(dismissable: Boolean, onDismiss: () -> Unit) { + fun show( + dismissable: Boolean, + dismissOnOverlayTap: Boolean, + overlayOpacity: Float, + collapsedHeight: Int, + onDismiss: () -> Unit, + ) { if (isShown) return - val root = findInlineRoot() ?: return + val root = findInlineRoot(anchor) ?: return this.onDismiss = onDismiss + snaps = collapsedHeight > 0 + val scrimAlpha = if (overlayOpacity >= 0) overlayOpacity else SCRIM_ALPHA val ctx = anchor.context - val overlayRoot = FrameLayout(ctx).apply { + val overlayRoot = PassThroughFrameLayout(ctx).apply { layoutParams = ViewGroup.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT, ) } - val coordinator = CoordinatorLayout(ctx).apply { + val coordinator = PassThroughCoordinatorLayout(ctx).apply { layoutParams = FrameLayout.LayoutParams( FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT, @@ -69,9 +80,10 @@ internal class InlineSheetPresenter( CoordinatorLayout.LayoutParams.MATCH_PARENT, ) } - coordinator.addView(touchOutside) + // Without a scrim touches outside the sheet must reach the screen behind. + if (scrimAlpha > 0) coordinator.addView(touchOutside) - val designBottomSheet = FrameLayout(ctx).apply { + val designBottomSheet = PassThroughFrameLayout(ctx).apply { layoutParams = CoordinatorLayout.LayoutParams( CoordinatorLayout.LayoutParams.MATCH_PARENT, CoordinatorLayout.LayoutParams.WRAP_CONTENT, @@ -86,9 +98,9 @@ internal class InlineSheetPresenter( val behavior = BottomSheetBehavior().apply { setHideable(true) - setSkipCollapsed(true) - setDraggable(dismissable) - setPeekHeight(10, false) + setSkipCollapsed(!snaps) + setDraggable(dismissable || snaps) + setPeekHeight(if (snaps) collapsedHeight else 10, false) setState(BottomSheetBehavior.STATE_HIDDEN) addBottomSheetCallback(object : BottomSheetBehavior.BottomSheetCallback() { override fun onStateChanged(bottomSheet: View, newState: Int) { @@ -105,6 +117,7 @@ internal class InlineSheetPresenter( newState == BottomSheetBehavior.STATE_COLLAPSED || newState == BottomSheetBehavior.STATE_HALF_EXPANDED ) { + if (snaps) behavior?.setHideable(dismissable) // Sheet settled — sync Fabric shadow tree with our new visual // position so Pressability measures correctly. bottomSheet.post { anchor.pushContentOriginOffset() } @@ -122,16 +135,19 @@ internal class InlineSheetPresenter( this.behavior = behavior touchOutside.setOnClickListener { - if (dismissable) behavior.setState(BottomSheetBehavior.STATE_HIDDEN) + if (dismissable && dismissOnOverlayTap) behavior.setState(BottomSheetBehavior.STATE_HIDDEN) } - animateScrim(coordinator, fromAlpha = 0f, toAlpha = SCRIM_ALPHA) + if (scrimAlpha > 0) animateScrim(coordinator, fromAlpha = 0f, toAlpha = scrimAlpha) root.addView(overlayRoot) + InlineFooterPresenter.bringFootersToFront(root) overlay = overlayRoot designBottomSheet.post { - behavior.setState(BottomSheetBehavior.STATE_EXPANDED) + behavior.setState( + if (snaps) BottomSheetBehavior.STATE_COLLAPSED else BottomSheetBehavior.STATE_EXPANDED + ) // Post once more so Fabric state sync runs after the expand-layout pass. designBottomSheet.post { anchor.pushContentOriginOffset() } } @@ -139,8 +155,10 @@ internal class InlineSheetPresenter( fun dismiss(animated: Boolean = true, invokeCallback: Boolean = true) { val layout = overlay ?: return + hideKeyboard() val behavior = this.behavior if (animated && behavior != null && behavior.getState() != BottomSheetBehavior.STATE_HIDDEN) { + behavior.setHideable(true) behavior.setState(BottomSheetBehavior.STATE_HIDDEN) return } @@ -156,7 +174,7 @@ internal class InlineSheetPresenter( } fun setDismissable(dismissable: Boolean) { - behavior?.setDraggable(dismissable) + behavior?.setDraggable(dismissable || snaps) } fun setOverlayView(view: View?) { @@ -169,7 +187,16 @@ internal class InlineSheetPresenter( private fun attachOverlayView() { val host = overlayHost ?: return val view = overlayView ?: return - (view.parent as? ViewGroup)?.removeView(view) + // Re-parenting a view React Native mounted somewhere else: its old parent + // loses a child without React Native being told. + val previousParent = view.parent as? ViewGroup + SheetTreeLog.log( + view.context, + "inline.attachOverlay", + "overlay=${SheetTreeLog.tag(view)} from=${SheetTreeLog.tag(previousParent)}" + + " fromChildren=${SheetTreeLog.childTags(previousParent)} to=${SheetTreeLog.tag(host)}", + ) + previousParent?.removeView(view) view.layoutParams = FrameLayout.LayoutParams( FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT, @@ -177,30 +204,11 @@ internal class InlineSheetPresenter( host.addView(view) } - /** - * Walks up from [anchor] looking for the closest react-native-screens Screen - * ancestor and returns its parent (the ScreenStack / ScreensCoordinatorLayout). - * Attaching the overlay there places it as a sibling of the current Screen — - * so when a new Screen is pushed (e.g. fullScreenModal) it lands as a later - * child of the same container and naturally draws on top of us. - * - * Using the Screen itself does not work: Screen and ScreenContentWrapper rely on - * RN/Yoga to lay out their children and leave non-RN children at 0×0. - * - * Falls back to the top-most ViewGroup ancestor when not hosted by - * react-native-screens. - */ - private fun findInlineRoot(): ViewGroup? { - var current: ViewParent? = anchor.parent - var lastGroup: ViewGroup? = null - while (current != null) { - if (current is ViewGroup) lastGroup = current - if (current.javaClass.name == SCREEN_CLASS_NAME) { - return (current.parent as? ViewGroup) ?: (current as? ViewGroup) - } - current = current.parent - } - return lastGroup + private fun hideKeyboard() { + val focused = hostView.findFocus() ?: return + val imm = hostView.context.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager + imm?.hideSoftInputFromWindow(focused.windowToken, 0) + focused.clearFocus() } private fun cancelAncestorJsTouches(child: View) { @@ -237,6 +245,5 @@ internal class InlineSheetPresenter( companion object { private const val SCRIM_ALPHA = 0.5f private const val SCRIM_DURATION_MS = 250L - private const val SCREEN_CLASS_NAME = "com.swmansion.rnscreens.Screen" } } diff --git a/android/src/main/java/com/sheet2/PassThroughLayouts.kt b/android/src/main/java/com/sheet2/PassThroughLayouts.kt new file mode 100644 index 0000000..7c3379b --- /dev/null +++ b/android/src/main/java/com/sheet2/PassThroughLayouts.kt @@ -0,0 +1,17 @@ +package com.sheet2 + +import android.content.Context +import android.widget.FrameLayout +import androidx.coordinatorlayout.widget.CoordinatorLayout +import com.facebook.react.uimanager.PointerEvents +import com.facebook.react.uimanager.ReactPointerEventsView + +internal class PassThroughFrameLayout(context: Context) : FrameLayout(context), ReactPointerEventsView { + override val pointerEvents: PointerEvents = PointerEvents.BOX_NONE +} + +internal class PassThroughCoordinatorLayout(context: Context) : + CoordinatorLayout(context), + ReactPointerEventsView { + override val pointerEvents: PointerEvents = PointerEvents.BOX_NONE +} diff --git a/android/src/main/java/com/sheet2/Sheet2ViewManager.kt b/android/src/main/java/com/sheet2/Sheet2ViewManager.kt index 8744e3f..5f84838 100644 --- a/android/src/main/java/com/sheet2/Sheet2ViewManager.kt +++ b/android/src/main/java/com/sheet2/Sheet2ViewManager.kt @@ -101,11 +101,28 @@ class Sheet2ViewManager(reactContext: ReactApplicationContext) : ViewGroupManage view.centerAnimation = value ?: "fade" } + override fun setDismissOnOverlayTap(view: Sheet2View, value: Boolean) { + view.dismissOnOverlayTap = value + } + + override fun setOverlayOpacity(view: Sheet2View, value: Double) { + view.overlayOpacity = value.toFloat() + } + + override fun setCollapsedHeight(view: Sheet2View, value: Double) { + view.collapsedHeight = value.dpToPx() + } + override fun setCalculatedHeight(view: Sheet2View, value: Double) { println("==========setCalculatedHeight $value") view.mHostView.setVirtualHeight(value.dpToPx()) } + override fun onDropViewInstance(view: Sheet2View) { + super.onDropViewInstance(view) + view.onDropInstance() + } + override fun onAfterUpdateTransaction(view: Sheet2View) { super.onAfterUpdateTransaction(view) println("==========onAfterUpdateTransaction") diff --git a/android/src/main/java/com/sheet2/SheetModule.kt b/android/src/main/java/com/sheet2/SheetModule.kt index 8f57a7b..d6d55e9 100644 --- a/android/src/main/java/com/sheet2/SheetModule.kt +++ b/android/src/main/java/com/sheet2/SheetModule.kt @@ -4,6 +4,7 @@ import android.view.ViewGroup import androidx.appcompat.app.AppCompatActivity import com.facebook.react.bridge.Arguments import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.bridge.WritableArray import com.facebook.react.bridge.WritableMap class SheetModule(reactContext: ReactApplicationContext) : NativeSheetSpec(reactContext) { @@ -23,6 +24,12 @@ class SheetModule(reactContext: ReactApplicationContext) : NativeSheetSpec(react return Arguments.createMap() } + override fun getTreeLog(): WritableArray { + val entries = Arguments.createArray() + SheetTreeLog.snapshot().forEach { entries.pushString(it) } + return entries + } + override fun dismissAll() { reactApplicationContext.runOnUiQueueThread { reactApplicationContext.currentActivity?.let { AppFittedSheet.Companion.dismissAll(it as AppCompatActivity) } diff --git a/android/src/main/java/com/sheet2/SheetTreeLog.kt b/android/src/main/java/com/sheet2/SheetTreeLog.kt new file mode 100644 index 0000000..ba89e2b --- /dev/null +++ b/android/src/main/java/com/sheet2/SheetTreeLog.kt @@ -0,0 +1,71 @@ +package com.sheet2 + +import android.content.Context +import android.util.Log +import android.view.View +import android.view.ViewGroup +import com.facebook.react.bridge.ReactContext +import com.facebook.react.modules.core.DeviceEventManagerModule + +/** + * Every place where this library moves a view React Native believes it owns. + * + * The mounting crashes worth chasing carry no frame of ours: on Android the + * stack between `ViewGroupManager.addView` and `ViewGroup.addInArray` is React + * Native and the framework only ("addViewAt: failed to insert view [child] into + * parent [parent] at index N", caused by "IndexOutOfBoundsException: index=N + * count=0"). So the only way to tell afterwards whether a sheet was involved is + * to say so ourselves, naming the views by the id React Native uses as the tag — + * which is exactly what that message carries. + * + * Entries go to logcat, to a small in-memory ring for a local session, and — when + * a listener is registered from JS — to the app, which is expected to turn them + * into breadcrumbs. Breadcrumbs are what survives into a native crash report. + */ +object SheetTreeLog { + private const val TAG = "Sheet2Tree" + private const val EVENT = "sheet2:tree" + private const val CAPACITY = 32 + + private val entries = ArrayDeque() + + fun log(context: Context?, op: String, details: String) { + val entry = "$op $details" + + synchronized(entries) { + if (entries.size == CAPACITY) entries.removeFirst() + entries.addLast(entry) + } + + Log.d(TAG, entry) + emit(context, entry) + } + + /** The tag React Native knows the view by, or "-" when there is no view. */ + fun tag(view: View?): String = view?.let { "${it.id}" } ?: "-" + + fun childTags(parent: ViewGroup?): String { + if (parent == null) return "-" + val count = parent.childCount + if (count == 0) return "none" + + return (0 until count).joinToString(",") { "${parent.getChildAt(it).id}" } + } + + fun snapshot(): List = synchronized(entries) { entries.toList() } + + private fun emit(context: Context?, entry: String) { + val reactContext = context as? ReactContext ?: return + if (!reactContext.hasActiveReactInstance()) return + + try { + reactContext + .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java) + .emit(EVENT, entry) + } catch (error: Throwable) { + // A sheet can be torn down while the instance is going away; diagnostics + // must never be the reason something crashes. + Log.d(TAG, "emit failed: ${error.message}") + } + } +} diff --git a/ios/FittedSheets/SheetViewController.swift b/ios/FittedSheets/SheetViewController.swift index c8823fd..b9e2c0d 100644 --- a/ios/FittedSheets/SheetViewController.swift +++ b/ios/FittedSheets/SheetViewController.swift @@ -622,6 +622,7 @@ public class SheetViewController: UIViewController { } public func attemptDismiss(animated: Bool) { + self.view.endEditing(true) if self.options.useInlineMode { if animated { self.animateOut { @@ -675,12 +676,9 @@ public class SheetViewController: UIViewController { extension SheetViewController: SheetViewDelegate { func sheetPoint(inside point: CGPoint, with event: UIEvent?) -> Bool { - let isInOverlay = self.overlayTapView.bounds.contains(point) - if self.allowGestureThroughOverlay, isInOverlay { - return false - } else { - return true - } + guard self.allowGestureThroughOverlay else { return true } + let cardView = self.contentViewController.view! + return cardView.bounds.contains(self.view.convert(point, to: cardView)) } } diff --git a/ios/HostFittedSheet.swift b/ios/HostFittedSheet.swift index 27f467b..0f751f9 100644 --- a/ios/HostFittedSheet.swift +++ b/ios/HostFittedSheet.swift @@ -10,6 +10,7 @@ func viewPort() -> CGSize { } var presentedSheets: [SheetViewController] = [] +var footers: [UIView] = [] var lastPresentedSheetSizes: [[SheetSize]] = [] public final class HostFittedSheet: UIView { @@ -25,6 +26,12 @@ public final class HostFittedSheet: UIView { private var _sheetSize: CGFloat? public var sheetMaxWidthSize: CGFloat? private var dismissable = true + private var dismissOnOverlayTap = true + private var overlayOpacity: CGFloat = -1 + private var collapsedHeight: CGFloat = 0 + private var _footer = false + // Inline sheets mounted outside any RN surface (e.g. at the app root) get no touches from the surface handler. + private var _ownsTouches = false private var topLeftRightCornerRadius: CGFloat? private var stacked = false private var _backgroundColor: UIColor = .clear @@ -88,6 +95,7 @@ public final class HostFittedSheet: UIView { @objc public func setPresentationStyle(_ value: NSString) { _centered = (value as String) == "center" + _footer = (value as String) == "footer" } @objc @@ -110,6 +118,9 @@ public final class HostFittedSheet: UIView { public func setFittedSheetParams(_ params: NSDictionary) { sheetMaxWidthSize = RCTConvert.cgFloat(params["maxWidth"]) dismissable = params["dismissable"] as? Bool ?? true + dismissOnOverlayTap = params["dismissOnOverlayTap"] as? Bool ?? true + overlayOpacity = RCTConvert.cgFloat(params["overlayOpacity"]) + collapsedHeight = RCTConvert.cgFloat(params["collapsedHeight"]) topLeftRightCornerRadius = RCTConvert.cgFloat(params["topLeftRightCornerRadius"]) if let value = sheetMaxWidthSize { @@ -133,7 +144,14 @@ public final class HostFittedSheet: UIView { } public override func insertReactSubview(_ subview: UIView!, at atIndex: Int) { + SheetTreeLog.log( + "sheet.insertReactSubview", + details: "child=\(SheetTreeLog.name(subview)) index=\(atIndex) self=\(SheetTreeLog.name(self))" + + " presented=\(_isPresented)" + ) if atIndex > 0 { + // The overlay never becomes a subview of the content view React Native + // mounted it into. _overlaySubview = subview subview.isUserInteractionEnabled = false attachOverlaySubview() @@ -148,6 +166,11 @@ public final class HostFittedSheet: UIView { } public override func removeReactSubview(_ subview: UIView!) { + SheetTreeLog.log( + "sheet.removeReactSubview", + details: "child=\(SheetTreeLog.name(subview)) self=\(SheetTreeLog.name(self))" + + " presented=\(_isPresented)" + ) if let overlaySubview = _overlaySubview, subview === overlaySubview { _overlaySubview?.removeFromSuperview() _overlaySubview = nil @@ -166,6 +189,12 @@ public final class HostFittedSheet: UIView { guard let overlaySubview = _overlaySubview, let sheetView = _modalViewController?.view else { return } + SheetTreeLog.log( + "sheet.attachOverlay", + details: "overlay=\(SheetTreeLog.name(overlaySubview))" + + " from=\(SheetTreeLog.name(overlaySubview.superview))" + + " to=\(SheetTreeLog.name(sheetView))" + ) overlaySubview.removeFromSuperview() overlaySubview.frame = sheetView.bounds overlaySubview.autoresizingMask = [.flexibleWidth, .flexibleHeight] @@ -208,7 +237,11 @@ public final class HostFittedSheet: UIView { return } _sheetSize = RCTConvert.cgFloat(height) - _modalViewController?.setSizes([.fixed(_sheetSize ?? 0)]) + if _footer { + layoutFooter() + } else { + _modalViewController?.setSizes(sheetSizes(_sheetSize ?? 0)) + } // Inline-containment mode: tryToPresent early-returned while height was // still 0 — now that we have a real size, try again. if _useInlinePresentation && !_isPresented && (_sheetSize ?? 0) > 0 { @@ -228,17 +261,66 @@ public final class HostFittedSheet: UIView { opts.centerSlide = _centerSlide self._modalViewController = SheetViewController( controller: self.viewController, - sizes: [.fixed(size.height)], + sizes: sheetSizes(size.height), options: opts ) self._modalViewController?.allowPullingPastMaxHeight = false - self._modalViewController?.dismissOnOverlayTap = self.dismissable + self._modalViewController?.dismissOnOverlayTap = self.dismissable && self.dismissOnOverlayTap + if overlayOpacity >= 0 { + self._modalViewController?.overlayColor = UIColor(white: 0, alpha: overlayOpacity) + } + self._modalViewController?.allowGestureThroughOverlay = overlayOpacity == 0 self._modalViewController?.dismissOnPull = self.dismissable self._modalViewController?.cornerRadius = self.topLeftRightCornerRadius ?? 12 self._modalViewController?.contentBackgroundColor = _backgroundColor } + private func hasSurfaceTouchHandler(_ view: UIView?) -> Bool { + var current = view + while let v = current { + if v.gestureRecognizers?.contains(where: { $0 is RCTSurfaceTouchHandler }) == true { return true } + current = v.superview + } + return false + } + + private func sheetSizes(_ height: CGFloat) -> [SheetSize] { + guard _useInlinePresentation, collapsedHeight > 0, collapsedHeight < height else { + return [.fixed(height)] + } + return [.fixed(collapsedHeight), .fixed(height)] + } + + private func presentFooter() { + guard let hostVC = presentViewController else { return } + hostVC.view.addSubview(viewController.view) + _ownsTouches = !hasSurfaceTouchHandler(hostVC.view) + if !_ownsTouches { detachTouchHandler() } + footers.append(viewController.view) + layoutFooter() + DispatchQueue.main.async { [weak self] in + self?.pushContentOriginOffset() + } + } + + private func layoutFooter() { + guard _footer, let host = viewController.view.superview else { return } + let height = _sheetSize ?? 0 + viewController.view.frame = CGRect( + x: 0, + y: host.bounds.height - height, + width: host.bounds.width, + height: height + ) + viewController.view.autoresizingMask = [.flexibleWidth, .flexibleTopMargin] + _reactSubview?.frame = viewController.view.bounds + host.bringSubviewToFront(viewController.view) + DispatchQueue.main.async { [weak self] in + self?.pushContentOriginOffset() + } + } + private func tryAttachScrollView() { guard let controller = _modalViewController else { return } let scrollView = self._reactSubview?.find(deepIndex: 0) @@ -255,7 +337,7 @@ public final class HostFittedSheet: UIView { /// matching the real touch positions. Only meaningful in inline /// containment — dialog mode has matching coords and needs no offset. private func pushContentOriginOffset() { - guard _useInlinePresentation else { return } + guard _useInlinePresentation, !_ownsTouches else { return } guard self.window != nil else { return } guard let reactSubview = _reactSubview, reactSubview.window != nil else { return } let yogaPos = self.convert(CGPoint.zero, to: nil) @@ -281,6 +363,16 @@ public final class HostFittedSheet: UIView { return } + if !_isPresented && _footer { + if (_sheetSize ?? 0) <= 0 { return } + presentViewController = self.findEnclosingViewController() ?? RCTPresentedViewController() + _isPresented = true + RCTExecuteOnMainQueue { [weak self] in + self?.presentFooter() + } + return + } + if (!_isPresented) { if _useInlinePresentation { // Present on the current react-navigation Screen's VC (found via @@ -327,6 +419,9 @@ public final class HostFittedSheet: UIView { sheetVC.view.autoresizingMask = [.flexibleWidth, .flexibleHeight] hostVC.view.addSubview(sheetVC.view) sheetVC.didMove(toParent: hostVC) + footers.forEach { footer in + if footer.superview === hostVC.view { hostVC.view.bringSubviewToFront(footer) } + } self.attachOverlaySubview() sheetVC.view.setNeedsLayout() sheetVC.view.layoutIfNeeded() @@ -338,7 +433,8 @@ public final class HostFittedSheet: UIView { // UIKit hit-test and dispatches them through Fabric's normal // pipeline. Keeping our own handler duplicates every touch event // and breaks Pressability state after the first tap. - self.detachTouchHandler() + self._ownsTouches = !self.hasSurfaceTouchHandler(hostVC.view) + if !self._ownsTouches { self.detachTouchHandler() } // Push the visual delta (Yoga-position → physical on-screen // position) into the Fabric state so that descendants' measure() @@ -369,8 +465,15 @@ public final class HostFittedSheet: UIView { contentView.transform = .identity sheetVC.overlayView.alpha = 1 }, - completion: nil + completion: { [weak self] _ in + self?.pushContentOriginOffset() + } ) + sheetVC.sizeChanged = { [weak self] _, _, _ in + DispatchQueue.main.async { + self?.pushContentOriginOffset() + } + } } else { hostVC.present(sheetVC, animated: true) { [weak self] in // Modal presentation: attach the overlay once the sheet VC's view @@ -405,6 +508,12 @@ public final class HostFittedSheet: UIView { @objc public func dismiss() { + _reactSubview?.endEditing(true) + if _footer { + destroy() + onSheetDismiss?() + return + } if _useInlinePresentation { // attemptDismiss handles both inline (removeFromSuperview + // removeFromParent) and modal paths internally. @@ -416,6 +525,19 @@ public final class HostFittedSheet: UIView { @objc public func destroy() { + SheetTreeLog.log( + "sheet.destroy", + details: "self=\(SheetTreeLog.name(self)) content=\(SheetTreeLog.name(_reactSubview))" + + " overlay=\(SheetTreeLog.name(_overlaySubview)) presented=\(_isPresented)" + ) + _reactSubview?.endEditing(true) + if _footer { + _isPresented = false + footers.removeAll { $0 === viewController.view } + viewController.view.removeFromSuperview() + presentViewController = nil + return + } if !_isPresented && _modalViewController == nil { return } diff --git a/ios/SheetModule.mm b/ios/SheetModule.mm index db2df0b..e394213 100644 --- a/ios/SheetModule.mm +++ b/ios/SheetModule.mm @@ -37,6 +37,10 @@ - (NSDictionary *)viewportSize { return dict; } +- (NSArray *)getTreeLog { + return [SheetTreeLog snapshot]; +} + - (void)dismiss:(NSInteger)tag { NSLog(@"😀 dismissModule %d", [[NSNumber alloc] initWithInt:tag].intValue); } diff --git a/ios/SheetTreeLog.swift b/ios/SheetTreeLog.swift new file mode 100644 index 0000000..98aaccf --- /dev/null +++ b/ios/SheetTreeLog.swift @@ -0,0 +1,49 @@ +import Foundation +import UIKit +import os + +/** + Every place where this library moves a view React Native believes it owns — + the iOS half of the Android `SheetTreeLog`. + + On iOS the crashes do carry our symbols (`HostFittedSheet.setFittedSheetParams`, + `dismissSheet` inside `RCTMountingManager.performTransaction`), so what is + missing there is not the name but the state: which subview was mounted, which + one was taken away, and whether the sheet was already being destroyed. That is + what these entries carry. + + The trail lives in memory and in the unified log under the `com.sheet2` + subsystem; `getTreeLog()` on the Sheet module hands the last entries to JS. + */ +@objc public class SheetTreeLog: NSObject { + private static let capacity = 32 + private static let lock = NSLock() + private static var entries: [String] = [] + private static let osLog = OSLog(subsystem: "com.sheet2", category: "tree") + + @objc public static func log(_ op: String, details: String) { + let entry = "\(op) \(details)" + + lock.lock() + entries.append(entry) + if entries.count > capacity { + entries.removeFirst(entries.count - capacity) + } + lock.unlock() + + os_log("%{public}@", log: osLog, type: .debug, entry) + } + + @objc public static func snapshot() -> [String] { + lock.lock() + defer { lock.unlock() } + return entries + } + + /// A view named the way a crash report names it: the React tag when the view + /// carries one, and the address, which is what an EXC_BAD_ACCESS points at. + @objc public static func name(_ view: UIView?) -> String { + guard let view else { return "-" } + return "tag=\(view.tag)/\(Unmanaged.passUnretained(view).toOpaque())" + } +} diff --git a/ios/SheetView.mm b/ios/SheetView.mm index 994d0f8..16dfd84 100644 --- a/ios/SheetView.mm +++ b/ios/SheetView.mm @@ -64,6 +64,13 @@ - (instancetype)initWithFrame:(CGRect)frame return self; } +// The sheet content is rendered in another view hierarchy; this placeholder must never take touches. +- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event +{ + UIView *view = [super hitTest:point withEvent:event]; + return view == self || view == _view2 ? nil : view; +} + - (void)_bindStateUpdater { __weak SheetView *weakSelf = self; @@ -110,7 +117,10 @@ - (void)updateProps:(Props::Shared const &)props oldProps:(Props::Shared const & [_view2 setFittedSheetParams:@{ @"maxWidth": @(newViewProps.maxWidth), @"dismissable": @(newViewProps.dismissable), - @"topLeftRightCornerRadius": @(newViewProps.topLeftRightCornerRadius) + @"topLeftRightCornerRadius": @(newViewProps.topLeftRightCornerRadius), + @"dismissOnOverlayTap": @(newViewProps.dismissOnOverlayTap), + @"overlayOpacity": @(newViewProps.overlayOpacity), + @"collapsedHeight": @(newViewProps.collapsedHeight) }]; auto color = RCTUIColorFromSharedColor(newViewProps.sheetBackgroundColor); @@ -135,6 +145,10 @@ - (void)updateProps:(Props::Shared const &)props oldProps:(Props::Shared const & } - (void)mountChildComponentView:(UIView *)childComponentView index:(NSInteger)index { + [SheetTreeLog log:@"host.mountChild" + details:[NSString stringWithFormat:@"child=%@ index=%ld host=%@", + [SheetTreeLog name:childComponentView], (long)index, + [SheetTreeLog name:_view2]]]; [_view2 insertReactSubview:childComponentView atIndex:index]; __weak SheetView *weakSelf = self; @@ -150,10 +164,18 @@ - (void)mountChildComponentView:(UIView *)childCompone } - (void)unmountChildComponentView:(UIView *)childComponentView index:(NSInteger)index { + [SheetTreeLog log:@"host.unmountChild" + details:[NSString stringWithFormat:@"child=%@ index=%ld host=%@", + [SheetTreeLog name:childComponentView], (long)index, + [SheetTreeLog name:_view2]]]; [_view2 removeReactSubview:childComponentView]; } - (void)prepareForRecycle { + // The HostFittedSheet is thrown away and rebuilt here, so anything the + // mounting layer still holds from before this point is stale. + [SheetTreeLog log:@"host.prepareForRecycle" + details:[NSString stringWithFormat:@"host=%@", [SheetTreeLog name:_view2]]]; [_view2 destroy]; _view2.onSheetDismiss = nil; diff --git a/package.json b/package.json index 90eee62..f92fb4d 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "example" ], "private": "true", - "version": "7.10.2", + "version": "7.12.0", "description": "Native implementation of Bottom sheet", "main": "src/index.tsx", "module": "src/index", diff --git a/src/FittedSheet.tsx b/src/FittedSheet.tsx index d18958b..397883b 100644 --- a/src/FittedSheet.tsx +++ b/src/FittedSheet.tsx @@ -38,15 +38,20 @@ export interface FittedSheetParams { * - 'bottom' (default): anchored to the bottom of the screen. * - 'center': floats in the vertical center as a dialog card, the whole * screen is dimmed, dismissed by swiping down or tapping the dim. + * - 'footer': with `useInlinePresentation`, a static bar pinned to the bottom + * of the screen above inline sheets; no gestures, no dim. Android only for now. * When 'center', `useInlinePresentation` is ignored (always modal). */ - presentationStyle?: 'bottom' | 'center'; + presentationStyle?: 'bottom' | 'center' | 'footer'; /** * Enter/exit animation for `presentationStyle: 'center'`. Ignored otherwise. * - 'fade' (default): fades + scales in at the center. * - 'slide': slides up from the bottom and settles at the center. */ centerAnimation?: 'fade' | 'slide'; + dismissOnOverlayTap?: boolean; + overlayOpacity?: number; + collapsedHeight?: number; /** * Android only */ @@ -155,7 +160,8 @@ export class PrivateFittedSheet extends React.PureComponent { componentWillUnmount() { this.log('componentWillUnmount'); - this.hide(); + // The native view tears the sheet down when it is removed; a command sent to a view + // that is being deleted crashes Fabric. this.cleanup?.(); this.cleanup = undefined; } @@ -173,9 +179,13 @@ export class PrivateFittedSheet extends React.PureComponent { render() { if (!this.state.show) return null; + const insetsHeight = + this.props.params?.presentationStyle === 'footer' + ? 0 + : this.insets().top + this.insets().bottom; let maxHeight = Math.min( this.props.params?.maxHeight ?? Number.MAX_VALUE, - this.dimensions.height - this.insets().top - this.insets().bottom + this.dimensions.height - insetsHeight ); const paramsMaxWidth = this.state.isLandscape ? this.props.params?.maxLandscapeWidth @@ -233,6 +243,13 @@ export class PrivateFittedSheet extends React.PureComponent { useInlinePresentation={this.props.params?.useInlinePresentation} presentationStyle={this.props.params?.presentationStyle ?? 'bottom'} centerAnimation={this.props.params?.centerAnimation ?? 'fade'} + dismissOnOverlayTap={ + this.props.params?.dismissOnOverlayTap ?? + this.props.params?.dismissable ?? + true + } + overlayOpacity={this.props.params?.overlayOpacity ?? -1} + collapsedHeight={this.props.params?.collapsedHeight ?? 0} isSystemUILight={this.props.params?.isSystemUILight ?? true} calculatedHeight={nativeHeight} passScrollViewReactTag={this.state.passScrollViewReactTag} diff --git a/src/NativeSheet.ts b/src/NativeSheet.ts index cc55f47..f0d3d19 100644 --- a/src/NativeSheet.ts +++ b/src/NativeSheet.ts @@ -13,6 +13,8 @@ export interface Spec extends TurboModule { viewportSize(): { width: Double; height: Double }; dismissAll(): void; dismissPresented(): void; + /** Last native view-tree moves this library made — see SheetTreeLog. */ + getTreeLog(): string[]; } export default TurboModuleRegistry.getEnforcing('Sheet'); diff --git a/src/SheetTreeLog.ts b/src/SheetTreeLog.ts new file mode 100644 index 0000000..a47f3af --- /dev/null +++ b/src/SheetTreeLog.ts @@ -0,0 +1,39 @@ +import { DeviceEventEmitter, type EmitterSubscription } from 'react-native'; + +import SheetModule from './NativeSheet'; + +const EVENT = 'sheet2:tree'; + +/** + * Native view-tree operations this library performs on views React Native + * believes it owns: presenting and dismissing a sheet moves them between + * containers, and an overlay child never reaches the host view at all. + * + * The mounting crashes those operations can end in name nothing of ours — the + * Android one is "addViewAt: failed to insert view [child] into parent [parent] + * at index N" with a stack made of React Native and framework frames. Each entry + * here names the views by the id React Native uses as the tag, so an app that + * turns these into breadcrumbs can answer, from the crash report alone, whether + * a sheet had touched the views in question. + * + * Android only for now; the iOS side is not wired yet. + * + * @example + * addSheetTreeListener(entry => + * Sentry.addBreadcrumb({ category: 'sheet2', message: entry, level: 'info' }), + * ) + */ +export function addSheetTreeListener( + handler: (entry: string) => void +): EmitterSubscription { + return DeviceEventEmitter.addListener(EVENT, handler); +} + +/** + * The last native view-tree moves, newest last (32 are kept). iOS has no event + * channel yet, so this is how the trail is read there — for a report attached by + * hand, or from a debug screen. + */ +export function getSheetTreeLog(): string[] { + return SheetModule.getTreeLog(); +} diff --git a/src/SheetViewNativeComponent.ts b/src/SheetViewNativeComponent.ts index 3b99905..a05b799 100644 --- a/src/SheetViewNativeComponent.ts +++ b/src/SheetViewNativeComponent.ts @@ -27,6 +27,9 @@ interface NativeProps extends ViewProps { useInlinePresentation?: boolean; presentationStyle?: string; centerAnimation?: string; + dismissOnOverlayTap?: boolean; + overlayOpacity?: Double; + collapsedHeight?: Double; onSheetDismiss: DirectEventHandler; } diff --git a/src/__tests__/EdgeCases.test.tsx b/src/__tests__/EdgeCases.test.tsx index e6987bc..51097ff 100644 --- a/src/__tests__/EdgeCases.test.tsx +++ b/src/__tests__/EdgeCases.test.tsx @@ -549,6 +549,58 @@ describe('Edge Cases', () => { }); describe('Unmounting Edge Cases', () => { + it('does not dispatch a native dismiss command while unmounting a shown sheet', async () => { + const { Commands } = require('../SheetViewNativeComponent'); + const ref = React.createRef(); + const { getByText, unmount } = render( + + + Unmount Command + + + ); + React.act(() => ref.current?.show()); + await waitFor(() => expect(getByText('Unmount Command')).toBeTruthy()); + Commands.dismissSheet.mockClear(); + + unmount(); + + expect(Commands.dismissSheet).not.toHaveBeenCalled(); + }); + + it('still dispatches the native dismiss command for an explicit hide', async () => { + const { Commands } = require('../SheetViewNativeComponent'); + const ref = React.createRef(); + const { getByText } = render( + + + Explicit Hide + + + ); + React.act(() => ref.current?.show()); + await waitFor(() => expect(getByText('Explicit Hide')).toBeTruthy()); + Commands.dismissSheet.mockClear(); + + React.act(() => ref.current?.hide()); + + expect(Commands.dismissSheet).toHaveBeenCalledTimes(1); + }); + + it('sends nothing when unmounting a sheet that was never shown', () => { + const { Commands } = require('../SheetViewNativeComponent'); + Commands.dismissSheet.mockClear(); + const { unmount } = render( + + + + ); + + unmount(); + + expect(Commands.dismissSheet).not.toHaveBeenCalled(); + }); + it('should cleanup when sheet is unmounted while shown', async () => { const TestComponent = ({ mounted }: { mounted: boolean }) => { const sheetRef = useRef(null); diff --git a/src/__tests__/FittedSheet.test.tsx b/src/__tests__/FittedSheet.test.tsx index 8762efd..269250e 100644 --- a/src/__tests__/FittedSheet.test.tsx +++ b/src/__tests__/FittedSheet.test.tsx @@ -238,6 +238,111 @@ describe('FittedSheet', () => { expect(native.props.centerAnimation).toBe('slide'); }); + it('forwards overlay and snap params to the native component', () => { + const ref = React.createRef(); + const tree = render( + + + + ); + act(() => ref.current?.show()); + const native = tree.UNSAFE_getByType(SheetViewNativeComponent); + expect(native.props.dismissOnOverlayTap).toBe(false); + expect(native.props.overlayOpacity).toBe(0); + expect(native.props.collapsedHeight).toBe(500); + }); + + it('forwards the footer presentation style', () => { + const ref = React.createRef(); + const tree = render( + + + + ); + act(() => ref.current?.show()); + const native = tree.UNSAFE_getByType(SheetViewNativeComponent); + expect(native.props.presentationStyle).toBe('footer'); + }); + + it('lets a footer span the whole screen height without inset clamping', () => { + const ref = React.createRef(); + const tree = render( + + + + ); + act(() => ref.current?.show()); + const native = tree.UNSAFE_getByType(SheetViewNativeComponent); + expect(native.props.maxHeight).toBe(812); + }); + + it('lets tap-outside follow dismissable when not set', () => { + const ref = React.createRef(); + const tree = render( + + + + ); + act(() => ref.current?.show()); + const native = tree.UNSAFE_getByType(SheetViewNativeComponent); + expect(native.props.dismissable).toBe(true); + expect(native.props.dismissOnOverlayTap).toBe(true); + }); + + it('keeps an explicit maxHeight for a footer', () => { + const ref = React.createRef(); + const tree = render( + + + + ); + act(() => ref.current?.show()); + const native = tree.UNSAFE_getByType(SheetViewNativeComponent); + expect(native.props.maxHeight).toBe(120); + }); + + it('still clamps a regular sheet by the safe-area insets', () => { + const ref = React.createRef(); + const tree = render( + + + + ); + act(() => ref.current?.show()); + const native = tree.UNSAFE_getByType(SheetViewNativeComponent); + expect(native.props.maxHeight).toBe(812 - 44 - 34); + }); + + it('defaults overlay and snap params', () => { + const ref = React.createRef(); + const tree = render( + + + + ); + act(() => ref.current?.show()); + const native = tree.UNSAFE_getByType(SheetViewNativeComponent); + expect(native.props.dismissOnOverlayTap).toBe(false); + expect(native.props.overlayOpacity).toBe(-1); + expect(native.props.collapsedHeight).toBe(0); + }); + it('defaults presentationStyle to bottom and centerAnimation to fade', () => { const ref = React.createRef(); const tree = render( diff --git a/src/index.tsx b/src/index.tsx index 0e8cbe4..e7586e9 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -9,6 +9,7 @@ import { dismissFittedPresented, } from './PublicSheetView'; import SheetModule from './NativeSheet'; +import { addSheetTreeListener, getSheetTreeLog } from './SheetTreeLog'; import type { ReactNode } from 'react'; import { Fragment } from 'react'; @@ -49,5 +50,7 @@ export { presentGlobalFittedSheet, dismissGlobalFittedSheet, attachScrollViewToGlobalFittedSheet, + addSheetTreeListener, + getSheetTreeLog, type FittedSheetParams, };