From a8cb0fd0f92e70080c050f2e3b64aac1c3e00c40 Mon Sep 17 00:00:00 2001 From: Jovanni Lo Date: Sun, 12 Jul 2026 03:23:33 +0800 Subject: [PATCH 1/7] feat: add TrueSheetPeek component --- .../truesheet/TrueSheetContainerView.kt | 46 +++++- .../com/lodev09/truesheet/TrueSheetPackage.kt | 3 +- .../lodev09/truesheet/TrueSheetPeekView.kt | 69 +++++++++ .../truesheet/TrueSheetPeekViewManager.kt | 31 ++++ .../com/lodev09/truesheet/TrueSheetView.kt | 5 + .../truesheet/TrueSheetViewController.kt | 5 + .../core/TrueSheetDetentCalculator.kt | 8 +- docs/docs/guides/header.mdx | 19 +++ docs/docs/reference/04-types.mdx | 2 +- example/shared/src/screens/MapScreen.tsx | 5 +- ios/TrueSheetContainerView.h | 13 ++ ios/TrueSheetContainerView.mm | 62 +++++++- ios/TrueSheetPeekView.h | 32 ++++ ios/TrueSheetPeekView.mm | 96 ++++++++++++ ios/TrueSheetView.mm | 10 ++ ios/TrueSheetViewController.h | 1 + ios/TrueSheetViewController.mm | 1 + ios/core/TrueSheetDetentCalculator.h | 5 +- ios/core/TrueSheetDetentCalculator.mm | 3 +- ios/tvos/TrueSheetStubs.mm | 11 ++ package.json | 3 + src/TrueSheet.types.ts | 4 +- src/TrueSheet.web.tsx | 145 +++++++++--------- src/TrueSheetPeek.tsx | 10 ++ src/TrueSheetPeek.web.tsx | 26 ++++ src/__tests__/TrueSheet.test.tsx | 15 +- .../TrueSheetPeekViewNativeComponent.ts | 8 + src/index.ts | 1 + src/mocks/index.ts | 9 +- 29 files changed, 564 insertions(+), 84 deletions(-) create mode 100644 android/src/main/java/com/lodev09/truesheet/TrueSheetPeekView.kt create mode 100644 android/src/main/java/com/lodev09/truesheet/TrueSheetPeekViewManager.kt create mode 100644 ios/TrueSheetPeekView.h create mode 100644 ios/TrueSheetPeekView.mm create mode 100644 src/TrueSheetPeek.tsx create mode 100644 src/TrueSheetPeek.web.tsx create mode 100644 src/fabric/TrueSheetPeekViewNativeComponent.ts diff --git a/android/src/main/java/com/lodev09/truesheet/TrueSheetContainerView.kt b/android/src/main/java/com/lodev09/truesheet/TrueSheetContainerView.kt index ea490fce..84f8b800 100644 --- a/android/src/main/java/com/lodev09/truesheet/TrueSheetContainerView.kt +++ b/android/src/main/java/com/lodev09/truesheet/TrueSheetContainerView.kt @@ -2,6 +2,7 @@ package com.lodev09.truesheet import android.annotation.SuppressLint import android.view.View +import android.view.ViewGroup import com.facebook.react.uimanager.ThemedReactContext import com.facebook.react.uimanager.events.EventDispatcher import com.facebook.react.views.view.ReactViewGroup @@ -13,6 +14,7 @@ interface TrueSheetContainerViewDelegate { fun containerViewScrollViewDidChange() fun containerViewHeaderDidChangeSize(width: Int, height: Int) fun containerViewFooterDidChangeSize(width: Int, height: Int) + fun containerViewPeekDidChangeSize(width: Int, height: Int) } /** @@ -24,17 +26,20 @@ class TrueSheetContainerView(reactContext: ThemedReactContext) : ReactViewGroup(reactContext), TrueSheetContentViewDelegate, TrueSheetHeaderViewDelegate, - TrueSheetFooterViewDelegate { + TrueSheetFooterViewDelegate, + TrueSheetPeekViewDelegate { var delegate: TrueSheetContainerViewDelegate? = null var contentView: TrueSheetContentView? = null var headerView: TrueSheetHeaderView? = null var footerView: TrueSheetFooterView? = null + var peekView: TrueSheetPeekView? = null var contentHeight: Int = 0 var headerHeight: Int = 0 var footerHeight: Int = 0 + var peekContentHeight: Int = 0 var insetAdjustment: TrueSheetInsetAdjustment = TrueSheetInsetAdjustment.AUTOMATIC var scrollViewBottomInset: Int = 0 @@ -75,6 +80,10 @@ class TrueSheetContainerView(reactContext: ThemedReactContext) : child.delegate = this child.scrollableOptions = scrollableOptions contentView = child + + // Children mount bottom-up, so the content subtree is complete here. + // Late-mounted peek views attach themselves instead (see TrueSheetPeekView). + findPeekView(child)?.let { attachPeekView(it) } } is TrueSheetHeaderView -> { @@ -113,6 +122,36 @@ class TrueSheetContainerView(reactContext: ThemedReactContext) : super.removeViewAt(index) } + // ==================== Peek View ==================== + + private fun findPeekView(view: View): TrueSheetPeekView? { + if (view is TrueSheetPeekView) return view + + if (view is ViewGroup) { + for (i in 0 until view.childCount) { + findPeekView(view.getChildAt(i))?.let { return it } + } + } + + return null + } + + fun attachPeekView(view: TrueSheetPeekView) { + if (peekView === view || peekView != null) return + + peekView = view + view.delegate = this + peekViewDidChangeSize(view.width, view.height) + } + + fun detachPeekView(view: TrueSheetPeekView) { + if (peekView !== view) return + + view.delegate = null + peekView = null + peekViewDidChangeSize(0, 0) + } + // ==================== Delegate Implementations ==================== override fun contentViewDidChangeSize(width: Int, height: Int) { @@ -138,6 +177,11 @@ class TrueSheetContainerView(reactContext: ThemedReactContext) : delegate?.containerViewFooterDidChangeSize(width, height) } + override fun peekViewDidChangeSize(width: Int, height: Int) { + peekContentHeight = height + delegate?.containerViewPeekDidChangeSize(width, height) + } + companion object { const val TAG_NAME = "TrueSheet" } diff --git a/android/src/main/java/com/lodev09/truesheet/TrueSheetPackage.kt b/android/src/main/java/com/lodev09/truesheet/TrueSheetPackage.kt index f31e6668..a63ec6a6 100644 --- a/android/src/main/java/com/lodev09/truesheet/TrueSheetPackage.kt +++ b/android/src/main/java/com/lodev09/truesheet/TrueSheetPackage.kt @@ -39,6 +39,7 @@ class TrueSheetPackage : BaseReactPackage() { TrueSheetContainerViewManager(), TrueSheetContentViewManager(), TrueSheetHeaderViewManager(), - TrueSheetFooterViewManager() + TrueSheetFooterViewManager(), + TrueSheetPeekViewManager() ) } diff --git a/android/src/main/java/com/lodev09/truesheet/TrueSheetPeekView.kt b/android/src/main/java/com/lodev09/truesheet/TrueSheetPeekView.kt new file mode 100644 index 00000000..8919dd9c --- /dev/null +++ b/android/src/main/java/com/lodev09/truesheet/TrueSheetPeekView.kt @@ -0,0 +1,69 @@ +package com.lodev09.truesheet + +import android.annotation.SuppressLint +import android.view.ViewParent +import com.facebook.react.uimanager.ThemedReactContext +import com.facebook.react.views.view.ReactViewGroup + +/** + * Delegate interface for peek view size changes + */ +interface TrueSheetPeekViewDelegate { + fun peekViewDidChangeSize(width: Int, height: Int) +} + +/** + * Peek view that marks part of the content as the sheet's peek content. + * Its measured height is included in the peek detent height. + */ +@SuppressLint("ViewConstructor") +class TrueSheetPeekView(context: ThemedReactContext) : ReactViewGroup(context) { + var delegate: TrueSheetPeekViewDelegate? = null + + private var lastWidth = 0 + private var lastHeight = 0 + + override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) { + super.onSizeChanged(w, h, oldw, oldh) + + attachToContainerView() + + if (w != lastWidth || h != lastHeight) { + lastWidth = w + lastHeight = h + delegate?.peekViewDidChangeSize(w, h) + } + } + + override fun onAttachedToWindow() { + super.onAttachedToWindow() + attachToContainerView() + } + + /** + * Peek can be nested anywhere within the content, so it attaches itself to the + * nearest container instead of being mounted by it. The container also searches + * for it when the content is added to cover the initial (bottom-up) mount order. + */ + private fun attachToContainerView() { + if (delegate != null) return + + var parent: ViewParent? = parent + while (parent != null && parent !is TrueSheetContainerView) { + parent = parent.parent + } + + (parent as? TrueSheetContainerView)?.attachPeekView(this) + } + + /** + * Called by the ViewManager when the view is dropped (Fabric unmount) + */ + fun detachFromContainerView() { + (delegate as? TrueSheetContainerView)?.detachPeekView(this) + } + + companion object { + const val TAG_NAME = "TrueSheet" + } +} diff --git a/android/src/main/java/com/lodev09/truesheet/TrueSheetPeekViewManager.kt b/android/src/main/java/com/lodev09/truesheet/TrueSheetPeekViewManager.kt new file mode 100644 index 00000000..9a2ef67b --- /dev/null +++ b/android/src/main/java/com/lodev09/truesheet/TrueSheetPeekViewManager.kt @@ -0,0 +1,31 @@ +package com.lodev09.truesheet + +import com.facebook.react.uimanager.PointerEvents +import com.facebook.react.uimanager.ThemedReactContext +import com.facebook.react.uimanager.ViewGroupManager +import com.facebook.react.uimanager.annotations.ReactProp + +/** + * ViewManager for TrueSheetPeekView + * Marks part of the content as the sheet's peek content + */ +class TrueSheetPeekViewManager : ViewGroupManager() { + + override fun getName(): String = REACT_CLASS + + override fun createViewInstance(reactContext: ThemedReactContext): TrueSheetPeekView = TrueSheetPeekView(reactContext) + + override fun onDropViewInstance(view: TrueSheetPeekView) { + super.onDropViewInstance(view) + view.detachFromContainerView() + } + + @ReactProp(name = "pointerEvents") + fun setPointerEvents(view: TrueSheetPeekView, pointerEventsStr: String?) { + view.pointerEvents = PointerEvents.parsePointerEvents(pointerEventsStr) + } + + companion object { + const val REACT_CLASS = "TrueSheetPeekView" + } +} diff --git a/android/src/main/java/com/lodev09/truesheet/TrueSheetView.kt b/android/src/main/java/com/lodev09/truesheet/TrueSheetView.kt index 75d9bbdf..6f03b91c 100644 --- a/android/src/main/java/com/lodev09/truesheet/TrueSheetView.kt +++ b/android/src/main/java/com/lodev09/truesheet/TrueSheetView.kt @@ -620,6 +620,11 @@ class TrueSheetView(private val reactContext: ThemedReactContext) : viewController.positionFooter() } + override fun containerViewPeekDidChangeSize(width: Int, height: Int) { + // Peek content height affects peek detents + updateSheetIfNeeded() + } + // ==================== RNScreensEventObserverDelegate ==================== override fun presenterScreenWillDisappear() { diff --git a/android/src/main/java/com/lodev09/truesheet/TrueSheetViewController.kt b/android/src/main/java/com/lodev09/truesheet/TrueSheetViewController.kt index 38aecad2..88d340db 100644 --- a/android/src/main/java/com/lodev09/truesheet/TrueSheetViewController.kt +++ b/android/src/main/java/com/lodev09/truesheet/TrueSheetViewController.kt @@ -276,6 +276,7 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) : private var cachedContentHeight: Int = 0 private var cachedHeaderHeight: Int = 0 private var cachedFooterHeight: Int = 0 + private var cachedPeekContentHeight: Int = 0 override val contentHeight: Int get() = containerView?.contentHeight ?: cachedContentHeight @@ -286,6 +287,9 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) : override val footerHeight: Int get() = containerView?.footerHeight ?: cachedFooterHeight + override val peekContentHeight: Int + get() = containerView?.peekContentHeight ?: cachedPeekContentHeight + // Insets // Target keyboard height used for detent calculations override val keyboardInset: Int @@ -841,6 +845,7 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) : cachedContentHeight = it.contentHeight cachedHeaderHeight = it.headerHeight cachedFooterHeight = it.footerHeight + cachedPeekContentHeight = it.peekContentHeight } interactionState = InteractionState.Reconfiguring diff --git a/android/src/main/java/com/lodev09/truesheet/core/TrueSheetDetentCalculator.kt b/android/src/main/java/com/lodev09/truesheet/core/TrueSheetDetentCalculator.kt index d0e03003..4871e9cf 100644 --- a/android/src/main/java/com/lodev09/truesheet/core/TrueSheetDetentCalculator.kt +++ b/android/src/main/java/com/lodev09/truesheet/core/TrueSheetDetentCalculator.kt @@ -16,6 +16,7 @@ interface TrueSheetDetentCalculatorDelegate { val contentHeight: Int val headerHeight: Int val footerHeight: Int + val peekContentHeight: Int val contentBottomInset: Int val maxContentHeight: Int? val keyboardInset: Int @@ -34,17 +35,18 @@ class TrueSheetDetentCalculator(private val reactContext: ThemedReactContext) { private val contentHeight: Int get() = delegate?.contentHeight ?: 0 private val headerHeight: Int get() = delegate?.headerHeight ?: 0 private val footerHeight: Int get() = delegate?.footerHeight ?: 0 + private val peekContentHeight: Int get() = delegate?.peekContentHeight ?: 0 private val contentBottomInset: Int get() = delegate?.contentBottomInset ?: 0 private val maxContentHeight: Int? get() = delegate?.maxContentHeight private val keyboardInset: Int get() = delegate?.keyboardInset ?: 0 /** - * Height for peek (-2.0) detents: header + footer height. - * Falls back to 150dp when neither is present. + * Height for peek (-2.0) detents: header + footer + peek content height. + * Falls back to 150dp when none is present. */ private val peekDetentHeight: Int get() { - val height = headerHeight + footerHeight + val height = headerHeight + footerHeight + peekContentHeight return if (height > 0) height else DEFAULT_PEEK_HEIGHT.dpToPx().toInt() } diff --git a/docs/docs/guides/header.mdx b/docs/docs/guides/header.mdx index eb37d36e..117ba13d 100644 --- a/docs/docs/guides/header.mdx +++ b/docs/docs/guides/header.mdx @@ -87,6 +87,25 @@ const App = () => { The peek height automatically follows the measured header and footer sizes — no `onLayout` juggling needed. When neither `header` nor `footer` is provided, it falls back to a fixed height of `150`. +### Peeking Content + +To include part of the content in the peek height, wrap it with the `TrueSheetPeek` component. Its measured height is added to the header and footer heights. + +```tsx {8-10} +import { TrueSheet, TrueSheetPeek } from '@lodev09/react-native-true-sheet' + +const App = () => { + return ( + + + + + + + ) +} +``` + ## Platform Support The `header` prop is supported on both **iOS** and **Android**. diff --git a/docs/docs/reference/04-types.mdx b/docs/docs/reference/04-types.mdx index d63f45c1..ab35804c 100644 --- a/docs/docs/reference/04-types.mdx +++ b/docs/docs/reference/04-types.mdx @@ -15,7 +15,7 @@ keywords: [bottom sheet types, bottom sheet typescript, bottom sheet definitions | Value | Description | 🍎 | 🤖 | 🌐 | | - | - | - | - | - | | `"auto"` | Auto resize based on content height. | **_16+_** | ✅ | ✅ | -| `"peek"` | Collapsed height based on the combined [`header`](configuration#header) and [`footer`](configuration#footer) heights. Falls back to a fixed height of `150` when neither is provided. | **_16+_** | ✅ | ✅ | +| `"peek"` | Collapsed height based on the combined [`header`](configuration#header) and [`footer`](configuration#footer) heights, plus the measured height of a `TrueSheetPeek` component rendered within the content. Falls back to a fixed height of `150` when none is provided. | **_16+_** | ✅ | ✅ | | `number` | Fractional height (0-1) representing percentage of screen height. | ✅ | ✅ | ✅ | :::warning diff --git a/example/shared/src/screens/MapScreen.tsx b/example/shared/src/screens/MapScreen.tsx index ca929ba5..0d31599a 100644 --- a/example/shared/src/screens/MapScreen.tsx +++ b/example/shared/src/screens/MapScreen.tsx @@ -12,6 +12,7 @@ import { } from 'react-native'; import { TrueSheet, + TrueSheetPeek, type DetentChangeEvent, type DidPresentEvent, type DragBeginEvent, @@ -230,10 +231,10 @@ const MapScreenInner = ({ onDidDismiss={() => log('didDismiss')} header={
} > - + True Sheet The true native bottom sheet experience. - +