diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b732f4d..1e75813d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### 🎉 New features + +- New `TrueSheetPeek` component that marks part of the content as the sheet's peek content — the `peek` detent height covers the content through its bottom edge (measured natively), along with the `header` and `footer` heights. ([#730](https://github.com/lodev09/react-native-true-sheet/pull/730) by [@lodev09](https://github.com/lodev09)) + ## 3.11.4 ### 🐛 Bug fixes diff --git a/android/src/main/java/com/lodev09/truesheet/TrueSheetContainerView.kt b/android/src/main/java/com/lodev09/truesheet/TrueSheetContainerView.kt index ea490fce..59259a30 100644 --- a/android/src/main/java/com/lodev09/truesheet/TrueSheetContainerView.kt +++ b/android/src/main/java/com/lodev09/truesheet/TrueSheetContainerView.kt @@ -2,8 +2,10 @@ 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.util.RNLog import com.facebook.react.views.view.ReactViewGroup interface TrueSheetContainerViewDelegate { @@ -13,6 +15,7 @@ interface TrueSheetContainerViewDelegate { fun containerViewScrollViewDidChange() fun containerViewHeaderDidChangeSize(width: Int, height: Int) fun containerViewFooterDidChangeSize(width: Int, height: Int) + fun containerViewPeekDidChangeSize(width: Int, height: Int) } /** @@ -24,18 +27,40 @@ 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 + /** + * Distance from the top of the content view to the bottom of the peek view. + * Includes the peek view's offset within the content (padding, views above it) + * so the peek detent reveals everything down to the peek content's bottom edge. + */ + val peekContentHeight: Int + get() { + val peek = peekView ?: return 0 + val content = contentView ?: return peek.height + + var bottom = peek.height + var view: View = peek + while (view !== content) { + bottom += view.top + view = view.parent as? View ?: return peek.height + } + + return bottom + } + var insetAdjustment: TrueSheetInsetAdjustment = TrueSheetInsetAdjustment.AUTOMATIC var scrollViewBottomInset: Int = 0 var scrollableEnabled: Boolean = false @@ -75,6 +100,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 +142,41 @@ 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) return + + if (peekView != null) { + RNLog.w(context as ThemedReactContext, "TrueSheet: Sheet can only have one peek component.") + 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 +202,10 @@ class TrueSheetContainerView(reactContext: ThemedReactContext) : delegate?.containerViewFooterDidChangeSize(width, height) } + override fun peekViewDidChangeSize(width: Int, height: Int) { + 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..62791084 --- /dev/null +++ b/android/src/main/java/com/lodev09/truesheet/TrueSheetPeekView.kt @@ -0,0 +1,66 @@ +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. + * The peek detent covers the content through this view's bottom edge. + */ +@SuppressLint("ViewConstructor") +class TrueSheetPeekView(context: ThemedReactContext) : ReactViewGroup(context) { + var delegate: TrueSheetPeekViewDelegate? = null + + override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) { + super.onLayout(changed, left, top, right, bottom) + + attachToContainerView() + + // Position changes matter too — the peek's offset within the content + // affects the peek detent. + if (changed) { + delegate?.peekViewDidChangeSize(width, height) + } + } + + 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..407ef4af --- /dev/null +++ b/android/src/main/java/com/lodev09/truesheet/TrueSheetPeekViewManager.kt @@ -0,0 +1,33 @@ +package com.lodev09.truesheet + +import com.facebook.react.module.annotations.ReactModule +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 + */ +@ReactModule(name = TrueSheetPeekViewManager.REACT_CLASS) +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/footer.mdx b/docs/docs/guides/footer.mdx index c2a6b81e..bcf6b23f 100644 --- a/docs/docs/guides/footer.mdx +++ b/docs/docs/guides/footer.mdx @@ -91,3 +91,23 @@ When the keyboard opens, that bottom padding leaves a gap above the keyboard. Pa :::note On iPad, the sheet is displayed as a floating modal, so bottom padding is not needed. ::: + +## Collapsing to the Footer + +The footer height counts toward the [`"peek"`](../reference/types#sheetdetent) detent — collapse the sheet down to just the [`header`](header), footer, and any peeking content. + +```tsx {4-4} +const App = () => { + return ( + } + > + + + ) +} +``` + +Since the footer floats, it stays visible at every detent. See [Peeking Content](peeking) for the full guide. diff --git a/docs/docs/guides/header.mdx b/docs/docs/guides/header.mdx index eb37d36e..06e6214a 100644 --- a/docs/docs/guides/header.mdx +++ b/docs/docs/guides/header.mdx @@ -85,7 +85,7 @@ 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`. +The peek height automatically follows the measured header and footer sizes — no `onLayout` juggling needed. You can also include part of the content via the `TrueSheetPeek` component — see [Peeking Content](peeking) for the full guide. ## Platform Support diff --git a/docs/docs/guides/peeking.mdx b/docs/docs/guides/peeking.mdx new file mode 100644 index 00000000..c99a9d0d --- /dev/null +++ b/docs/docs/guides/peeking.mdx @@ -0,0 +1,107 @@ +--- +title: Peeking Content +description: Collapse the sheet to a compact peek that expands into full content. +keywords: [bottom sheet peek, bottom sheet collapsed, bottom sheet snap points, map bottom sheet] +--- + +Building a map-style sheet with a compact summary that expands into full content? The [`"peek"`](../reference/types#sheetdetent) detent collapses the sheet down to just the essentials — no manual height juggling needed. + +## The `"peek"` Detent + +Add `"peek"` to your [`detents`](../reference/configuration#detents) to get a collapsed size based on the measured [`header`](header) and [`footer`](footer) heights. + +```tsx {4-4} +const App = () => { + return ( + } + footer={} + scrollable + > + + + ) +} +``` + +The peek height automatically follows the measured header and footer sizes. When neither `header` nor `footer` is provided, it falls back to a fixed height of `150`. + +## Peeking Part of the Content + +To include part of the content in the peek height, wrap it with the `TrueSheetPeek` component. + +```tsx {8-10} +import { TrueSheet, TrueSheetPeek } from '@lodev09/react-native-true-sheet' + +const App = () => { + return ( + + + + + + + ) +} +``` + +When collapsed, the sheet reveals everything from the top of the sheet through the **bottom edge** of `TrueSheetPeek` — content padding and anything rendered above it count toward the height, while content below it stays hidden until the sheet is expanded. + +``` +┌──────────────────────┐ +│ header │ ─┐ +│ ┌──────────────────┐ │ │ +│ │ TrueSheetPeek │ │ ├─ visible when collapsed +│ └──────────────────┘ │ │ +│ footer │ ─┘ +├──────────────────────┤ +│ rest of the content │ ── hidden until expanded +└──────────────────────┘ +``` + +:::info +`TrueSheetPeek` can be placed anywhere within the content — the collapsed sheet simply reveals everything above it, including itself. A sheet can only have **one** peek component. +::: + +:::tip +Since everything above it counts, an **empty** `TrueSheetPeek` works as a fold marker — drop it between your existing views to mark where the collapsed sheet ends, no wrapping needed. + +```tsx {3} + + + + + +``` +::: + +## Styling + +`TrueSheetPeek` is a regular `View` — style it like any other. + +```tsx + + True Sheet + The true native bottom sheet experience. + +``` + +Note that only the content through its bottom edge counts — a `marginBottom` on the peek itself is *not* included. If you want breathing room below the peek content while collapsed, use `paddingBottom` instead. + +## Safe Area + +By default ([`insetAdjustment="automatic"`](../reference/configuration#insetadjustment)), the bottom safe area inset is added to the peek height so the peek content clears the system navigation bar. Set `insetAdjustment="never"` if you're handling insets yourself. + +## Resizing + +`"peek"` works like any other detent — resize to it programmatically or listen for changes. See [Resizing Programmatically](resizing). + +```tsx +sheet.current?.resize(0) // collapse back to peek +``` + +## Platform Support + +The `"peek"` detent and `TrueSheetPeek` are supported on **iOS 16+**, **Android**, and **Web**. diff --git a/docs/docs/reference/04-types.mdx b/docs/docs/reference/04-types.mdx index d63f45c1..d93b25db 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 content up to the bottom of a [`TrueSheetPeek`](../guides/peeking) component rendered within it. 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/bare/ios/Podfile.lock b/example/bare/ios/Podfile.lock index a08725d9..01a75f4e 100644 --- a/example/bare/ios/Podfile.lock +++ b/example/bare/ios/Podfile.lock @@ -2049,7 +2049,7 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga - - RNTrueSheet (3.11.3): + - RNTrueSheet (3.11.4): - hermes-engine - RCTRequired - RCTTypeSafety @@ -2481,7 +2481,7 @@ SPEC CHECKSUMS: RNGestureHandler: 2ff61eac036eaf89f6818bf4ed9c39771a17d134 RNReanimated: f735b1747a7a93bda7ca102c6d37a3cf54b6d5e8 RNScreens: 991cc417cd396602a6cf59a42139e5a9d91462a9 - RNTrueSheet: 353c65cecd44828c2e8be1bed59f9766003e8d1c + RNTrueSheet: 4dcd3292bcfe21502d48e0fd26b4fb3f68da0ad9 RNWorklets: 4931990f73bc8f347586918b2e13f11dfadf3b75 Yoga: 77dfa8673de2874e1855002ae59c68b8be9b007b diff --git a/example/shared/src/components/sheets/BasicSheet.tsx b/example/shared/src/components/sheets/BasicSheet.tsx index cb33ee9a..e6c16734 100644 --- a/example/shared/src/components/sheets/BasicSheet.tsx +++ b/example/shared/src/components/sheets/BasicSheet.tsx @@ -1,6 +1,11 @@ import { forwardRef, useRef, useState, type Ref, useImperativeHandle } from 'react'; import { StyleSheet } from 'react-native'; -import { TrueSheet, useTrueSheet, type TrueSheetProps } from '@lodev09/react-native-true-sheet'; +import { + TrueSheet, + TrueSheetPeek, + useTrueSheet, + type TrueSheetProps, +} from '@lodev09/react-native-true-sheet'; import { BLUE, DARK, DARK_BLUE, FOOTER_HEIGHT, GAP, SPACING, times } from '../../utils'; import { DemoContent } from '../DemoContent'; @@ -57,7 +62,7 @@ export const BasicSheet = forwardRef((props: BasicSheetProps, ref: Ref 0 ? BLUE : undefined} header={
} footer={