From 37a2fba10895d4248dc6f0ac982dbdb3d72eef73 Mon Sep 17 00:00:00 2001 From: SergeyMild <> Date: Wed, 16 Sep 2026 09:59:10 +0300 Subject: [PATCH 01/11] feat: say which views the sheet moved, so a mounting crash can be attributed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The crashes worth chasing name nothing of ours on Android: "addViewAt: failed to insert view [child] into parent [parent] at index N", caused by "IndexOutOfBoundsException: index=N count=0", with a stack made only of React Native and framework frames. On iOS the symbols are ours (HostFittedSheet.setFittedSheetParams, dismissSheet inside RCTMountingManager.performTransaction) but the state that led there is not. So the library says it itself. SheetTreeLog records every place where a view React Native believes it owns is moved by us, naming views the way the crash report names them - by the React tag on Android, by tag and address on iOS: host.addView the previous child is dropped without React Native hearing host.removeView / host.removeViewAt sheet.addView an overlay child never reaches mHostView, yet getChildCount goes on counting it sheet.removeView / sheet.removeViewAt sheet.detachHost the host leaves the dialog container on dismiss inline.attachOverlay / dialog.attachOverlay the overlay is re-parented out of whatever parent React Native gave it sheet.insertReactSubview / sheet.removeReactSubview / sheet.attachOverlay / sheet.destroy the same on iOS host.mountChild / host.unmountChild / host.prepareForRecycle what the mounting layer asked for, and where the HostFittedSheet is thrown away and rebuilt Entries go to logcat under Sheet2Tree and to the unified log under com.sheet2, to a 32-entry ring, and on Android to JS as a "sheet2:tree" event. addSheetTreeListener turns them into app breadcrumbs - which is what survives into a native crash report - and getSheetTreeLog() reads the ring, the only channel iOS has for now. Nothing changes in behaviour: the same views are moved the same way. The two leftover println("😀 ...") debug lines go with it. --- .../main/java/com/sheet2/AppFittedSheet.kt | 28 ++++++++ .../java/com/sheet2/DialogRootViewGroup.kt | 26 +++++-- .../com/sheet2/FragmentModalBottomSheet.kt | 11 ++- .../java/com/sheet2/InlineSheetPresenter.kt | 11 ++- .../src/main/java/com/sheet2/SheetModule.kt | 7 ++ .../src/main/java/com/sheet2/SheetTreeLog.kt | 71 +++++++++++++++++++ ios/HostFittedSheet.swift | 23 ++++++ ios/SheetModule.mm | 4 ++ ios/SheetTreeLog.swift | 49 +++++++++++++ ios/SheetView.mm | 12 ++++ package.json | 2 +- src/NativeSheet.ts | 2 + src/SheetTreeLog.ts | 39 ++++++++++ src/index.tsx | 3 + 14 files changed, 281 insertions(+), 7 deletions(-) create mode 100644 android/src/main/java/com/sheet2/SheetTreeLog.kt create mode 100644 ios/SheetTreeLog.swift create mode 100644 src/SheetTreeLog.ts diff --git a/android/src/main/java/com/sheet2/AppFittedSheet.kt b/android/src/main/java/com/sheet2/AppFittedSheet.kt index 7cd42dd..42adc67 100644 --- a/android/src/main/java/com/sheet2/AppFittedSheet.kt +++ b/android/src/main/java/com/sheet2/AppFittedSheet.kt @@ -211,6 +211,12 @@ open class AppFittedSheet(context: Context) : ViewGroup(context), LifecycleEvent 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 +268,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 +305,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 +326,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) 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..772ac00 100644 --- a/android/src/main/java/com/sheet2/FragmentModalBottomSheet.kt +++ b/android/src/main/java/com/sheet2/FragmentModalBottomSheet.kt @@ -84,7 +84,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/InlineSheetPresenter.kt b/android/src/main/java/com/sheet2/InlineSheetPresenter.kt index 3649a03..e348833 100644 --- a/android/src/main/java/com/sheet2/InlineSheetPresenter.kt +++ b/android/src/main/java/com/sheet2/InlineSheetPresenter.kt @@ -169,7 +169,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, 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/HostFittedSheet.swift b/ios/HostFittedSheet.swift index 27f467b..b1f203f 100644 --- a/ios/HostFittedSheet.swift +++ b/ios/HostFittedSheet.swift @@ -133,7 +133,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 +155,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 +178,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] @@ -416,6 +434,11 @@ 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)" + ) 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..c550077 100644 --- a/ios/SheetView.mm +++ b/ios/SheetView.mm @@ -135,6 +135,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 +154,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..fa5091d 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "example" ], "private": "true", - "version": "7.10.2", + "version": "7.11.0", "description": "Native implementation of Bottom sheet", "main": "src/index.tsx", "module": "src/index", 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/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, }; From 8231ebd2c36d4b4b11b07399197e887a01f3f01b Mon Sep 17 00:00:00 2001 From: SergeyMild <> Date: Tue, 29 Sep 2026 10:41:34 +0300 Subject: [PATCH 02/11] feat: dismissOnOverlayTap, overlayOpacity, collapsedHeight params --- README.md | 3 +++ src/FittedSheet.tsx | 10 +++++++++ src/SheetViewNativeComponent.ts | 3 +++ src/__tests__/FittedSheet.test.tsx | 35 ++++++++++++++++++++++++++++++ 4 files changed, 51 insertions(+) diff --git a/README.md b/README.md index ab7a958..1c278f0 100644 --- a/README.md +++ b/README.md @@ -509,6 +509,9 @@ export const MultipleExample = () => { | `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`. | | `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/src/FittedSheet.tsx b/src/FittedSheet.tsx index d18958b..456f61b 100644 --- a/src/FittedSheet.tsx +++ b/src/FittedSheet.tsx @@ -47,6 +47,9 @@ export interface FittedSheetParams { * - 'slide': slides up from the bottom and settles at the center. */ centerAnimation?: 'fade' | 'slide'; + dismissOnOverlayTap?: boolean; + overlayOpacity?: number; + collapsedHeight?: number; /** * Android only */ @@ -233,6 +236,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/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__/FittedSheet.test.tsx b/src/__tests__/FittedSheet.test.tsx index 8762efd..6dcbcc4 100644 --- a/src/__tests__/FittedSheet.test.tsx +++ b/src/__tests__/FittedSheet.test.tsx @@ -238,6 +238,41 @@ 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('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( From c7e122bd48e6982f97b8ee419af04c48bae71918 Mon Sep 17 00:00:00 2001 From: SergeyMild <> Date: Tue, 29 Sep 2026 10:43:05 +0300 Subject: [PATCH 03/11] feat(android): overlay opacity, tap-outside flag and inline snap points --- .../main/java/com/sheet2/AppFittedSheet.kt | 12 ++++++- .../com/sheet2/CustomBottomSheetDialog.java | 5 +-- .../com/sheet2/FragmentModalBottomSheet.kt | 8 +++++ .../java/com/sheet2/InlineSheetPresenter.kt | 32 +++++++++++++------ .../main/java/com/sheet2/Sheet2ViewManager.kt | 12 +++++++ 5 files changed, 57 insertions(+), 12 deletions(-) diff --git a/android/src/main/java/com/sheet2/AppFittedSheet.kt b/android/src/main/java/com/sheet2/AppFittedSheet.kt index 42adc67..ee289a3 100644 --- a/android/src/main/java/com/sheet2/AppFittedSheet.kt +++ b/android/src/main/java/com/sheet2/AppFittedSheet.kt @@ -47,6 +47,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 @@ -192,7 +195,12 @@ open class AppFittedSheet(context: Context) : ViewGroup(context), LifecycleEvent if (useInlinePresentation) { if (!inlinePresenter.isShown) { - inlinePresenter.show(dismissable) { onSheetDismiss() } + inlinePresenter.show( + dismissable = dismissable, + dismissOnOverlayTap = dismissOnOverlayTap, + overlayOpacity = overlayOpacity, + collapsedHeight = collapsedHeight.toInt(), + ) { onSheetDismiss() } } return } @@ -208,6 +216,8 @@ 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 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/FragmentModalBottomSheet.kt b/android/src/main/java/com/sheet2/FragmentModalBottomSheet.kt index 772ac00..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) diff --git a/android/src/main/java/com/sheet2/InlineSheetPresenter.kt b/android/src/main/java/com/sheet2/InlineSheetPresenter.kt index e348833..41fbbb5 100644 --- a/android/src/main/java/com/sheet2/InlineSheetPresenter.kt +++ b/android/src/main/java/com/sheet2/InlineSheetPresenter.kt @@ -37,13 +37,22 @@ 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 this.onDismiss = onDismiss + snaps = collapsedHeight > 0 + val scrimAlpha = if (overlayOpacity >= 0) overlayOpacity else SCRIM_ALPHA val ctx = anchor.context @@ -69,7 +78,8 @@ 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 { layoutParams = CoordinatorLayout.LayoutParams( @@ -86,9 +96,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 +115,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 +133,18 @@ 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) 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() } } @@ -141,6 +154,7 @@ internal class InlineSheetPresenter( val layout = overlay ?: return val behavior = this.behavior if (animated && behavior != null && behavior.getState() != BottomSheetBehavior.STATE_HIDDEN) { + behavior.setHideable(true) behavior.setState(BottomSheetBehavior.STATE_HIDDEN) return } @@ -156,7 +170,7 @@ internal class InlineSheetPresenter( } fun setDismissable(dismissable: Boolean) { - behavior?.setDraggable(dismissable) + behavior?.setDraggable(dismissable || snaps) } fun setOverlayView(view: View?) { diff --git a/android/src/main/java/com/sheet2/Sheet2ViewManager.kt b/android/src/main/java/com/sheet2/Sheet2ViewManager.kt index 8744e3f..f214e48 100644 --- a/android/src/main/java/com/sheet2/Sheet2ViewManager.kt +++ b/android/src/main/java/com/sheet2/Sheet2ViewManager.kt @@ -101,6 +101,18 @@ 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()) From 696c863951296a08ae07ca3aa999dcde35a4c0f0 Mon Sep 17 00:00:00 2001 From: SergeyMild <> Date: Tue, 29 Sep 2026 11:32:53 +0300 Subject: [PATCH 04/11] feat(android): footer presentation pinned above inline sheets --- .../main/java/com/sheet2/AppFittedSheet.kt | 14 ++++ .../java/com/sheet2/InlineFooterPresenter.kt | 74 +++++++++++++++++++ .../java/com/sheet2/InlineSheetPresenter.kt | 30 +------- src/FittedSheet.tsx | 4 +- src/__tests__/FittedSheet.test.tsx | 15 ++++ 5 files changed, 108 insertions(+), 29 deletions(-) create mode 100644 android/src/main/java/com/sheet2/InlineFooterPresenter.kt diff --git a/android/src/main/java/com/sheet2/AppFittedSheet.kt b/android/src/main/java/com/sheet2/AppFittedSheet.kt index ee289a3..03cf9a8 100644 --- a/android/src/main/java/com/sheet2/AppFittedSheet.kt +++ b/android/src/main/java/com/sheet2/AppFittedSheet.kt @@ -124,6 +124,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() } } @@ -193,6 +197,11 @@ open class AppFittedSheet(context: Context) : ViewGroup(context), LifecycleEvent return } + if (presentationStyle == "footer") { + footerPresenter.show() + return + } + if (useInlinePresentation) { if (!inlinePresenter.isShown) { inlinePresenter.show( @@ -367,6 +376,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/InlineFooterPresenter.kt b/android/src/main/java/com/sheet2/InlineFooterPresenter.kt new file mode 100644 index 0000000..193a561 --- /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 = FrameLayout(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 41fbbb5..af0854a 100644 --- a/android/src/main/java/com/sheet2/InlineSheetPresenter.kt +++ b/android/src/main/java/com/sheet2/InlineSheetPresenter.kt @@ -49,7 +49,7 @@ internal class InlineSheetPresenter( 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 @@ -139,6 +139,7 @@ internal class InlineSheetPresenter( if (scrimAlpha > 0) animateScrim(coordinator, fromAlpha = 0f, toAlpha = scrimAlpha) root.addView(overlayRoot) + InlineFooterPresenter.bringFootersToFront(root) overlay = overlayRoot designBottomSheet.post { @@ -200,32 +201,6 @@ 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 cancelAncestorJsTouches(child: View) { var p: ViewParent? = child.parent while (p != null) { @@ -260,6 +235,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/src/FittedSheet.tsx b/src/FittedSheet.tsx index 456f61b..f582c8c 100644 --- a/src/FittedSheet.tsx +++ b/src/FittedSheet.tsx @@ -38,9 +38,11 @@ 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. diff --git a/src/__tests__/FittedSheet.test.tsx b/src/__tests__/FittedSheet.test.tsx index 6dcbcc4..6243280 100644 --- a/src/__tests__/FittedSheet.test.tsx +++ b/src/__tests__/FittedSheet.test.tsx @@ -259,6 +259,21 @@ describe('FittedSheet', () => { 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('defaults overlay and snap params', () => { const ref = React.createRef(); const tree = render( From d17ec2ef72e2d45a5b66939cd7679105c085bf72 Mon Sep 17 00:00:00 2001 From: SergeyMild <> Date: Tue, 29 Sep 2026 11:53:45 +0300 Subject: [PATCH 05/11] fix(android): let touches outside inline sheets reach React Native views behind --- .../java/com/sheet2/InlineFooterPresenter.kt | 2 +- .../java/com/sheet2/InlineSheetPresenter.kt | 6 +++--- .../main/java/com/sheet2/PassThroughLayouts.kt | 17 +++++++++++++++++ 3 files changed, 21 insertions(+), 4 deletions(-) create mode 100644 android/src/main/java/com/sheet2/PassThroughLayouts.kt diff --git a/android/src/main/java/com/sheet2/InlineFooterPresenter.kt b/android/src/main/java/com/sheet2/InlineFooterPresenter.kt index 193a561..22f814a 100644 --- a/android/src/main/java/com/sheet2/InlineFooterPresenter.kt +++ b/android/src/main/java/com/sheet2/InlineFooterPresenter.kt @@ -19,7 +19,7 @@ internal class InlineFooterPresenter( if (isShown) return val root = findInlineRoot(anchor) ?: return - val overlayRoot = FrameLayout(anchor.context).apply { + val overlayRoot = PassThroughFrameLayout(anchor.context).apply { layoutParams = ViewGroup.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT, diff --git a/android/src/main/java/com/sheet2/InlineSheetPresenter.kt b/android/src/main/java/com/sheet2/InlineSheetPresenter.kt index af0854a..84b040d 100644 --- a/android/src/main/java/com/sheet2/InlineSheetPresenter.kt +++ b/android/src/main/java/com/sheet2/InlineSheetPresenter.kt @@ -56,14 +56,14 @@ internal class InlineSheetPresenter( 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, @@ -81,7 +81,7 @@ internal class InlineSheetPresenter( // 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, 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 +} From c124e85b6ff9403cc2df9e336fd37420bcb179be Mon Sep 17 00:00:00 2001 From: SergeyMild <> Date: Tue, 29 Sep 2026 12:33:20 +0300 Subject: [PATCH 06/11] fix: sheet placeholder never takes touches; footer spans the full screen height --- .../src/main/java/com/sheet2/AppFittedSheet.kt | 10 +++++++++- src/FittedSheet.tsx | 6 +++++- src/__tests__/FittedSheet.test.tsx | 15 +++++++++++++++ 3 files changed, 29 insertions(+), 2 deletions(-) diff --git a/android/src/main/java/com/sheet2/AppFittedSheet.kt b/android/src/main/java/com/sheet2/AppFittedSheet.kt index 03cf9a8..841d234 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) diff --git a/src/FittedSheet.tsx b/src/FittedSheet.tsx index f582c8c..e6afdd0 100644 --- a/src/FittedSheet.tsx +++ b/src/FittedSheet.tsx @@ -178,9 +178,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 diff --git a/src/__tests__/FittedSheet.test.tsx b/src/__tests__/FittedSheet.test.tsx index 6243280..d15f609 100644 --- a/src/__tests__/FittedSheet.test.tsx +++ b/src/__tests__/FittedSheet.test.tsx @@ -274,6 +274,21 @@ describe('FittedSheet', () => { 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('defaults overlay and snap params', () => { const ref = React.createRef(); const tree = render( From 4a098ce921c5d42037c6d7131a246214f7aad5bf Mon Sep 17 00:00:00 2001 From: SergeyMild <> Date: Tue, 29 Sep 2026 12:52:49 +0300 Subject: [PATCH 07/11] fix(android): hide the keyboard when an inline sheet with a focused input is dismissed --- .../src/main/java/com/sheet2/InlineSheetPresenter.kt | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/android/src/main/java/com/sheet2/InlineSheetPresenter.kt b/android/src/main/java/com/sheet2/InlineSheetPresenter.kt index 84b040d..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 @@ -153,6 +155,7 @@ 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) @@ -201,6 +204,13 @@ internal class InlineSheetPresenter( host.addView(view) } + 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) { var p: ViewParent? = child.parent while (p != null) { From 77f25ba5a4cbe27702e74af22247210b57e0a489 Mon Sep 17 00:00:00 2001 From: SergeyMild <> Date: Tue, 29 Sep 2026 13:37:15 +0300 Subject: [PATCH 08/11] feat(ios): overlay opacity, tap-outside flag, inline snap points, footer presentation; own touches outside RN surfaces --- ios/FittedSheets/SheetViewController.swift | 1 + ios/HostFittedSheet.swift | 111 +++++++++++++++++++-- ios/SheetView.mm | 12 ++- 3 files changed, 117 insertions(+), 7 deletions(-) diff --git a/ios/FittedSheets/SheetViewController.swift b/ios/FittedSheets/SheetViewController.swift index c8823fd..f8de758 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 { diff --git a/ios/HostFittedSheet.swift b/ios/HostFittedSheet.swift index b1f203f..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 { @@ -226,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 { @@ -246,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) @@ -273,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) @@ -299,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 @@ -345,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() @@ -356,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() @@ -387,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 @@ -423,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. @@ -439,6 +530,14 @@ public final class HostFittedSheet: UIView { 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/SheetView.mm b/ios/SheetView.mm index c550077..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); From 766a5ca20a8bd19a60b2d679bec5ad205872cc5d Mon Sep 17 00:00:00 2001 From: SergeyMild <> Date: Tue, 29 Sep 2026 13:48:12 +0300 Subject: [PATCH 09/11] fix(ios): with a transparent overlay only pass through touches outside the card --- ios/FittedSheets/SheetViewController.swift | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/ios/FittedSheets/SheetViewController.swift b/ios/FittedSheets/SheetViewController.swift index f8de758..b9e2c0d 100644 --- a/ios/FittedSheets/SheetViewController.swift +++ b/ios/FittedSheets/SheetViewController.swift @@ -676,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)) } } From eca5ba3b85ce2d39e6e9e663f5785628e8d4047f Mon Sep 17 00:00:00 2001 From: SergeyMild <> Date: Tue, 29 Sep 2026 14:30:19 +0300 Subject: [PATCH 10/11] fix: never send a dismiss command while unmounting; Android drops the sheet with its view --- .../main/java/com/sheet2/AppFittedSheet.kt | 2 +- .../main/java/com/sheet2/Sheet2ViewManager.kt | 5 +++++ src/FittedSheet.tsx | 3 ++- src/__tests__/EdgeCases.test.tsx | 19 +++++++++++++++++++ 4 files changed, 27 insertions(+), 2 deletions(-) diff --git a/android/src/main/java/com/sheet2/AppFittedSheet.kt b/android/src/main/java/com/sheet2/AppFittedSheet.kt index 841d234..1ce44a3 100644 --- a/android/src/main/java/com/sheet2/AppFittedSheet.kt +++ b/android/src/main/java/com/sheet2/AppFittedSheet.kt @@ -372,7 +372,7 @@ open class AppFittedSheet(context: Context) : dismiss() } - private fun onDropInstance() { + internal fun onDropInstance() { (context as ReactContext).removeLifecycleEventListener(this) dismiss() } diff --git a/android/src/main/java/com/sheet2/Sheet2ViewManager.kt b/android/src/main/java/com/sheet2/Sheet2ViewManager.kt index f214e48..5f84838 100644 --- a/android/src/main/java/com/sheet2/Sheet2ViewManager.kt +++ b/android/src/main/java/com/sheet2/Sheet2ViewManager.kt @@ -118,6 +118,11 @@ class Sheet2ViewManager(reactContext: ReactApplicationContext) : ViewGroupManage 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/src/FittedSheet.tsx b/src/FittedSheet.tsx index e6afdd0..397883b 100644 --- a/src/FittedSheet.tsx +++ b/src/FittedSheet.tsx @@ -160,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; } diff --git a/src/__tests__/EdgeCases.test.tsx b/src/__tests__/EdgeCases.test.tsx index e6987bc..29ef3ba 100644 --- a/src/__tests__/EdgeCases.test.tsx +++ b/src/__tests__/EdgeCases.test.tsx @@ -549,6 +549,25 @@ 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('should cleanup when sheet is unmounted while shown', async () => { const TestComponent = ({ mounted }: { mounted: boolean }) => { const sheetRef = useRef(null); From d5b1cabc60ee03f275b042c2a39862dcd2e2701c Mon Sep 17 00:00:00 2001 From: SergeyMild <> Date: Tue, 29 Sep 2026 14:51:14 +0300 Subject: [PATCH 11/11] test: cover defaults, footer height and dismiss commands; bump version to 7.12.0 --- README.md | 2 +- package.json | 2 +- src/__tests__/EdgeCases.test.tsx | 33 ++++++++++++++++++++++++ src/__tests__/FittedSheet.test.tsx | 40 ++++++++++++++++++++++++++++++ 4 files changed, 75 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 1c278f0..bbcfa4d 100644 --- a/README.md +++ b/README.md @@ -507,7 +507,7 @@ 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. | diff --git a/package.json b/package.json index fa5091d..f92fb4d 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "example" ], "private": "true", - "version": "7.11.0", + "version": "7.12.0", "description": "Native implementation of Bottom sheet", "main": "src/index.tsx", "module": "src/index", diff --git a/src/__tests__/EdgeCases.test.tsx b/src/__tests__/EdgeCases.test.tsx index 29ef3ba..51097ff 100644 --- a/src/__tests__/EdgeCases.test.tsx +++ b/src/__tests__/EdgeCases.test.tsx @@ -568,6 +568,39 @@ describe('Edge Cases', () => { 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 d15f609..269250e 100644 --- a/src/__tests__/FittedSheet.test.tsx +++ b/src/__tests__/FittedSheet.test.tsx @@ -289,6 +289,46 @@ describe('FittedSheet', () => { 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(