From 4b9b0e804accb8c3fd59a2ad1ab9a9cff980d6da Mon Sep 17 00:00:00 2001 From: Jovanni Lo Date: Thu, 16 Jul 2026 06:29:17 +0800 Subject: [PATCH 01/20] feat!: synchronous per-detent container layout (#735) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat!: synchronous per-detent container layout Size the container to the sheet's visible height per detent instead of screen height / largest detent. Requires RN 0.82+. - iOS: report all size changes; immediate state updates relayout Yoga within the same UIKit layout pass - Android: per-detent state sizing (grow before animation, shrink on settle); remove scroll expansion padding workaround - JS: container always fills the sheet so flex layouts track detents - Web: sized flex wrapper for non-scrollable sheets (auto/form keep content-fit flow) * feat(ios): drop AutoLayout constraints in favor of Yoga layout Container and footer frames now come purely from Yoga (synchronous state updates land them in the same UIKit layout pass). Footer is pinned via bottom:0 style; keyboard avoidance uses a transform. Removes LayoutUtil. * feat!: auto-detect scrollables, deprecate scrollable prop ScrollViews/FlatLists now plug in directly like a regular view: - Content fills the sheet by default (except with auto detents, which derive height from natural content) - Native scroll wiring (insets, keyboard, nested scrolling, edge effects) always applies to the detected ScrollView - iOS: removed scrollview frame-forcing; Yoga owns the frame - scrollable prop is deprecated and a no-op on native * feat(android): resize container in realtime while dragging Track the sheet's visible height per-frame during drag (and post-release settle) so the container resizes with the finger like iOS, killing the settle flicker. Skip detent reconfigure mid-drag — the size change is driven by the drag itself and resetting behavior state kills the gesture. * feat(android): synchronous Fabric state updates via JNI Kotlin's StateWrapper.updateState is hardcoded async, so the container's Yoga layout lagged the sheet by a frame or two during drags (footer jumping). Bridge to ConcreteState::updateState with unstable_Immediate — commits and mounts within the same UI-thread frame, like iOS. Falls back to the async path when the codegen lib isn't loadable (merged-so builds). * docs: add PR link to changelog entries * chore(docs): require Node 24 for Vercel builds * chore: run install * feat(web): track sheet visible height in realtime while dragging * feat!: remove deprecated scrollable prop Scrollables are auto-detected — the prop was already a no-op on native. Web now always uses the sized content-fill layout for plugged scrollables. Docs updated for auto-detection. --- CHANGELOG.md | 6 + .../truesheet/TrueSheetContainerView.kt | 3 +- .../lodev09/truesheet/TrueSheetContentView.kt | 24 +--- .../truesheet/TrueSheetStateUpdater.kt | 27 +++++ .../com/lodev09/truesheet/TrueSheetView.kt | 22 ++-- .../truesheet/TrueSheetViewController.kt | 87 +++++++++----- .../lodev09/truesheet/TrueSheetViewManager.kt | 5 - .../src/main/jni/TrueSheetStateUpdater.cpp | 56 +++++++++ docs/docs/guides/header.mdx | 1 - docs/docs/guides/keyboard.mdx | 4 +- docs/docs/guides/peeking.mdx | 1 - docs/docs/guides/scrolling.mdx | 60 ++-------- docs/docs/migration.mdx | 8 +- docs/docs/reference/01-configuration.mdx | 16 +-- docs/docs/reference/04-types.mdx | 5 +- docs/package.json | 2 +- .../src/components/sheets/FlatListSheet.tsx | 1 - .../src/components/sheets/PromptSheet.tsx | 1 - .../src/components/sheets/ScrollViewSheet.tsx | 1 - ios/TrueSheetContainerView.h | 10 -- ios/TrueSheetContainerView.mm | 32 +----- ios/TrueSheetContentView.h | 10 +- ios/TrueSheetContentView.mm | 43 +------ ios/TrueSheetFooterView.h | 2 - ios/TrueSheetFooterView.mm | 83 ++------------ ios/TrueSheetHeaderView.mm | 1 - ios/TrueSheetView.mm | 22 +--- ios/TrueSheetViewController.mm | 13 ++- ios/utils/LayoutUtil.h | 77 ------------- ios/utils/LayoutUtil.mm | 106 ------------------ package.json | 2 +- src/TrueSheet.tsx | 23 ++-- src/TrueSheet.types.ts | 11 -- src/TrueSheet.web.tsx | 106 +++++++++++++----- src/fabric/TrueSheetViewNativeComponent.ts | 1 - src/navigation/types.ts | 1 - yarn.lock | 2 +- 37 files changed, 305 insertions(+), 570 deletions(-) create mode 100644 android/src/main/java/com/lodev09/truesheet/TrueSheetStateUpdater.kt create mode 100644 android/src/main/jni/TrueSheetStateUpdater.cpp delete mode 100644 ios/utils/LayoutUtil.h delete mode 100644 ios/utils/LayoutUtil.mm diff --git a/CHANGELOG.md b/CHANGELOG.md index 3be07a68..4b581efd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ ## Unreleased +### 💥 Breaking changes + +- Requires React Native 0.82+. ([#735](https://github.com/lodev09/react-native-true-sheet/pull/735) by [@lodev09](https://github.com/lodev09)) +- Synchronous per-detent container layout — the container is sized to the sheet's visible height per detent and tracks it in realtime while dragging, on all platforms. Previously it was sized to the screen height / largest detent; layouts relying on that may shift. ([#735](https://github.com/lodev09/react-native-true-sheet/pull/735) by [@lodev09](https://github.com/lodev09)) +- Removed the `scrollable` prop — scrollables are auto-detected. `ScrollView`/`FlatList` work plugged in directly, like a regular view; insets, keyboard handling, and nested scrolling are wired automatically. ([#735](https://github.com/lodev09/react-native-true-sheet/pull/735) by [@lodev09](https://github.com/lodev09)) + ## 3.11.9 ### 🐛 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 59259a30..9470953f 100644 --- a/android/src/main/java/com/lodev09/truesheet/TrueSheetContainerView.kt +++ b/android/src/main/java/com/lodev09/truesheet/TrueSheetContainerView.kt @@ -63,7 +63,6 @@ class TrueSheetContainerView(reactContext: ThemedReactContext) : var insetAdjustment: TrueSheetInsetAdjustment = TrueSheetInsetAdjustment.AUTOMATIC var scrollViewBottomInset: Int = 0 - var scrollableEnabled: Boolean = false var scrollableOptions: ScrollableOptions? = null set(value) { field = value @@ -81,7 +80,7 @@ class TrueSheetContainerView(reactContext: ThemedReactContext) : fun setupScrollable() { val bottomInset = if (insetAdjustment == TrueSheetInsetAdjustment.AUTOMATIC) scrollViewBottomInset else 0 - contentView?.setupScrollable(scrollableEnabled, bottomInset) + contentView?.setupScrollable(bottomInset) } fun setupKeyboardHandler() { diff --git a/android/src/main/java/com/lodev09/truesheet/TrueSheetContentView.kt b/android/src/main/java/com/lodev09/truesheet/TrueSheetContentView.kt index 5fe2e2ca..a9c325f5 100644 --- a/android/src/main/java/com/lodev09/truesheet/TrueSheetContentView.kt +++ b/android/src/main/java/com/lodev09/truesheet/TrueSheetContentView.kt @@ -43,7 +43,6 @@ class TrueSheetContentView(private val reactContext: ThemedReactContext) : React private var pinnedScrollView: ViewGroup? = null private var originalScrollViewPaddingBottom: Int = 0 private var bottomInset: Int = 0 - private var scrollExpansionPadding: Int = 0 private var keyboardScrollOffset: Float = 0f private var keyboardObserver: TrueSheetKeyboardObserver? = null @@ -83,12 +82,7 @@ class TrueSheetContentView(private val reactContext: ThemedReactContext) : React } } - fun setupScrollable(enabled: Boolean, bottomInset: Int) { - if (!enabled) { - clearScrollable() - return - } - + fun setupScrollable(bottomInset: Int) { // Check if pinned scroll view is still valid (still in view hierarchy) if (pinnedScrollView != null && pinnedScrollView?.isDescendantOf(this) == false) { clearScrollable() @@ -127,19 +121,6 @@ class TrueSheetContentView(private val reactContext: ThemedReactContext) : React } } - // TODO: Replace this workaround with synchronous state layout updates on every sheet resize. - // The container is currently sized to the largest detent, so at smaller detents the ScrollView - // viewport extends beyond the visible area, reducing the effective scroll range. This padding - // compensates for that difference until we can resize the container per-detent synchronously. - fun updateScrollExpansionPadding(padding: Int) { - if (scrollExpansionPadding == padding) return - scrollExpansionPadding = padding - val keyboardHeight = keyboardObserver?.currentHeight ?: 0 - val basePadding = if (keyboardHeight > 0) keyboardHeight else bottomInset - setScrollViewPaddingBottom(originalScrollViewPaddingBottom + basePadding) - nudgeScrollView() - } - private fun setScrollViewPaddingBottom(paddingBottom: Int) { val scrollView = pinnedScrollView ?: return scrollView.clipToPadding = false @@ -147,7 +128,7 @@ class TrueSheetContentView(private val reactContext: ThemedReactContext) : React scrollView.paddingLeft, scrollView.paddingTop, scrollView.paddingRight, - paddingBottom + scrollExpansionPadding + paddingBottom ) } @@ -155,7 +136,6 @@ class TrueSheetContentView(private val reactContext: ThemedReactContext) : React pinnedScrollView?.setOnScrollChangeListener(null as View.OnScrollChangeListener?) pinnedScrollView?.isNestedScrollingEnabled = false (pinnedScrollView?.parent as? SwipeRefreshLayout)?.isNestedScrollingEnabled = true - scrollExpansionPadding = 0 setScrollViewPaddingBottom(originalScrollViewPaddingBottom) pinnedScrollView = null originalScrollViewPaddingBottom = 0 diff --git a/android/src/main/java/com/lodev09/truesheet/TrueSheetStateUpdater.kt b/android/src/main/java/com/lodev09/truesheet/TrueSheetStateUpdater.kt new file mode 100644 index 00000000..8dbe5986 --- /dev/null +++ b/android/src/main/java/com/lodev09/truesheet/TrueSheetStateUpdater.kt @@ -0,0 +1,27 @@ +package com.lodev09.truesheet + +import com.facebook.react.uimanager.StateWrapper + +/** + * Synchronous Fabric state updates via JNI (see TrueSheetStateUpdater.cpp). + * + * StateWrapper.updateState commits asynchronously, so the container's Yoga + * layout lags the sheet by a frame or two during drags. The native bridge + * commits with `unstable_Immediate`, which mounts synchronously when called + * from the UI thread — same-frame layout, like iOS. + */ +object TrueSheetStateUpdater { + private val isAvailable: Boolean = try { + System.loadLibrary("react_codegen_TrueSheetSpec") + true + } catch (e: UnsatisfiedLinkError) { + false + } + + /** Returns false when unavailable — caller should fall back to async updateState. */ + fun updateState(stateWrapper: StateWrapper, widthDp: Float, heightDp: Float): Boolean = + isAvailable && nativeUpdateState(stateWrapper, widthDp, heightDp) + + @JvmStatic + private external fun nativeUpdateState(stateWrapper: Any, width: Float, height: Float): Boolean +} diff --git a/android/src/main/java/com/lodev09/truesheet/TrueSheetView.kt b/android/src/main/java/com/lodev09/truesheet/TrueSheetView.kt index 6f03b91c..953a217e 100644 --- a/android/src/main/java/com/lodev09/truesheet/TrueSheetView.kt +++ b/android/src/main/java/com/lodev09/truesheet/TrueSheetView.kt @@ -274,11 +274,6 @@ class TrueSheetView(private val reactContext: ThemedReactContext) : setupScrollable() } - fun setScrollable(scrollable: Boolean) { - viewController.scrollable = scrollable - setupScrollable() - } - fun setScrollableOptions(options: ScrollableOptions?) { viewController.scrollableOptions = options setupScrollable() @@ -292,7 +287,6 @@ class TrueSheetView(private val reactContext: ThemedReactContext) : private fun setupScrollable() { viewController.containerView?.let { it.insetAdjustment = viewController.insetAdjustment - it.scrollableEnabled = viewController.scrollable it.scrollViewBottomInset = viewController.contentBottomInset it.scrollableOptions = viewController.scrollableOptions it.setupScrollable() @@ -336,9 +330,16 @@ class TrueSheetView(private val reactContext: ThemedReactContext) : lastContainerHeight = height val sw = stateWrapper ?: return + val widthDp = width.toFloat().pxToDp() + val heightDp = height.toFloat().pxToDp() + + // Synchronous update — commits and mounts within the same UI-thread frame + if (TrueSheetStateUpdater.updateState(sw, widthDp, heightDp)) return + + // Fallback: async state update val newStateData = WritableNativeMap() - newStateData.putDouble("containerWidth", width.toFloat().pxToDp().toDouble()) - newStateData.putDouble("containerHeight", height.toFloat().pxToDp().toDouble()) + newStateData.putDouble("containerWidth", widthDp.toDouble()) + newStateData.putDouble("containerHeight", heightDp.toDouble()) sw.updateState(newStateData) } @@ -562,10 +563,7 @@ class TrueSheetView(private val reactContext: ThemedReactContext) : } override fun viewControllerDidChangeSize(width: Int, height: Int) { - // On android scrollable, we need the actual sheet height to get proper ScrollView height. - // Unlike IOS where ScrollView is pinned to the container. - val effectiveHeight = if (viewController.scrollable) height else viewController.screenHeight - updateState(width, effectiveHeight) + updateState(width, height) } override fun viewControllerWillFocus() { diff --git a/android/src/main/java/com/lodev09/truesheet/TrueSheetViewController.kt b/android/src/main/java/com/lodev09/truesheet/TrueSheetViewController.kt index 88d340db..b72afe33 100644 --- a/android/src/main/java/com/lodev09/truesheet/TrueSheetViewController.kt +++ b/android/src/main/java/com/lodev09/truesheet/TrueSheetViewController.kt @@ -215,13 +215,10 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) : override var sheetBackgroundColor: Int? = null var insetAdjustment: TrueSheetInsetAdjustment = TrueSheetInsetAdjustment.AUTOMATIC - var scrollable: Boolean = false - var scrollableOptions: ScrollableOptions? = null set(value) { field = value behavior?.scrollingExpandsSheet = value?.scrollingExpandsSheet ?: true - if (isPresented) sheetView?.let { updateScrollExpansionPadding(it.top) } } override var sheetCornerRadius: Float = DEFAULT_CORNER_RADIUS.dpToPx() @@ -561,7 +558,6 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) : else -> { } } - updateScrollExpansionPadding(sheetView.top) emitChangePositionDelegate(sheetView.top) // On older APIs, use onSlide for footer positioning during keyboard transitions @@ -575,15 +571,6 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) : } } - private fun updateScrollExpansionPadding(sheetTop: Int) { - if (!scrollable) { - containerView?.contentView?.updateScrollExpansionPadding(0) - return - } - val expandedOffset = behavior?.expandedOffset ?: return - containerView?.contentView?.updateScrollExpansionPadding(maxOf(0, sheetTop - expandedOffset)) - } - private fun handleStateSettled(sheetView: View, newState: Int) { if (interactionState is InteractionState.Reconfiguring) return @@ -631,6 +618,9 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) : } } + // Settled — resize the container to the settled detent (applies deferred shrinks) + updateStateDimensions() + if (!isKeyboardTransitioning) { updateDimAmount(animated = true) } @@ -735,6 +725,7 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) : } pendingDetentIndex = detentIndex + updateStateDimensions(deferShrink = true) setupDimmedBackground() setStateForDetentIndex(detentIndex) resizePromise?.invoke() @@ -880,7 +871,7 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) : animate = isPresented ) - updateStateDimensions(expandedOffset) + updateStateDimensions() if (isPresented && applyState) { // Prefer the pending target while a resize animation is in flight so a @@ -910,6 +901,11 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) : } fun setupSheetDetentsForSizeChange() { + // Skip while dragging — the size change is driven by the drag itself (the + // container tracks the sheet's visible height), and reconfiguring resets + // behavior state, killing the gesture. + if (interactionState is InteractionState.Dragging) return + setupSheetDetents() positionFooter() } @@ -1052,9 +1048,9 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) : // If a resize is in flight, restore to its target — not the stale current detentIndexBeforeKeyboard = if (pendingDetentIndex >= 0) pendingDetentIndex else currentDetentIndex pendingDetentIndex = -1 - setupSheetDetents() + // Commit the target index first so the container grows before the sheet expands currentDetentIndex = detents.size - 1 - setStateForDetentIndex(currentDetentIndex) + setupSheetDetents() updateDimAmount(animated = true) } @@ -1173,6 +1169,8 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) : private fun handleDragChange(sheetView: View) { if (interactionState !is InteractionState.Dragging) return + updateStateDimensionsForDrag(sheetView.top) + val position = getPositionDpForView(sheetView) val detent = detentCalculator.getDetentValueForIndex(currentDetentIndex) delegate?.viewControllerDidDragChange(currentDetentIndex, position, detent) @@ -1241,19 +1239,56 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) : behavior.maxWidth = newMaxWidth } - private fun updateStateDimensions(expandedOffset: Int? = null) { - val offset = expandedOffset ?: (realScreenHeight - detentCalculator.getDetentHeight(detents.last())) - val topOffset = if (offset == 0) topInset else 0 - val newHeight = realScreenHeight - offset - topOffset + /** + * Updates the Fabric state with the current/target detent size so Yoga sizes + * the container to the sheet's visible height. + * + * Unlike iOS, resize animations run over multiple frames (ViewDragHelper), so + * shrinking is deferred to settle ([deferShrink]) when a resize animation is + * about to run — growing applies immediately so content is laid out before + * the sheet reveals it. + */ + private fun updateStateDimensions(deferShrink: Boolean = false) { + if (detents.isEmpty()) return + + val targetIndex = (if (pendingDetentIndex >= 0) pendingDetentIndex else currentDetentIndex) + .coerceIn(0, detents.size - 1) + val maxAvailableHeight = realScreenHeight - topInset + val newHeight = minOf(detentCalculator.getDetentHeight(detents[targetIndex]), maxAvailableHeight) + val newWidth = getStateWidth() + + if (deferShrink && newWidth == lastStateWidth && newHeight < lastStateHeight) return + + setStateDimensions(newWidth, newHeight) + } + + /** + * Tracks the sheet's visible height during drag (and the post-release settle + * animation) so the container resizes with the finger, like iOS. Clamped to the + * smallest detent — dragging below it slides the sheet out without resizing. + */ + private fun updateStateDimensionsForDrag(sheetTop: Int) { + if (detents.isEmpty()) return + + val maxAvailableHeight = realScreenHeight - topInset + val minHeight = minOf(detentCalculator.getDetentHeight(detents.first()), maxAvailableHeight) + val newHeight = detentCalculator.getVisibleSheetHeight(sheetTop).coerceIn(minHeight, maxAvailableHeight) + + setStateDimensions(getStateWidth(), newHeight) + } + + private fun getStateWidth(): Int { val applyMaxWidth = maxContentWidth != null && !ScreenUtils.isPortraitPhone(reactContext) val effectiveMaxWidth = if (applyMaxWidth) maxContentWidth!! else DEFAULT_MAX_WIDTH.dpToPx().toInt() - val newWidth = minOf(screenWidth, effectiveMaxWidth) + return minOf(screenWidth, effectiveMaxWidth) + } - if (lastStateWidth != newWidth || lastStateHeight != newHeight) { - lastStateWidth = newWidth - lastStateHeight = newHeight - delegate?.viewControllerDidChangeSize(newWidth, newHeight) - } + private fun setStateDimensions(width: Int, height: Int) { + if (lastStateWidth == width && lastStateHeight == height) return + + lastStateWidth = width + lastStateHeight = height + delegate?.viewControllerDidChangeSize(width, height) } fun translateSheet(translationY: Int, onEnd: (() -> Unit)? = null) { diff --git a/android/src/main/java/com/lodev09/truesheet/TrueSheetViewManager.kt b/android/src/main/java/com/lodev09/truesheet/TrueSheetViewManager.kt index 35c265a4..6270f7c4 100644 --- a/android/src/main/java/com/lodev09/truesheet/TrueSheetViewManager.kt +++ b/android/src/main/java/com/lodev09/truesheet/TrueSheetViewManager.kt @@ -201,11 +201,6 @@ class TrueSheetViewManager : view.setInsetAdjustment(insetAdjustment ?: "automatic") } - @ReactProp(name = "scrollable", defaultBoolean = false) - override fun setScrollable(view: TrueSheetView, value: Boolean) { - view.setScrollable(value) - } - @ReactProp(name = "presentation") override fun setPresentation(view: TrueSheetView, value: String?) { // iOS/web-specific prop - no-op on Android diff --git a/android/src/main/jni/TrueSheetStateUpdater.cpp b/android/src/main/jni/TrueSheetStateUpdater.cpp new file mode 100644 index 00000000..1eb923e5 --- /dev/null +++ b/android/src/main/jni/TrueSheetStateUpdater.cpp @@ -0,0 +1,56 @@ +// +// Created by Jovanni Lo (@lodev09) +// Copyright (c) 2024-present. All rights reserved. +// +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. +// + +#include +#include +#include +#include + +namespace facebook::react { + +// Kotlin's StateWrapper.updateState is hardcoded async. This bridges to +// ConcreteState::updateState with unstable_Immediate so the commit — and, when +// called from the UI thread, the mount — run synchronously, letting Yoga resize +// the container in the same frame as the sheet (parity with iOS). +static jboolean updateStateImmediate( + jni::alias_ref /*clazz*/, + jni::alias_ref stateWrapper, + jfloat containerWidth, + jfloat containerHeight) { + static const auto stateWrapperImplClass = + jni::findClassStatic(StateWrapperImpl::StateWrapperImplJavaDescriptor); + if (!stateWrapper->isInstanceOf(stateWrapperImplClass)) { + return JNI_FALSE; + } + + auto impl = jni::static_ref_cast(stateWrapper); + auto state = impl->cthis()->getState(); + if (!state) { + return JNI_FALSE; + } + + auto concreteState = std::static_pointer_cast>(state); + + TrueSheetViewState newState{}; + newState.containerWidth = containerWidth; + newState.containerHeight = containerHeight; + concreteState->updateState(std::move(newState), EventQueue::UpdateMode::unstable_Immediate); + + return JNI_TRUE; +} + +} // namespace facebook::react + +JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM *vm, void * /*reserved*/) { + return facebook::jni::initialize(vm, [] { + facebook::jni::findClassStatic("com/lodev09/truesheet/TrueSheetStateUpdater") + ->registerNatives({ + makeNativeMethod("nativeUpdateState", facebook::react::updateStateImmediate), + }); + }); +} diff --git a/docs/docs/guides/header.mdx b/docs/docs/guides/header.mdx index 06e6214a..669d23b0 100644 --- a/docs/docs/guides/header.mdx +++ b/docs/docs/guides/header.mdx @@ -77,7 +77,6 @@ const App = () => { initialDetentIndex={0} header={} footer={} - scrollable > diff --git a/docs/docs/guides/keyboard.mdx b/docs/docs/guides/keyboard.mdx index 22390d6a..7eff4ca3 100644 --- a/docs/docs/guides/keyboard.mdx +++ b/docs/docs/guides/keyboard.mdx @@ -36,12 +36,12 @@ const App = () => { ## Scrollable + Keyboard -When using [`scrollable`](../reference/configuration#scrollable), keyboard handling works out of the box. If a `TextInput` inside a `ScrollView` is focused, the sheet automatically scrolls to keep the input visible above the keyboard — no extra setup needed. +When your sheet contains a `ScrollView` or `FlatList` (auto-detected), keyboard handling works out of the box. If a `TextInput` inside a `ScrollView` is focused, the sheet automatically scrolls to keep the input visible above the keyboard — no extra setup needed. You can use `keyboardScrollOffset` in [`scrollableOptions`](../reference/configuration#scrollableoptions) to add extra spacing between the focused input and the keyboard: ```tsx - + diff --git a/docs/docs/guides/peeking.mdx b/docs/docs/guides/peeking.mdx index c99a9d0d..a385240f 100644 --- a/docs/docs/guides/peeking.mdx +++ b/docs/docs/guides/peeking.mdx @@ -18,7 +18,6 @@ const App = () => { initialDetentIndex={0} header={} footer={} - scrollable > diff --git a/docs/docs/guides/scrolling.mdx b/docs/docs/guides/scrolling.mdx index 37332129..03e0227c 100644 --- a/docs/docs/guides/scrolling.mdx +++ b/docs/docs/guides/scrolling.mdx @@ -6,23 +6,20 @@ keywords: [bottom sheet scrolling, bottom sheet scrollview, bottom sheet flatlis import scrolling from './assets/scrolling.gif' -Scrolling content within the sheet requires some special configuration on both iOS and Android. Let's admit, some things just don't work out of the box. - -Follow the guide below so you can scroll your content using `TrueSheet`. +Scrolling content within the sheet works out of the box — plug a `ScrollView` or `FlatList` in directly, like a regular view. scrolling ## How? -Enable the [`scrollable`](../reference/configuration#scrollable) prop on your `TrueSheet` to properly handle scrolling within the sheet. +Scroll views (including `ScrollView` and `FlatList`) are **automatically detected**. Insets, keyboard handling, and nested scrolling are all wired for you — no props needed. -```tsx {5-7} +```tsx const App = () => { const sheet = useRef(null) return ( - - {/* Header content */} + @@ -31,22 +28,10 @@ const App = () => { } ``` -:::info -By default, `scrollable` is `false`. Set it to `true` to enable automatic ScrollView fitting. -::: - -:::warning -The [`auto`](../reference/types#sheetdetent) detent is not supported with `scrollable`. Use fixed fractional detents (e.g., `0.5`, `0.8`, `1`) instead. -::: - -### iOS - -On iOS, `scrollable` automatically pins scroll views (including `ScrollView` and `FlatList`) to fit within the sheet's available space. The ScrollView's top edge will be pinned below any top sibling views, and its left, right, and bottom edges will be pinned to the container. - -The scroll view detection supports up to 2 levels deep in the view hierarchy, so you can wrap your `ScrollView` or `FlatList` in a container `View` if needed: +The sheet container is sized to the sheet's visible height per detent — resizing in realtime while dragging — so flex layouts bound the scroll view naturally. Wrapping it in a container `View` works too: ```tsx - + {/* Header content */} @@ -54,12 +39,12 @@ The scroll view detection supports up to 2 levels deep in the view hierarchy, so ``` -### Android - -On Android, `scrollable` ensures the scroll view fills the available sheet space. Nested scrolling is automatically enabled on the detected scroll view, so you don't need to set `nestedScrollEnabled` manually. - :::info -When `scrollable` is enabled, any `nestedScrollEnabled` prop on your `ScrollView` or `FlatList` is ignored — TrueSheet manages nested scrolling internally. `RefreshControl` is also fully supported. +On Android, nested scrolling is managed internally — any `nestedScrollEnabled` prop on your `ScrollView` or `FlatList` is ignored. `RefreshControl` is also fully supported. +::: + +:::warning +The [`auto`](../reference/types#sheetdetent) detent derives the sheet height from the content's natural height, so it doesn't work with scrolling content. Use fixed fractional detents (e.g., `0.5`, `0.8`, `1`) instead. ::: ## Preventing Scroll-to-Expand @@ -68,7 +53,6 @@ By default, scrolling content to the top edge expands the sheet to the next dete ```tsx @@ -88,7 +72,6 @@ Set [`topScrollEdgeEffect`](../reference/types#scrollableoptions) and/or [`botto ```tsx (null); - -const scrollToEnd = () => { - // Large offset will be clamped to max scrollable position - scrollRef.current?.scrollToOffset({ offset: 99999, animated: true }); -}; - -return ( - - - -); -``` diff --git a/docs/docs/migration.mdx b/docs/docs/migration.mdx index e87e2e9e..b5a5f525 100644 --- a/docs/docs/migration.mdx +++ b/docs/docs/migration.mdx @@ -91,7 +91,7 @@ The `grabberProps` prop has been removed. The grabber is now rendered **natively #### `scrollRef` (iOS) -The `scrollRef` prop has been removed. Scroll views are now **automatically detected** on iOS. Use the new `scrollable` prop to enable automatic ScrollView pinning. +The `scrollRef` prop has been removed. Scroll views are now **automatically detected** — plug them in directly. **Migration:** @@ -103,14 +103,14 @@ const scrollViewRef = useRef(null) {/* ... */} -// ✅ v3 - +// ✅ + {/* ... */} ``` :::tip -See the [Scrolling guide](guides/scrolling) for more information about `scrollable`. +See the [Scrolling guide](guides/scrolling) for more information. ::: #### `contentContainerStyle` diff --git a/docs/docs/reference/01-configuration.mdx b/docs/docs/reference/01-configuration.mdx index cfbbd0e6..02b4e39c 100644 --- a/docs/docs/reference/01-configuration.mdx +++ b/docs/docs/reference/01-configuration.mdx @@ -296,23 +296,9 @@ Options for customizing footer behavior. | - | - | - | - | - | | [`FooterOptions`](types#footeroptions) | | ✅ | ✅ | | -## `scrollable` - -On iOS, automatically pins ScrollView or FlatList to fit within the sheet's available space. When enabled, the ScrollView's top edge will be pinned below any top sibling views, and its left, right, and bottom edges will be pinned to the container. See [this guide](../guides/scrolling) for example. - -On Android, it ensures the scroll view fills the available sheet space and automatically manages nested scrolling. Any `nestedScrollEnabled` prop on your `ScrollView` or `FlatList` is ignored when `scrollable` is enabled. - -| Type | Default | 🍎 | 🤖 | 🌐 | -| - | - | - | - | - | -| `boolean` | `false` | ✅ | ✅ | | - -:::warning -The [`auto`](types#sheetdetent) detent is not supported with `scrollable`. Use fixed fractional detents (e.g., `0.5`, `0.8`, `1`) instead. -::: - ## `scrollableOptions` -Options for customizing scrollable behavior. Only applies when `scrollable` is `true`. +Options for customizing scrollable behavior. Applies when the sheet contains a `ScrollView` or `FlatList` (auto-detected). See [this guide](../guides/scrolling) for more information. | Type | Default | 🍎 | 🤖 | 🌐 | | - | - | - | - | - | diff --git a/docs/docs/reference/04-types.mdx b/docs/docs/reference/04-types.mdx index d93b25db..8b044acf 100644 --- a/docs/docs/reference/04-types.mdx +++ b/docs/docs/reference/04-types.mdx @@ -19,7 +19,7 @@ keywords: [bottom sheet types, bottom sheet typescript, bottom sheet definitions | `number` | Fractional height (0-1) representing percentage of screen height. | ✅ | ✅ | ✅ | :::warning -The `"auto"` detent is not supported with [`scrollable`](configuration#scrollable). Use fixed fractional detents (e.g., `0.5`, `0.8`, `1`) instead. +The `"auto"` detent derives the sheet height from the content's natural height, so it doesn't work with scrolling content. Use fixed fractional detents (e.g., `0.5`, `0.8`, `1`) instead. ::: ## `BackgroundBlur` @@ -79,11 +79,10 @@ Options for customizing the blur effect. Only applies when `backgroundBlur` is s ## `ScrollableOptions` -Options for customizing scrollable behavior. Only applies when `scrollable` is `true`. +Options for customizing scrollable behavior. Applies when the sheet contains a `ScrollView` or `FlatList` (auto-detected). ```tsx =18.0" + "node": ">=24.0" } } diff --git a/example/shared/src/components/sheets/FlatListSheet.tsx b/example/shared/src/components/sheets/FlatListSheet.tsx index 8332c7b2..53308df4 100644 --- a/example/shared/src/components/sheets/FlatListSheet.tsx +++ b/example/shared/src/components/sheets/FlatListSheet.tsx @@ -20,7 +20,6 @@ export const FlatListSheet = forwardRef((props, r detents={[0.5, 1]} backgroundBlur="dark" backgroundColor={DARK} - scrollable scrollableOptions={{ bottomScrollEdgeEffect: 'soft', topScrollEdgeEffect: 'soft', diff --git a/example/shared/src/components/sheets/PromptSheet.tsx b/example/shared/src/components/sheets/PromptSheet.tsx index 84147c49..ff3919ae 100644 --- a/example/shared/src/components/sheets/PromptSheet.tsx +++ b/example/shared/src/components/sheets/PromptSheet.tsx @@ -46,7 +46,6 @@ export const PromptSheet = forwardRef((props: PromptSheetProps, ref: Ref((prop ref={ref} detents={[0.8, 1]} name="scrollview" - scrollable scrollableOptions={{ scrollingExpandsSheet: false, bottomScrollEdgeEffect: 'soft', diff --git a/ios/TrueSheetContainerView.h b/ios/TrueSheetContainerView.h index bd4d453b..66c98998 100644 --- a/ios/TrueSheetContainerView.h +++ b/ios/TrueSheetContainerView.h @@ -45,11 +45,6 @@ NS_ASSUME_NONNULL_BEGIN */ @property (nonatomic, weak, nullable) id delegate; -/** - * Enable scrollable content - */ -@property (nonatomic, assign) BOOL scrollableEnabled; - /** * Inset adjustment mode for scrollable content */ @@ -85,11 +80,6 @@ NS_ASSUME_NONNULL_BEGIN */ - (void)attachPeekView:(TrueSheetPeekView *)peekView; -/** - * Updates footer layout constraints if needed - */ -- (void)layoutFooter; - /** * Re-applies the footer's keyboard slide to reflect a live `keyboardOffset` change. */ diff --git a/ios/TrueSheetContainerView.mm b/ios/TrueSheetContainerView.mm index 86dca5d5..9ed056c2 100644 --- a/ios/TrueSheetContainerView.mm +++ b/ios/TrueSheetContainerView.mm @@ -26,7 +26,6 @@ #import #import -#import using namespace facebook::react; @@ -56,7 +55,6 @@ @implementation TrueSheetContainerView { TrueSheetFooterView *_footerView; TrueSheetPeekView *__weak _peekView; TrueSheetKeyboardObserver *_keyboardObserver; - BOOL _scrollableSet; } #pragma mark - Initialization @@ -74,7 +72,6 @@ - (instancetype)initWithFrame:(CGRect)frame { _contentView = nil; _headerView = nil; _footerView = nil; - _scrollableSet = NO; self.isAccessibilityElement = NO; } return self; @@ -109,11 +106,6 @@ - (BOOL)hasAccessibilityFooterElements { #pragma mark - Layout -- (void)layoutSubviews { - [super layoutSubviews]; - [_contentView updateScrollViewHeight]; -} - - (CGFloat)contentHeight { return _contentView ? _contentView.frame.size.height : 0; } @@ -141,36 +133,22 @@ - (CGFloat)peekContentHeight { return CGRectGetMaxY([_peekView convertRect:_peekView.bounds toView:_contentView]); } -- (void)layoutFooter { - if (_footerView) { - CGFloat height = _footerView.frame.size.height; - if (height > 0) { - [_footerView setupConstraintsWithHeight:height]; - } - } -} - - (void)updateFooterKeyboardOffset { [_footerView applyKeyboardOffset]; } -- (void)setScrollableEnabled:(BOOL)scrollableEnabled { - _scrollableEnabled = scrollableEnabled; - _scrollableSet = YES; -} - - (void)setScrollableOptions:(ScrollableOptions *)scrollableOptions { _scrollableOptions = scrollableOptions; _contentView.keyboardScrollOffset = scrollableOptions ? scrollableOptions.keyboardScrollOffset : 0; } - (void)setupScrollable { - if (_scrollableSet && _contentView) { + if (_contentView) { CGFloat bottomInset = 0; if (_insetAdjustment == TrueSheetViewInsetAdjustment::Automatic) { bottomInset = [WindowUtil keyWindow].safeAreaInsets.bottom; } - [_contentView setupScrollable:_scrollableEnabled bottomInset:bottomInset]; + [_contentView setupScrollableWithBottomInset:bottomInset]; [_contentView applyScrollEdgeEffects:_scrollableOptions]; if (@available(iOS 26.0, *)) { [self setupEdgeInteractions]; @@ -269,12 +247,6 @@ - (void)updateProps:(Props::Shared const &)props oldProps:(Props::Shared const & [super updateProps:props oldProps:oldProps]; } -#pragma clang diagnostic ignored "-Wobjc-missing-super-calls" -- (void)updateLayoutMetrics:(const LayoutMetrics &)layoutMetrics - oldLayoutMetrics:(const LayoutMetrics &)oldLayoutMetrics { - // Intentionally skip super - AutoLayout handles container's frame, not Yoga -} - #pragma mark - TrueSheetContentViewDelegate - (void)contentViewDidChangeSize:(CGSize)newSize { diff --git a/ios/TrueSheetContentView.h b/ios/TrueSheetContentView.h index 45521c70..bc5cfd62 100644 --- a/ios/TrueSheetContentView.h +++ b/ios/TrueSheetContentView.h @@ -36,16 +36,10 @@ NS_ASSUME_NONNULL_BEGIN - (RCTScrollViewComponentView *_Nullable)findScrollView; /** - * Setup scrollable content - * @param enabled Whether scrollable is enabled + * Pin the first ScrollView found in the content, wiring insets and keyboard handling * @param bottomInset Bottom content inset for the scroll view */ -- (void)setupScrollable:(BOOL)enabled bottomInset:(CGFloat)bottomInset; - -/** - * Update the pinned scroll view's height to fill the container - */ -- (void)updateScrollViewHeight; +- (void)setupScrollableWithBottomInset:(CGFloat)bottomInset; /** * Apply scroll edge effects to the pinned scroll view (iOS 26+) diff --git a/ios/TrueSheetContentView.mm b/ios/TrueSheetContentView.mm index a5c43ed5..13a04995 100644 --- a/ios/TrueSheetContentView.mm +++ b/ios/TrueSheetContentView.mm @@ -26,7 +26,6 @@ @implementation TrueSheetContentView { RCTScrollViewComponentView *_pinnedScrollView; CGSize _lastSize; CGFloat _bottomInset; - CGFloat _originalScrollViewHeight; CGFloat _originalIndicatorBottomInset; BOOL _observingTextChanges; } @@ -117,24 +116,14 @@ - (void)setScrollViewContentInset:(CGFloat)contentBottom indicatorInset:(CGFloat - (void)clearScrollable { if (_pinnedScrollView) { - CGRect frame = _pinnedScrollView.frame; - frame.size.height = _originalScrollViewHeight; - _pinnedScrollView.frame = frame; - [self setScrollViewContentInset:0 indicatorInset:_originalIndicatorBottomInset]; } _pinnedScrollView = nil; _bottomInset = 0; - _originalScrollViewHeight = 0; _originalIndicatorBottomInset = 0; } -- (void)setupScrollable:(BOOL)enabled bottomInset:(CGFloat)bottomInset { - if (!enabled) { - [self clearScrollable]; - return; - } - +- (void)setupScrollableWithBottomInset:(CGFloat)bottomInset { // Check if pinned scroll view is still valid (still in view hierarchy) if (_pinnedScrollView && ![_pinnedScrollView isDescendantOfView:self]) { [self clearScrollable]; @@ -152,15 +141,12 @@ - (void)setupScrollable:(BOOL)enabled bottomInset:(CGFloat)bottomInset { // Only capture originals on first pin if (!_pinnedScrollView) { - _originalScrollViewHeight = scrollView.frame.size.height; _originalIndicatorBottomInset = scrollView.scrollView.verticalScrollIndicatorInsets.bottom; _pinnedScrollView = scrollView; } _bottomInset = bottomInset; - [self updateScrollViewHeight]; - [self setScrollViewContentInset:_bottomInset indicatorInset:_originalIndicatorBottomInset]; // If keyboard is currently showing, re-apply the keyboard inset to the new ScrollView @@ -170,33 +156,6 @@ - (void)setupScrollable:(BOOL)enabled bottomInset:(CGFloat)bottomInset { } } -- (void)updateScrollViewHeight { - if (!_pinnedScrollView) { - return; - } - - UIView *containerView = self.superview; - if (!containerView) { - return; - } - - CGRect scrollViewFrameInContainer = [_pinnedScrollView.superview convertRect:_pinnedScrollView.frame - toView:containerView]; - CGFloat newHeight = containerView.bounds.size.height - scrollViewFrameInContainer.origin.y; - - if (newHeight > 0) { - // Preserve contentOffset before changing frame to prevent scroll jump - CGPoint savedContentOffset = _pinnedScrollView.scrollView.contentOffset; - - CGRect frame = _pinnedScrollView.frame; - frame.size.height = newHeight; - _pinnedScrollView.frame = frame; - - // Restore contentOffset after frame change - _pinnedScrollView.scrollView.contentOffset = savedContentOffset; - } -} - - (RCTScrollViewComponentView *)findScrollView { if (_pinnedScrollView) { return _pinnedScrollView; diff --git a/ios/TrueSheetFooterView.h b/ios/TrueSheetFooterView.h index 6909f1b0..c71896af 100644 --- a/ios/TrueSheetFooterView.h +++ b/ios/TrueSheetFooterView.h @@ -27,8 +27,6 @@ NS_ASSUME_NONNULL_BEGIN @property (nonatomic, weak, nullable) TrueSheetKeyboardObserver *keyboardObserver; @property (nonatomic, weak, nullable) id delegate; -- (void)setupConstraintsWithHeight:(CGFloat)height; - /** * Re-applies the footer's keyboard slide using the current keyboard height. * No-op when the keyboard is hidden. Used to reflect live `keyboardOffset` changes. diff --git a/ios/TrueSheetFooterView.mm b/ios/TrueSheetFooterView.mm index 47ca833f..09f501a5 100644 --- a/ios/TrueSheetFooterView.mm +++ b/ios/TrueSheetFooterView.mm @@ -14,16 +14,12 @@ #import #import #import "TrueSheetViewController.h" -#import "utils/LayoutUtil.h" #import "utils/UIView+ScrollEdgeInteraction.h" using namespace facebook::react; @implementation TrueSheetFooterView { CGFloat _lastHeight; - CGFloat _pendingHeight; - BOOL _didInitialLayout; - NSLayoutConstraint *_bottomConstraint; CGFloat _currentKeyboardOffset; } @@ -40,9 +36,6 @@ - (instancetype)initWithFrame:(CGRect)frame { self.isAccessibilityElement = NO; _lastHeight = 0; - _pendingHeight = 0; - _didInitialLayout = NO; - _bottomConstraint = nil; _currentKeyboardOffset = 0; } return self; @@ -74,57 +67,13 @@ - (void)collectAccessibilityElementsFromView:(UIView *)view into:(NSMutableArray #pragma mark - Layout -- (void)setupConstraintsWithHeight:(CGFloat)height { - UIView *parentView = self.superview; - if (!parentView) { - // On recycled views, updateLayoutMetrics can fire before the view is - // reparented. Remember the desired height so didMoveToSuperview applies - // it instead of falling back to the stale self.frame from the previous - // present cycle. - _pendingHeight = height; - return; - } - - [LayoutUtil unpinView:self fromParentView:parentView]; - _bottomConstraint = nil; - - self.translatesAutoresizingMaskIntoConstraints = NO; - - [self.leadingAnchor constraintEqualToAnchor:parentView.leadingAnchor].active = YES; - [self.trailingAnchor constraintEqualToAnchor:parentView.trailingAnchor].active = YES; - - _bottomConstraint = [self.bottomAnchor constraintEqualToAnchor:parentView.bottomAnchor - constant:-_currentKeyboardOffset]; - _bottomConstraint.active = YES; - - if (height > 0) { - [self.heightAnchor constraintEqualToConstant:height].active = YES; - } - - _lastHeight = height; - _pendingHeight = 0; -} - -- (void)didMoveToSuperview { - [super didMoveToSuperview]; - - if (self.superview) { - CGFloat initialHeight = _pendingHeight > 0 ? _pendingHeight : self.frame.size.height; - [self setupConstraintsWithHeight:initialHeight]; - } -} - - (void)updateLayoutMetrics:(const facebook::react::LayoutMetrics &)layoutMetrics oldLayoutMetrics:(const facebook::react::LayoutMetrics &)oldLayoutMetrics { - CGFloat height = layoutMetrics.frame.size.height; - - if (!_didInitialLayout) { - [super updateLayoutMetrics:layoutMetrics oldLayoutMetrics:oldLayoutMetrics]; - _didInitialLayout = YES; - } + [super updateLayoutMetrics:layoutMetrics oldLayoutMetrics:oldLayoutMetrics]; + CGFloat height = layoutMetrics.frame.size.height; if (height != _lastHeight) { - [self setupConstraintsWithHeight:height]; + _lastHeight = height; [self.delegate footerViewDidChangeSize:CGSizeMake(layoutMetrics.frame.size.width, height)]; } } @@ -132,25 +81,20 @@ - (void)updateLayoutMetrics:(const facebook::react::LayoutMetrics &)layoutMetric - (void)prepareForRecycle { [super prepareForRecycle]; - [LayoutUtil unpinView:self fromParentView:self.superview]; if (@available(iOS 26.0, *)) { [self cleanupEdgeInteraction]; } + self.transform = CGAffineTransformIdentity; _lastHeight = 0; - _pendingHeight = 0; - _didInitialLayout = NO; - _bottomConstraint = nil; _currentKeyboardOffset = 0; } #pragma mark - TrueSheetKeyboardObserverDelegate +// Yoga owns the footer's frame (pinned to the container's bottom edge), so the +// keyboard slide is carried by a transform instead of layout. - (void)keyboardWillShow:(CGFloat)height duration:(NSTimeInterval)duration curve:(UIViewAnimationOptions)curve { - if (!_bottomConstraint) { - return; - } - CGFloat keyboardOffset = self.keyboardObserver.viewController.footerKeyboardOffset; CGFloat slide = MAX(0, height + keyboardOffset); _currentKeyboardOffset = slide; @@ -159,40 +103,33 @@ - (void)keyboardWillShow:(CGFloat)height duration:(NSTimeInterval)duration curve delay:0 options:curve | UIViewAnimationOptionBeginFromCurrentState animations:^{ - self->_bottomConstraint.constant = -slide; - [self.superview layoutIfNeeded]; + self.transform = CGAffineTransformMakeTranslation(0, -slide); } completion:nil]; } - (void)keyboardWillHide:(NSTimeInterval)duration curve:(UIViewAnimationOptions)curve { - if (!_bottomConstraint) { - return; - } - _currentKeyboardOffset = 0; [UIView animateWithDuration:duration delay:0 options:curve | UIViewAnimationOptionBeginFromCurrentState animations:^{ - self->_bottomConstraint.constant = 0; - [self.superview layoutIfNeeded]; + self.transform = CGAffineTransformIdentity; } completion:nil]; } - (void)applyKeyboardOffset { CGFloat height = self.keyboardObserver.currentHeight; - if (!_bottomConstraint || height <= 0) { + if (height <= 0) { return; } CGFloat keyboardOffset = self.keyboardObserver.viewController.footerKeyboardOffset; CGFloat slide = MAX(0, height + keyboardOffset); _currentKeyboardOffset = slide; - _bottomConstraint.constant = -slide; - [self.superview layoutIfNeeded]; + self.transform = CGAffineTransformMakeTranslation(0, -slide); } @end diff --git a/ios/TrueSheetHeaderView.mm b/ios/TrueSheetHeaderView.mm index 1954178e..7529a18b 100644 --- a/ios/TrueSheetHeaderView.mm +++ b/ios/TrueSheetHeaderView.mm @@ -13,7 +13,6 @@ #import #import #import -#import "utils/LayoutUtil.h" #import "utils/UIView+ScrollEdgeInteraction.h" using namespace facebook::react; diff --git a/ios/TrueSheetView.mm b/ios/TrueSheetView.mm index 2ce79bde..621bf0fe 100644 --- a/ios/TrueSheetView.mm +++ b/ios/TrueSheetView.mm @@ -19,7 +19,6 @@ #import "events/TrueSheetFocusEvents.h" #import "events/TrueSheetLifecycleEvents.h" #import "events/TrueSheetStateEvents.h" -#import "utils/LayoutUtil.h" #import #import @@ -33,7 +32,6 @@ #import #import #import -#import #import using namespace facebook::react; @@ -52,7 +50,6 @@ @implementation TrueSheetView { CGSize _lastStateSize; NSInteger _initialDetentIndex; TrueSheetViewInsetAdjustment _insetAdjustment; - BOOL _scrollable; ScrollableOptions *_scrollableOptions; BOOL _initialDetentAnimated; BOOL _isSheetUpdatePending; @@ -86,7 +83,6 @@ - (instancetype)initWithFrame:(CGRect)frame { _lastStateSize = CGSizeZero; _initialDetentIndex = -1; _initialDetentAnimated = YES; - _scrollable = NO; _isSheetUpdatePending = NO; _screensEventObserver = [[RNScreensEventObserver alloc] init]; @@ -241,7 +237,6 @@ - (void)updateProps:(Props::Shared const &)props oldProps:(Props::Shared const & _initialDetentIndex = newProps.initialDetentIndex; _initialDetentAnimated = newProps.initialDetentAnimated; - _scrollable = newProps.scrollable; const auto &scrollableOpts = newProps.scrollableOptions; BOOL scrollingExpandsSheet = scrollableOpts.scrollingExpandsSheet; @@ -301,13 +296,10 @@ - (void)updateStateWithSize:(CGSize)size { stateData.containerWidth = static_cast(size.width); stateData.containerHeight = static_cast(size.height); -#if REACT_NATIVE_VERSION_MINOR >= 82 - // TODO: RN 0.82+ processes state updates in the same layout pass (synchronous). - // Once stable, we can drop native layout constraints in favor of synchronous Yoga layout. + // RN 0.82+ processes immediate state updates in the same layout pass, so Yoga + // resizes the container synchronously with the sheet (e.g. inside UIKit's + // animation block during detent transitions). _state->updateState(std::move(stateData), facebook::react::EventQueue::UpdateMode::unstable_Immediate); -#else - _state->updateState(std::move(stateData)); -#endif } - (void)finalizeUpdates:(RNComponentViewUpdateMask)updateMask { @@ -352,7 +344,6 @@ - (void)cleanupContainerView { _containerView.delegate = nil; [_touchHandler detachFromView:_containerView]; - [LayoutUtil unpinView:_containerView fromParentView:nil]; [_containerView removeFromSuperview]; _containerView = nil; @@ -377,7 +368,6 @@ - (void)mountChildComponentView:(UIView *)childCompone [_touchHandler attachToView:_containerView]; [_controller.view addSubview:_containerView]; - [LayoutUtil pinView:_containerView toParentView:_controller.view edges:UIRectEdgeAll]; [_controller.view bringSubviewToFront:_containerView]; _containerView.accessibilityViewIsModal = YES; _controller.accessibilityContentView = _containerView; @@ -681,10 +671,7 @@ - (void)viewControllerDidChangePosition:(CGFloat)index } - (void)viewControllerDidChangeSize:(CGSize)size { - // TODO: Explicit screen height for now until synchronous layout is supported. - CGSize effectiveSize = CGSizeMake(size.width, _controller.screenHeight); - - [self updateStateWithSize:effectiveSize]; + [self updateStateWithSize:size]; } - (void)viewControllerWillFocus { @@ -752,7 +739,6 @@ - (void)setupScrollable { if (!_containerView) return; - _containerView.scrollableEnabled = _scrollable; _containerView.insetAdjustment = _insetAdjustment; _containerView.scrollableOptions = _scrollableOptions; [_containerView setupScrollable]; diff --git a/ios/TrueSheetViewController.mm b/ios/TrueSheetViewController.mm index 5ebaf861..b4c3d527 100644 --- a/ios/TrueSheetViewController.mm +++ b/ios/TrueSheetViewController.mm @@ -54,7 +54,7 @@ - (void)animateInteractiveContainerToTransform:(CGAffineTransform)transform @implementation TrueSheetViewController { TrueSheetPositionState _lastEmittedPositionState; - CGFloat _lastWidth; + CGSize _lastReportedSize; NSInteger _pendingDetentIndex; BOOL _pendingContentSizeChange; BOOL _pendingDetentsChange; @@ -488,11 +488,12 @@ - (void)viewWillLayoutSubviews { - (void)viewDidLayoutSubviews { [super viewDidLayoutSubviews]; - // Update state on rotation (width change) - CGFloat width = self.view.frame.size.width; - if (_lastWidth != width) { - _lastWidth = width; - [self.delegate viewControllerDidChangeSize:self.view.frame.size]; + // Report any size change (detent resize, keyboard, rotation) so Yoga + // relayouts the container synchronously with the sheet. + CGSize size = self.view.frame.size; + if (!CGSizeEqualToSize(_lastReportedSize, size)) { + _lastReportedSize = size; + [self.delegate viewControllerDidChangeSize:size]; } if (_pendingDetentIndex >= 0) { diff --git a/ios/utils/LayoutUtil.h b/ios/utils/LayoutUtil.h deleted file mode 100644 index 74d2dc60..00000000 --- a/ios/utils/LayoutUtil.h +++ /dev/null @@ -1,77 +0,0 @@ -// -// Created by Jovanni Lo (@lodev09) -// Copyright (c) 2024-present. All rights reserved. -// -// This source code is licensed under the MIT license found in the -// LICENSE file in the root directory of this source tree. -// - -#ifdef RCT_NEW_ARCH_ENABLED - -#import - -NS_ASSUME_NONNULL_BEGIN - -@interface LayoutUtil : NSObject - -/** - * Pins a view to its parent view using Auto Layout constraints - * @param view The view to pin - * @param parentView The parent view to pin to - * @param edges The edges to pin (UIRectEdge flags) - */ -+ (void)pinView:(UIView *)view toParentView:(UIView *)parentView edges:(UIRectEdge)edges; - -/** - * Pins a view to its parent view with a specific height constraint - * @param view The view to pin - * @param parentView The parent view to pin to - * @param edges The edges to pin (UIRectEdge flags) - * @param height The height constraint to apply (0 means no height constraint) - */ -+ (void)pinView:(UIView *)view toParentView:(UIView *)parentView edges:(UIRectEdge)edges height:(CGFloat)height; - -/** - * Pins a view to its parent view with its top edge anchored below a top sibling view - * @param view The view to pin - * @param parentView The parent view to pin to - * @param topView The view to position below (top sibling) - * @param edges The edges to pin to parent (excluding top, which is pinned to topView) - */ -+ (void)pinView:(UIView *)view toParentView:(UIView *)parentView withTopView:(UIView *)topView edges:(UIRectEdge)edges; - -/** - * Pins a view to its parent view with insets - * @param view The view to pin - * @param parentView The parent view to pin to - * @param edges The edges to pin (UIRectEdge flags) - * @param insets The insets to apply to each edge - */ -+ (void)pinView:(UIView *)view toParentView:(UIView *)parentView edges:(UIRectEdge)edges insets:(UIEdgeInsets)insets; - -/** - * Pins a view to its parent view with its top edge anchored below a top sibling view, with insets - * @param view The view to pin - * @param parentView The parent view to pin to - * @param topView The view to position below (top sibling) - * @param edges The edges to pin to parent (excluding top, which is pinned to topView) - * @param insets The insets to apply to each edge - */ -+ (void)pinView:(UIView *)view - toParentView:(UIView *)parentView - withTopView:(UIView *)topView - edges:(UIRectEdge)edges - insets:(UIEdgeInsets)insets; - -/** - * Unpins a view by removing its constraints and re-enabling autoresizing mask translation - * @param view The view to unpin - * @param parentView The parent view that holds the constraints (optional, will use superview if nil) - */ -+ (void)unpinView:(UIView *)view fromParentView:(nullable UIView *)parentView; - -@end - -NS_ASSUME_NONNULL_END - -#endif \ No newline at end of file diff --git a/ios/utils/LayoutUtil.mm b/ios/utils/LayoutUtil.mm deleted file mode 100644 index c41a519f..00000000 --- a/ios/utils/LayoutUtil.mm +++ /dev/null @@ -1,106 +0,0 @@ -// -// Created by Jovanni Lo (@lodev09) -// Copyright (c) 2024-present. All rights reserved. -// -// This source code is licensed under the MIT license found in the -// LICENSE file in the root directory of this source tree. -// - -#ifdef RCT_NEW_ARCH_ENABLED - -#import "LayoutUtil.h" - -@implementation LayoutUtil - -+ (void)pinView:(UIView *)view toParentView:(UIView *)parentView edges:(UIRectEdge)edges { - [self pinView:view toParentView:parentView edges:edges height:0]; -} - -+ (void)pinView:(UIView *)view toParentView:(UIView *)parentView edges:(UIRectEdge)edges height:(CGFloat)height { - view.translatesAutoresizingMaskIntoConstraints = NO; - - if (edges & UIRectEdgeTop) { - [view.topAnchor constraintEqualToAnchor:parentView.topAnchor].active = YES; - } - if (edges & UIRectEdgeBottom) { - [view.bottomAnchor constraintEqualToAnchor:parentView.bottomAnchor].active = YES; - } - if (edges & UIRectEdgeLeft) { - [view.leadingAnchor constraintEqualToAnchor:parentView.leadingAnchor].active = YES; - } - if (edges & UIRectEdgeRight) { - [view.trailingAnchor constraintEqualToAnchor:parentView.trailingAnchor].active = YES; - } - - // Apply height constraint if provided - if (height > 0) { - [view.heightAnchor constraintEqualToConstant:height].active = YES; - } -} - -+ (void)pinView:(UIView *)view toParentView:(UIView *)parentView edges:(UIRectEdge)edges insets:(UIEdgeInsets)insets { - view.translatesAutoresizingMaskIntoConstraints = NO; - - if (edges & UIRectEdgeTop) { - [view.topAnchor constraintEqualToAnchor:parentView.topAnchor constant:insets.top].active = YES; - } - if (edges & UIRectEdgeBottom) { - [view.bottomAnchor constraintEqualToAnchor:parentView.bottomAnchor constant:-insets.bottom].active = YES; - } - if (edges & UIRectEdgeLeft) { - [view.leadingAnchor constraintEqualToAnchor:parentView.leadingAnchor constant:insets.left].active = YES; - } - if (edges & UIRectEdgeRight) { - [view.trailingAnchor constraintEqualToAnchor:parentView.trailingAnchor constant:-insets.right].active = YES; - } -} - -+ (void)pinView:(UIView *)view toParentView:(UIView *)parentView withTopView:(UIView *)topView edges:(UIRectEdge)edges { - [self pinView:view toParentView:parentView withTopView:topView edges:edges insets:UIEdgeInsetsZero]; -} - -+ (void)pinView:(UIView *)view - toParentView:(UIView *)parentView - withTopView:(UIView *)topView - edges:(UIRectEdge)edges - insets:(UIEdgeInsets)insets { - view.translatesAutoresizingMaskIntoConstraints = NO; - - // Pin top edge to bottom of top sibling - [view.topAnchor constraintEqualToAnchor:topView.bottomAnchor].active = YES; - - // Pin other edges to parent based on edges parameter - if (edges & UIRectEdgeBottom) { - [view.bottomAnchor constraintEqualToAnchor:parentView.bottomAnchor constant:-insets.bottom].active = YES; - } - if (edges & UIRectEdgeLeft) { - [view.leadingAnchor constraintEqualToAnchor:parentView.leadingAnchor constant:insets.left].active = YES; - } - if (edges & UIRectEdgeRight) { - [view.trailingAnchor constraintEqualToAnchor:parentView.trailingAnchor constant:-insets.right].active = YES; - } -} - -+ (void)unpinView:(UIView *)view fromParentView:(UIView *)parentView { - if (!view) - return; - - // Remove constraints from parent view that reference this view - UIView *targetParent = parentView ?: view.superview; - if (targetParent) { - NSMutableArray *constraintsToRemove = [NSMutableArray array]; - for (NSLayoutConstraint *constraint in targetParent.constraints) { - if (constraint.firstItem == view || constraint.secondItem == view) { - [constraintsToRemove addObject:constraint]; - } - } - [targetParent removeConstraints:constraintsToRemove]; - } - - view.translatesAutoresizingMaskIntoConstraints = YES; - [view removeConstraints:view.constraints]; -} - -@end - -#endif \ No newline at end of file diff --git a/package.json b/package.json index 8e062334..a47f3b0d 100644 --- a/package.json +++ b/package.json @@ -128,7 +128,7 @@ "@radix-ui/react-presence": ">=1", "@react-navigation/core": ">=7", "react": "*", - "react-native": "*", + "react-native": ">=0.82.0", "react-native-reanimated": ">=4", "react-native-worklets": "*" }, diff --git a/src/TrueSheet.tsx b/src/TrueSheet.tsx index 0a3e82f8..f6871008 100644 --- a/src/TrueSheet.tsx +++ b/src/TrueSheet.tsx @@ -457,7 +457,6 @@ export class TrueSheet maxContentWidth, anchor = 'center', anchorOffset, - scrollable = false, scrollableOptions, footerOptions, presentation = 'page', @@ -472,7 +471,7 @@ export class TrueSheet } = this.props; // Trim to max 3 detents and clamp fractions - const resolvedDetents = detents.slice(0, 3).map((detent) => { + const resolvedDetents: number[] = detents.slice(0, 3).map((detent) => { if (detent === 'auto' || detent === -1) return -1; if (detent === 'peek' || detent === -2) return -2; @@ -483,6 +482,11 @@ export class TrueSheet return Math.min(1, detent); }); + // `auto` detents derive the sheet height from the content's natural height, + // so the content can't fill the container. Otherwise fill so flex layouts + // and ScrollViews/FlatLists work as-is, like a regular bounded view. + const hasAutoDetent = resolvedDetents.includes(-1); + // Cache grabberOptions to avoid creating a new object every render if (grabberOptions !== this.cachedGrabberOptions) { this.cachedGrabberOptions = grabberOptions; @@ -514,7 +518,6 @@ export class TrueSheet maxContentWidth={maxContentWidth} anchor={anchor} anchorOffset={anchorOffset} - scrollable={scrollable} scrollableOptions={scrollableOptions} footerOptions={footerOptions} presentation={presentation} @@ -536,16 +539,14 @@ export class TrueSheet onVisibilityChange={this.onVisibilityChange} > {this.state.shouldRenderNativeView && ( - + {header && ( {isValidElement(header) ? header : createElement(header)} )} {children} @@ -570,19 +571,23 @@ const styles = StyleSheet.create({ zIndex: -9999, pointerEvents: 'box-none', }, - scrollableContainer: { + // Fill the sheet so flex layouts track the sheet's size per detent + container: { ...StyleSheet.absoluteFill, }, - scrollableContent: { + contentFill: { flex: 1, }, header: { pointerEvents: 'box-none', }, + // Pinned to the sheet's bottom edge via Yoga since the container tracks the + // sheet's visible height footer: { pointerEvents: 'box-none', position: 'absolute', left: 0, right: 0, + bottom: 0, }, }); diff --git a/src/TrueSheet.types.ts b/src/TrueSheet.types.ts index f73484a6..23546648 100644 --- a/src/TrueSheet.types.ts +++ b/src/TrueSheet.types.ts @@ -460,17 +460,6 @@ export interface TrueSheetProps extends ViewProps { */ footerStyle?: StyleProp; - /** - * On iOS, automatically pins ScrollView or FlatList to fit within the sheet's available space. - * When enabled, the ScrollView's top edge will be pinned below any top sibling views, - * and its left, right, and bottom edges will be pinned to the container. - * - * On Android, it adds additional style to the content for scrollable to work. - * - * @default false - */ - scrollable?: boolean; - /** * Options for scrollable behavior. */ diff --git a/src/TrueSheet.web.tsx b/src/TrueSheet.web.tsx index 5ae5fed7..ff1f5042 100644 --- a/src/TrueSheet.web.tsx +++ b/src/TrueSheet.web.tsx @@ -77,7 +77,6 @@ const TrueSheetComponent = forwardRef((props, headerStyle, footer, footerStyle, - scrollable = false, presentation = 'page', detached = false, detachedOffset = DEFAULT_DETACHED_OFFSET, @@ -406,7 +405,7 @@ const TrueSheetComponent = forwardRef((props, positions.push(effectiveH - h); values.push(effectiveH > 0 ? h / effectiveH : 0); } - return { windowH, positions, values }; + return { windowH, effectiveH, ceiling, positions, values }; }, []); // Detent info for lifecycle events. Position/detent come from the active @@ -486,14 +485,54 @@ const TrueSheetComponent = forwardRef((props, [computeDetentGeometry] ); + // Mirror native synchronous layout: while dragging (and through the post- + // release settle) the sized layout tracks the sheet's visible height in + // realtime, clamped to the smallest detent — dragging below it slides the + // sheet out without resizing. Once settled, the inline height is cleared so + // the CSS calc() owns sizing again (adapts to viewport resizes at rest). + const sizedLayoutRef = useRef(null); + const isDraggingRef = useRef(false); + const isSettlingAfterDragRef = useRef(false); + const updateSizedLayoutHeight = useCallback( + (position: number) => { + const node = sizedLayoutRef.current; + if (!node) return; + + const { effectiveH, ceiling, positions } = computeDetentGeometry(); + if (positions.length === 0) return; + + if (isSettlingAfterDragRef.current) { + const snap = activeSnapPointRef.current; + const index = snap != null ? validDetentsRef.current.indexOf(snap) : -1; + const target = index >= 0 ? positions[index]! : null; + if (target != null && Math.abs(position - target) < 1) { + isSettlingAfterDragRef.current = false; + // Restore the calc — height is an inline style, so clearing it would + // leave the div unsized (React won't re-apply it without a render) + node.style.height = SIZED_LAYOUT_HEIGHT; + return; + } + } + + // positions[0] is the smallest detent's top-Y — its height is the clamp floor. + const minHeight = effectiveH - positions[0]!; + const height = Math.min(Math.max(effectiveH - position, minHeight), ceiling); + node.style.height = `${height}px`; + }, + [computeDetentGeometry] + ); + const handlePositionChange = useCallback( (position: number) => { + if (isDraggingRef.current || isSettlingAfterDragRef.current) { + updateSizedLayoutHeight(position); + } const { index, detent } = interpolateFromPosition(position); onPositionChangeRef.current?.({ nativeEvent: { index, position, detent, realtime: true }, } as PositionChangeEvent); }, - [interpolateFromPosition] + [interpolateFromPosition, updateSizedLayoutHeight] ); // Fire onMount once after first render. React-mount is the earliest point @@ -611,7 +650,6 @@ const TrueSheetComponent = forwardRef((props, // Vaul's `onDrag` fires once per pointermove while dragging; the first tick // after an idle gap marks the drag boundary, so track it via a ref. - const isDraggingRef = useRef(false); const handleDrag = useCallback(() => { if (!isDraggingRef.current) { isDraggingRef.current = true; @@ -619,11 +657,17 @@ const TrueSheetComponent = forwardRef((props, } onDragChangeRef.current?.({ nativeEvent: computeDetentInfo(true) } as DragChangeEvent); }, [computeDetentInfo]); - const handleRelease = useCallback(() => { - if (!isDraggingRef.current) return; - isDraggingRef.current = false; - onDragEndRef.current?.({ nativeEvent: computeDetentInfo(true) } as DragEndEvent); - }, [computeDetentInfo]); + const handleRelease = useCallback( + (_event: unknown, open: boolean) => { + if (!isDraggingRef.current) return; + isDraggingRef.current = false; + // Track the sized layout through the settle animation only when the sheet + // stays open — a dismissal slides out at the min-clamped height. + isSettlingAfterDragRef.current = open; + onDragEndRef.current?.({ nativeEvent: computeDetentInfo(true) } as DragEndEvent); + }, + [computeDetentInfo] + ); const { isNested, dismissAbove, descendants } = useSheetStack( methodsRef, @@ -815,6 +859,11 @@ const TrueSheetComponent = forwardRef((props, }; }, [isOpen, descendants.length]); + // Definite-height flex layout (per-detent sizing) unless content must be + // measured in natural flow: 'auto' detents and form-sheet content-fit sizing. + const hasAutoDetent = validDetents.includes('auto'); + const useSizedLayout = !hasAutoDetent && !isFormSheet; + const effectiveCornerRadius = cornerRadius ?? DEFAULT_CORNER_RADIUS; // Shadow cast upward from the sheet's top edge toward the background. Matches @@ -1001,28 +1050,32 @@ const TrueSheetComponent = forwardRef((props, > Sheet {grabber && } - {scrollable ? ( + {useSizedLayout ? ( // vaul wraps children in `[data-vaul-auto-size-wrapper]` (display: // flow-root) which doesn't honor descendant flex layout. Use an // absolute fill sized to the visible portion (via vaul's // `--snap-point-height` var) so the inner flex column has a - // definite height for the scroll container's flex:1 to fill. -
+ // definite height for flex layouts to fill — mirrors native, where + // the container is sized to the sheet's visible height per detent. +
{header && ( {isValidElement(header) ? header : createElement(header)} )} -
- - {children} - -
+ {/* Fill the sized layout so plugged ScrollViews/FlatLists have + a bounded height — mirrors native content fill. */} + + {children} +
) : ( + // Natural flow so vaul can measure content height — required for + // 'auto' detents and form-sheet content-fit sizing. <> {header && ( @@ -1047,23 +1100,22 @@ const overlayStyle: React.CSSProperties = { backgroundColor: 'rgba(0, 0, 0, 0.5)', }; -const scrollableLayoutStyle: React.CSSProperties = { +const SIZED_LAYOUT_HEIGHT = 'calc(100% - var(--snap-point-height, 0px))'; + +const sizedLayoutStyle: React.CSSProperties = { position: 'absolute', top: 0, left: 0, right: 0, - height: 'calc(100% - var(--snap-point-height, 0px))', + height: SIZED_LAYOUT_HEIGHT, display: 'flex', flexDirection: 'column', }; -const scrollableContainerStyle: React.CSSProperties = { +const contentFillStyle = { flex: 1, minHeight: 0, - overflowY: 'auto', - overscrollBehavior: 'contain', - touchAction: 'pan-y', -}; +} as const; const visuallyHiddenStyle: React.CSSProperties = { position: 'absolute', diff --git a/src/fabric/TrueSheetViewNativeComponent.ts b/src/fabric/TrueSheetViewNativeComponent.ts index 247df7ed..4aabf83d 100644 --- a/src/fabric/TrueSheetViewNativeComponent.ts +++ b/src/fabric/TrueSheetViewNativeComponent.ts @@ -103,7 +103,6 @@ export interface NativeProps extends ViewProps { draggable?: WithDefault; dimmed?: WithDefault; initialDetentAnimated?: WithDefault; - scrollable?: WithDefault; scrollableOptions?: ScrollableOptionsType; footerOptions?: FooterOptionsType; presentation?: WithDefault<'page' | 'form', 'page'>; diff --git a/src/navigation/types.ts b/src/navigation/types.ts index 652fa9c9..fa46edb3 100644 --- a/src/navigation/types.ts +++ b/src/navigation/types.ts @@ -135,7 +135,6 @@ export type TrueSheetNavigationSheetProps = Pick< | 'blurOptions' | 'maxContentHeight' | 'maxContentWidth' - | 'scrollable' | 'presentation' | 'header' | 'headerStyle' diff --git a/yarn.lock b/yarn.lock index 0f3fe0f4..fe8c872b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6178,7 +6178,7 @@ __metadata: "@radix-ui/react-presence": ">=1" "@react-navigation/core": ">=7" react: "*" - react-native: "*" + react-native: ">=0.82.0" react-native-reanimated: ">=4" react-native-worklets: "*" peerDependenciesMeta: From ad6dcbcbfad938af67c759e4ae79b6f6abeab5ab Mon Sep 17 00:00:00 2001 From: Jovanni Lo Date: Fri, 17 Jul 2026 00:51:36 +0800 Subject: [PATCH 02/20] feat: support auto detent with scrollables (#743) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: support auto detent with scrollables Content shadow node fills the container (flexGrow/flexShrink via Fabric state) when a ScrollView is pinned so the viewport is bounded per detent, while the auto detent height is measured from the ScrollView's content size (contentFrame - scrollFrame + contentSize). Size reports coalesce to the next tick when pinned to avoid mid-transaction frame mismatches, and iOS seeds screen dimensions pre-present so content can lay out and measure before presenting (parity with Android). * fix(ios): resolve auto detent height lazily for correct initial size The auto detent captured contentHeight as a constant when detents were set up, so a first present raced in-flight content measurement and opened at a stale size before resizing. The resolver now reads contentHeight + headerHeight at layout time, and size changes during presentation invalidate detents so the in-flight animation retargets. * fix(ios): keep detents stable across present/dismiss transitions Size changes no longer reassign sheet.detents (which re-lays out the whole presentation stack, glitching the sheet behind). Auto/peek detents resolve lazily from snapshots; size changes just refresh snapshots + invalidateDetents. Measure heights synchronously at present time so no mid-flight retarget snaps the animation, and drop size updates while dismissing or not presented. * chore(example): add item button to FlatListSheet to showcase auto detent resize * chore(example): start FlatListSheet with 10 items * docs: changelog for #743 * docs: drop bug fix changelog entry for #743 * docs: one changelog entry per pr * chore(example): add remove item button to FlatListSheet * feat(web): support auto detent with plugged scrollables Mirror native: a plugged scrollable keeps the bounded (sized) layout so its viewport can scroll, while the auto detent resolves from the natural height (viewport replaced by scroll content size), observed for resizes. Also fixes drag release being swallowed when the lowest snap offset is 0 (clamped auto) — wrapper overshoot now counts as movement — and switches the drawer to overflow: clip so browser scroll-into-view can't offset it and block sheet drags. * chore: flatlist sheet auto detent only --- AGENTS.md | 2 + CHANGELOG.md | 8 +- .../lodev09/truesheet/TrueSheetContentView.kt | 88 +++++++++++- .../truesheet/TrueSheetContentViewManager.kt | 7 + android/src/main/jni/TrueSheetSpec.h | 1 + .../TrueSheetContentViewComponentDescriptor.h | 24 ++++ .../TrueSheetContentViewShadowNode.cpp | 42 ++++++ .../TrueSheetContentViewShadowNode.h | 28 ++++ .../TrueSheetContentViewState.cpp | 11 ++ .../TrueSheetSpec/TrueSheetContentViewState.h | 37 +++++ example/bare/ios/Podfile.lock | 4 +- .../src/components/sheets/FlatListSheet.tsx | 21 ++- ios/TrueSheetContainerView.mm | 2 +- ios/TrueSheetContentView.h | 7 + ios/TrueSheetContentView.mm | 117 +++++++++++++++- ios/TrueSheetView.mm | 46 +++++-- ios/TrueSheetViewController.mm | 71 ++++++++-- ios/tvos/TrueSheetStubs.mm | 1 + react-native.config.js | 5 +- src/TrueSheet.web.tsx | 128 ++++++++++++++++-- .../TrueSheetContentViewNativeComponent.ts | 6 +- src/web/vaul/index.tsx | 28 +++- 22 files changed, 626 insertions(+), 58 deletions(-) create mode 100644 common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetContentViewComponentDescriptor.h create mode 100644 common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetContentViewShadowNode.cpp create mode 100644 common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetContentViewShadowNode.h create mode 100644 common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetContentViewState.cpp create mode 100644 common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetContentViewState.h diff --git a/AGENTS.md b/AGENTS.md index d7b501ec..a587c873 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -87,4 +87,6 @@ Add entry to `Unreleased` section in `CHANGELOG.md` for user-facing changes: Format: `- **Platform**: Description. ([#123](https://github.com/lodev09/react-native-true-sheet/pull/123) by [@username](https://github.com/username))` +One entry per PR — summarize the PR's user-facing change in a single entry under its primary section. + Sort entries by platform: **iOS** first, then **Android**, then **Web**, then cross-platform or unscoped entries last. diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b581efd..269e6549 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,11 +2,13 @@ ## Unreleased +### 🎉 New features + +- The `auto` detent now works with plugged scrollables — the sheet sizes to the scrollable's content height and resizes as content grows or shrinks. ([#743](https://github.com/lodev09/react-native-true-sheet/pull/743) by [@lodev09](https://github.com/lodev09)) + ### 💥 Breaking changes -- Requires React Native 0.82+. ([#735](https://github.com/lodev09/react-native-true-sheet/pull/735) by [@lodev09](https://github.com/lodev09)) -- Synchronous per-detent container layout — the container is sized to the sheet's visible height per detent and tracks it in realtime while dragging, on all platforms. Previously it was sized to the screen height / largest detent; layouts relying on that may shift. ([#735](https://github.com/lodev09/react-native-true-sheet/pull/735) by [@lodev09](https://github.com/lodev09)) -- Removed the `scrollable` prop — scrollables are auto-detected. `ScrollView`/`FlatList` work plugged in directly, like a regular view; insets, keyboard handling, and nested scrolling are wired automatically. ([#735](https://github.com/lodev09/react-native-true-sheet/pull/735) by [@lodev09](https://github.com/lodev09)) +- Synchronous per-detent container layout — the container is sized to the sheet's visible height per detent and tracks it in realtime while dragging, on all platforms (previously sized to the screen height / largest detent). The `scrollable` prop is removed — scrollables are auto-detected and work plugged in directly. Requires React Native 0.82+. ([#735](https://github.com/lodev09/react-native-true-sheet/pull/735) by [@lodev09](https://github.com/lodev09)) ## 3.11.9 diff --git a/android/src/main/java/com/lodev09/truesheet/TrueSheetContentView.kt b/android/src/main/java/com/lodev09/truesheet/TrueSheetContentView.kt index a9c325f5..f7a4cbf3 100644 --- a/android/src/main/java/com/lodev09/truesheet/TrueSheetContentView.kt +++ b/android/src/main/java/com/lodev09/truesheet/TrueSheetContentView.kt @@ -9,7 +9,9 @@ import android.widget.EditText import android.widget.ScrollView import androidx.core.widget.NestedScrollView import androidx.swiperefreshlayout.widget.SwipeRefreshLayout +import com.facebook.react.bridge.WritableNativeMap import com.facebook.react.uimanager.PixelUtil.dpToPx +import com.facebook.react.uimanager.StateWrapper import com.facebook.react.uimanager.ThemedReactContext import com.facebook.react.views.view.ReactViewGroup import com.lodev09.truesheet.core.TrueSheetKeyboardObserver @@ -36,13 +38,42 @@ interface TrueSheetContentViewDelegate { @SuppressLint("ViewConstructor") class TrueSheetContentView(private val reactContext: ThemedReactContext) : ReactViewGroup(reactContext) { var delegate: TrueSheetContentViewDelegate? = null + var stateWrapper: StateWrapper? = null private var lastWidth = 0 private var lastHeight = 0 private var pinnedScrollView: ViewGroup? = null + private var observedScrollChild: View? = null private var originalScrollViewPaddingBottom: Int = 0 private var bottomInset: Int = 0 + private var scrollableBounded = false + private var isReportPending = false + + // Content growth is invisible to layout once the viewport is bounded, so track + // the scroll content size directly to keep the auto detent height in sync. + private val scrollChildLayoutListener = + View.OnLayoutChangeListener { _, _, top, _, bottom, _, oldTop, _, oldBottom -> + if (bottom - top != oldBottom - oldTop) { + reportSizeIfChanged() + } + } + + /** + * Content height with the pinned ScrollView's viewport replaced by its content + * size — the height the content wants regardless of container bounds. + * Falls back to the view height when no ScrollView is pinned. + */ + val naturalHeight: Int + get() { + var naturalHeight = height + pinnedScrollView?.let { scrollView -> + scrollView.getChildAt(0)?.let { child -> + naturalHeight += child.height - scrollView.height + } + } + return maxOf(0, naturalHeight) + } private var keyboardScrollOffset: Float = 0f private var keyboardObserver: TrueSheetKeyboardObserver? = null @@ -74,11 +105,49 @@ class TrueSheetContentView(private val reactContext: ThemedReactContext) : React override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) { super.onSizeChanged(w, h, oldw, oldh) + reportSizeIfChanged() + } + + private fun reportSizeIfChanged() { + // naturalHeight mixes sizes from different views; mid-layout they're + // momentarily inconsistent (parent resizes before its children), which + // feeds back into the auto detent and oscillates. Coalesce to the next + // frame so sizes are settled before measuring. + if (pinnedScrollView != null) { + if (isReportPending) return + isReportPending = true + + post { + isReportPending = false + reportSizeNow() + } + return + } - if (w != lastWidth || h != lastHeight) { - lastWidth = w - lastHeight = h - delegate?.contentViewDidChangeSize(w, h) + reportSizeNow() + } + + private fun reportSizeNow() { + val newHeight = naturalHeight + if (width != lastWidth || newHeight != lastHeight) { + lastWidth = width + lastHeight = newHeight + delegate?.contentViewDidChangeSize(width, newHeight) + } + } + + /** + * Tells the shadow node to fill the container (flexGrow/flexShrink) so the + * pinned ScrollView's viewport is bounded to the visible space. + */ + private fun setScrollableBounded(bounded: Boolean) { + if (scrollableBounded == bounded) return + scrollableBounded = bounded + + stateWrapper?.let { + val newState = WritableNativeMap() + newState.putBoolean("scrollableBounded", bounded) + it.updateState(newState) } } @@ -108,6 +177,13 @@ class TrueSheetContentView(private val reactContext: ThemedReactContext) : React delegate?.contentViewDidScroll() } } + + scrollView.getChildAt(0)?.let { child -> + observedScrollChild = child + child.addOnLayoutChangeListener(scrollChildLayoutListener) + } + setScrollableBounded(true) + reportSizeIfChanged() } this.bottomInset = bottomInset @@ -137,9 +213,13 @@ class TrueSheetContentView(private val reactContext: ThemedReactContext) : React pinnedScrollView?.isNestedScrollingEnabled = false (pinnedScrollView?.parent as? SwipeRefreshLayout)?.isNestedScrollingEnabled = true setScrollViewPaddingBottom(originalScrollViewPaddingBottom) + observedScrollChild?.removeOnLayoutChangeListener(scrollChildLayoutListener) + observedScrollChild = null + setScrollableBounded(false) pinnedScrollView = null originalScrollViewPaddingBottom = 0 bottomInset = 0 + reportSizeIfChanged() } fun findScrollView(): ViewGroup? { diff --git a/android/src/main/java/com/lodev09/truesheet/TrueSheetContentViewManager.kt b/android/src/main/java/com/lodev09/truesheet/TrueSheetContentViewManager.kt index 2853192b..6659feeb 100644 --- a/android/src/main/java/com/lodev09/truesheet/TrueSheetContentViewManager.kt +++ b/android/src/main/java/com/lodev09/truesheet/TrueSheetContentViewManager.kt @@ -2,6 +2,8 @@ package com.lodev09.truesheet import com.facebook.react.module.annotations.ReactModule import com.facebook.react.uimanager.PointerEvents +import com.facebook.react.uimanager.ReactStylesDiffMap +import com.facebook.react.uimanager.StateWrapper import com.facebook.react.uimanager.ThemedReactContext import com.facebook.react.uimanager.ViewGroupManager import com.facebook.react.uimanager.annotations.ReactProp @@ -17,6 +19,11 @@ class TrueSheetContentViewManager : ViewGroupManager() { override fun createViewInstance(reactContext: ThemedReactContext): TrueSheetContentView = TrueSheetContentView(reactContext) + override fun updateState(view: TrueSheetContentView, props: ReactStylesDiffMap?, stateWrapper: StateWrapper?): Any? { + view.stateWrapper = stateWrapper + return null + } + @ReactProp(name = "pointerEvents") fun setPointerEvents(view: TrueSheetContentView, pointerEventsStr: String?) { view.pointerEvents = PointerEvents.parsePointerEvents(pointerEventsStr) diff --git a/android/src/main/jni/TrueSheetSpec.h b/android/src/main/jni/TrueSheetSpec.h index 9569f3d3..c1d52ce5 100644 --- a/android/src/main/jni/TrueSheetSpec.h +++ b/android/src/main/jni/TrueSheetSpec.h @@ -3,6 +3,7 @@ #include #include #include +#include #include namespace facebook { diff --git a/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetContentViewComponentDescriptor.h b/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetContentViewComponentDescriptor.h new file mode 100644 index 00000000..ef9ef6c8 --- /dev/null +++ b/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetContentViewComponentDescriptor.h @@ -0,0 +1,24 @@ +#pragma once + +#include +#include + +namespace facebook::react { + +/* + * Descriptor for component. + */ +class TrueSheetContentViewComponentDescriptor final + : public ConcreteComponentDescriptor { + using ConcreteComponentDescriptor::ConcreteComponentDescriptor; + + void adopt(ShadowNode &shadowNode) const override { + auto &concreteShadowNode = + static_cast(shadowNode); + concreteShadowNode.adjustLayoutWithState(); + + ConcreteComponentDescriptor::adopt(shadowNode); + } +}; + +} // namespace facebook::react diff --git a/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetContentViewShadowNode.cpp b/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetContentViewShadowNode.cpp new file mode 100644 index 00000000..654c227d --- /dev/null +++ b/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetContentViewShadowNode.cpp @@ -0,0 +1,42 @@ +#include "TrueSheetContentViewShadowNode.h" + +#include + +namespace facebook::react { + +using namespace yoga; + +extern const char TrueSheetContentViewComponentName[] = "TrueSheetContentView"; + +void TrueSheetContentViewShadowNode::adjustLayoutWithState() { + ensureUnsealed(); + + auto state = std::static_pointer_cast< + const TrueSheetContentViewShadowNode::ConcreteState>(getState()); + auto stateData = state->getData(); + + auto &props = getConcreteProps(); + + // When a ScrollView is pinned, fill the container so descendant flex layouts + // (flex:1 wrappers, the ScrollView itself) resolve against a definite height + // and the viewport is bounded. The natural height for auto detents is + // measured from the ScrollView's content size instead (see ContentView's + // naturalHeight on each platform). + auto flexGrow = stateData.scrollableBounded + ? FloatOptional{1.0f} + : props.yogaStyle.flexGrow(); + auto flexShrink = stateData.scrollableBounded + ? FloatOptional{1.0f} + : props.yogaStyle.flexShrink(); + + if (yogaNode_.style().flexGrow() != flexGrow || + yogaNode_.style().flexShrink() != flexShrink) { + yoga::Style adjustedStyle = props.yogaStyle; + adjustedStyle.setFlexGrow(flexGrow); + adjustedStyle.setFlexShrink(flexShrink); + yogaNode_.setStyle(adjustedStyle); + yogaNode_.setDirty(true); + } +} + +} // namespace facebook::react diff --git a/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetContentViewShadowNode.h b/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetContentViewShadowNode.h new file mode 100644 index 00000000..b65d2468 --- /dev/null +++ b/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetContentViewShadowNode.h @@ -0,0 +1,28 @@ +#pragma once + +#include +#include +#include +#include +#include + +namespace facebook::react { + +JSI_EXPORT extern const char TrueSheetContentViewComponentName[]; + +/* + * `ShadowNode` for component. + */ +class JSI_EXPORT TrueSheetContentViewShadowNode final + : public ConcreteViewShadowNode< + TrueSheetContentViewComponentName, + TrueSheetContentViewProps, + TrueSheetContentViewEventEmitter, + TrueSheetContentViewState> { + using ConcreteViewShadowNode::ConcreteViewShadowNode; + + public: + void adjustLayoutWithState(); +}; + +} // namespace facebook::react diff --git a/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetContentViewState.cpp b/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetContentViewState.cpp new file mode 100644 index 00000000..8800f7ef --- /dev/null +++ b/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetContentViewState.cpp @@ -0,0 +1,11 @@ +#include "TrueSheetContentViewState.h" + +namespace facebook::react { + +#ifdef ANDROID +folly::dynamic TrueSheetContentViewState::getDynamic() const { + return folly::dynamic::object("scrollableBounded", scrollableBounded); +} +#endif + +} // namespace facebook::react diff --git a/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetContentViewState.h b/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetContentViewState.h new file mode 100644 index 00000000..e390c42e --- /dev/null +++ b/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetContentViewState.h @@ -0,0 +1,37 @@ +#pragma once + +#ifdef ANDROID +#include +#include +#include +#endif + +namespace facebook::react { + +/* + * State for component. + * Set from native when a ScrollView is pinned so the shadow node bounds the + * content to the container via flexShrink (see TrueSheetContentViewShadowNode). + */ +class TrueSheetContentViewState final { + public: + TrueSheetContentViewState() = default; + +#ifdef ANDROID + TrueSheetContentViewState( + TrueSheetContentViewState const &previousState, + folly::dynamic data) + : scrollableBounded(data["scrollableBounded"].getBool()) {} +#endif + + bool scrollableBounded{false}; + +#ifdef ANDROID + folly::dynamic getDynamic() const; + MapBuffer getMapBuffer() const { + return MapBufferBuilder::EMPTY(); + } +#endif +}; + +} // namespace facebook::react diff --git a/example/bare/ios/Podfile.lock b/example/bare/ios/Podfile.lock index 8dff1475..b4f9d88d 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.6): + - RNTrueSheet (3.11.9): - hermes-engine - RCTRequired - RCTTypeSafety @@ -2481,7 +2481,7 @@ SPEC CHECKSUMS: RNGestureHandler: 2ff61eac036eaf89f6818bf4ed9c39771a17d134 RNReanimated: f735b1747a7a93bda7ca102c6d37a3cf54b6d5e8 RNScreens: 991cc417cd396602a6cf59a42139e5a9d91462a9 - RNTrueSheet: 2fe0b0b375aa0a8e9ec19258503a73cc0da76cfd + RNTrueSheet: fa8b104afa2fa9eca2338f879880a147cae322f9 RNWorklets: 4931990f73bc8f347586918b2e13f11dfadf3b75 Yoga: 77dfa8673de2874e1855002ae59c68b8be9b007b diff --git a/example/shared/src/components/sheets/FlatListSheet.tsx b/example/shared/src/components/sheets/FlatListSheet.tsx index 53308df4..37c687e6 100644 --- a/example/shared/src/components/sheets/FlatListSheet.tsx +++ b/example/shared/src/components/sheets/FlatListSheet.tsx @@ -1,4 +1,4 @@ -import { forwardRef, useRef } from 'react'; +import { forwardRef, useRef, useState } from 'react'; import { StyleSheet, FlatList, View, Platform } from 'react-native'; import { TrueSheet, type TrueSheetProps } from '@lodev09/react-native-true-sheet'; @@ -7,17 +7,20 @@ import { DemoContent } from '../DemoContent'; import { Spacer } from '../Spacer'; import { Header } from '../Header'; import { Footer } from '../Footer'; +import { Button } from '../Button'; +import { ButtonGroup } from '../ButtonGroup'; interface FlatListSheetProps extends TrueSheetProps {} export const FlatListSheet = forwardRef((props, ref) => { const testRef = useRef(null); const scrollRef = useRef(null); + const [itemCount, setItemCount] = useState(3); return ( ((props, r i)} + data={times(itemCount, (i) => i)} contentContainerStyle={styles.content} indicatorStyle="black" ItemSeparatorComponent={Spacer} scrollIndicatorInsets={{ bottom: FOOTER_HEIGHT }} renderItem={({ item }) => } + ListFooterComponent={ + <> + + +
From 097e738a95265a4405afd7f26ed2ba7f76dad860 Mon Sep 17 00:00:00 2001 From: Jovanni Lo Date: Sat, 18 Jul 2026 03:19:56 +0800 Subject: [PATCH 06/20] feat: add headerOptions prop with absolute header position (#747) * feat: add headerOptions prop with absolute header position * docs: add headerOptions documentation * docs: add changelog entry --- CHANGELOG.md | 1 + .../com/lodev09/truesheet/TrueSheetView.kt | 4 +++ .../truesheet/TrueSheetViewController.kt | 2 ++ .../lodev09/truesheet/TrueSheetViewManager.kt | 6 +++++ .../core/TrueSheetDetentCalculator.kt | 13 ++++++++-- docs/docs/guides/header.mdx | 26 +++++++++++++++++++ docs/docs/reference/01-configuration.mdx | 8 ++++++ docs/docs/reference/04-types.mdx | 19 ++++++++++++++ .../src/components/sheets/BasicSheet.tsx | 2 +- .../src/components/sheets/FlatListSheet.tsx | 8 +----- .../src/components/sheets/ScrollViewSheet.tsx | 8 +----- ios/TrueSheetView.mm | 2 ++ ios/TrueSheetViewController.h | 1 + ios/TrueSheetViewController.mm | 4 +-- ios/core/TrueSheetDetentCalculator.h | 7 +++++ ios/core/TrueSheetDetentCalculator.mm | 9 +++++-- src/TrueSheet.tsx | 18 ++++++++++++- src/TrueSheet.types.ts | 22 ++++++++++++++++ src/TrueSheet.web.tsx | 26 ++++++++++++++++--- src/fabric/TrueSheetViewNativeComponent.ts | 5 ++++ 20 files changed, 166 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e52e3e2..44e856ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - **iOS**: `onPositionChange` now tracks the sheet's actual on-screen position everywhere — detent snaps, programmatic resizes, and sheets moving behind a child sheet emit realtime frames from a single native tracker (previously approximated with a JS-side spring or only emitted once settled). `onDetentChange` for programmatic resizes now fires when the animation actually ends instead of after a fixed delay. ([#744](https://github.com/lodev09/react-native-true-sheet/pull/744) by [@lodev09](https://github.com/lodev09)) - The `auto` detent now works with plugged scrollables — the sheet sizes to the scrollable's content height and resizes as content grows or shrinks. ([#743](https://github.com/lodev09/react-native-true-sheet/pull/743) by [@lodev09](https://github.com/lodev09)) +- New `headerOptions` prop with a `position` option — set to `'absolute'` to float the header over the content (pinned to the top edge) and exclude it from the `auto` detent height. ([#747](https://github.com/lodev09/react-native-true-sheet/pull/747) by [@lodev09](https://github.com/lodev09)) ### 💥 Breaking changes diff --git a/android/src/main/java/com/lodev09/truesheet/TrueSheetView.kt b/android/src/main/java/com/lodev09/truesheet/TrueSheetView.kt index 516d23c9..e70d93d9 100644 --- a/android/src/main/java/com/lodev09/truesheet/TrueSheetView.kt +++ b/android/src/main/java/com/lodev09/truesheet/TrueSheetView.kt @@ -280,6 +280,10 @@ class TrueSheetView(private val reactContext: ThemedReactContext) : setupScrollable() } + fun setAbsoluteHeader(absolute: Boolean) { + viewController.absoluteHeader = absolute + } + fun setFooterKeyboardOffset(offset: Float) { viewController.footerKeyboardOffset = offset.dpToPx().toInt() viewController.positionFooter() diff --git a/android/src/main/java/com/lodev09/truesheet/TrueSheetViewController.kt b/android/src/main/java/com/lodev09/truesheet/TrueSheetViewController.kt index b72afe33..4bb7116c 100644 --- a/android/src/main/java/com/lodev09/truesheet/TrueSheetViewController.kt +++ b/android/src/main/java/com/lodev09/truesheet/TrueSheetViewController.kt @@ -281,6 +281,8 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) : override val headerHeight: Int get() = containerView?.headerHeight ?: cachedHeaderHeight + override var absoluteHeader: Boolean = false + override val footerHeight: Int get() = containerView?.footerHeight ?: cachedFooterHeight diff --git a/android/src/main/java/com/lodev09/truesheet/TrueSheetViewManager.kt b/android/src/main/java/com/lodev09/truesheet/TrueSheetViewManager.kt index 6270f7c4..92390cdc 100644 --- a/android/src/main/java/com/lodev09/truesheet/TrueSheetViewManager.kt +++ b/android/src/main/java/com/lodev09/truesheet/TrueSheetViewManager.kt @@ -225,6 +225,12 @@ class TrueSheetViewManager : view.setScrollableOptions(scrollableOptions) } + @ReactProp(name = "headerOptions") + override fun setHeaderOptions(view: TrueSheetView, options: ReadableMap?) { + val position = if (options != null && options.hasKey("position")) options.getString("position") else null + view.setAbsoluteHeader(position == "absolute") + } + @ReactProp(name = "footerOptions") override fun setFooterOptions(view: TrueSheetView, options: ReadableMap?) { val keyboardOffset = 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 4871e9cf..dfe8ef46 100644 --- a/android/src/main/java/com/lodev09/truesheet/core/TrueSheetDetentCalculator.kt +++ b/android/src/main/java/com/lodev09/truesheet/core/TrueSheetDetentCalculator.kt @@ -15,6 +15,7 @@ interface TrueSheetDetentCalculatorDelegate { val detents: MutableList val contentHeight: Int val headerHeight: Int + val absoluteHeader: Boolean val footerHeight: Int val peekContentHeight: Int val contentBottomInset: Int @@ -33,6 +34,7 @@ class TrueSheetDetentCalculator(private val reactContext: ThemedReactContext) { private val realScreenHeight: Int get() = delegate?.realScreenHeight ?: 0 private val detents: List get() = delegate?.detents ?: emptyList() 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 @@ -40,6 +42,13 @@ class TrueSheetDetentCalculator(private val reactContext: ThemedReactContext) { private val maxContentHeight: Int? get() = delegate?.maxContentHeight private val keyboardInset: Int get() = delegate?.keyboardInset ?: 0 + /** + * Height for auto (-1.0) detents: content + header height. + * An absolute (floating) header overlaps the content, so it contributes no height. + */ + private val autoDetentHeight: Int + get() = contentHeight + if (delegate?.absoluteHeader == true) 0 else headerHeight + /** * Height for peek (-2.0) detents: header + footer + peek content height. * Falls back to 150dp when none is present. @@ -56,7 +65,7 @@ class TrueSheetDetentCalculator(private val reactContext: ThemedReactContext) { */ fun getDetentHeight(detent: Double, includeKeyboard: Boolean = true): Int { val baseHeight = if (detent == -1.0) { - contentHeight + headerHeight + contentBottomInset + autoDetentHeight + contentBottomInset } else if (detent == -2.0) { peekDetentHeight + contentBottomInset } else { @@ -99,7 +108,7 @@ class TrueSheetDetentCalculator(private val reactContext: ThemedReactContext) { if (index < 0 || index >= detents.size) return 0f val value = detents[index] return if (value == -1.0) { - (contentHeight + headerHeight).toFloat() / screenHeight.toFloat() + autoDetentHeight.toFloat() / screenHeight.toFloat() } else if (value == -2.0) { peekDetentHeight.toFloat() / screenHeight.toFloat() } else { diff --git a/docs/docs/guides/header.mdx b/docs/docs/guides/header.mdx index 669d23b0..0f6c986f 100644 --- a/docs/docs/guides/header.mdx +++ b/docs/docs/guides/header.mdx @@ -65,6 +65,32 @@ const App = () => { When using `header` with `FlatList` or `ScrollView`, the content area height is automatically adjusted to account for the header height. This ensures proper scrolling behavior on both iOS and Android. ::: +## Floating Header + +By default, the header takes up space above the content and its height is included in the [`auto`](../reference/types#sheetdetent) detent calculation. Set `headerOptions.position` to `'absolute'` to float the header over the content instead — it gets pinned to the top edge and no longer adds to the `auto` detent height. + +```tsx {5-5} +const App = () => { + return ( + } + > + } + /> + + ) +} +``` + +:::tip +Since the header overlaps the content, add top padding to your content (e.g. `contentContainerStyle`) so it starts below the header. +::: + ## Collapsing to the Header Use the [`"peek"`](../reference/types#sheetdetent) detent to collapse the sheet down to just the header (plus [`footer`](footer), if provided). Great for map-style sheets with a compact summary that expands into full content. diff --git a/docs/docs/reference/01-configuration.mdx b/docs/docs/reference/01-configuration.mdx index fa5ee7e2..8f2b1fe2 100644 --- a/docs/docs/reference/01-configuration.mdx +++ b/docs/docs/reference/01-configuration.mdx @@ -272,6 +272,14 @@ Style for the header container. | - | - | - | - | - | | `StyleProp` | | ✅ | ✅ | ✅ | +## `headerOptions` + +Options for customizing header behavior. + +| Type | Default | 🍎 | 🤖 | 🌐 | +| - | - | - | - | - | +| [`HeaderOptions`](types#headeroptions) | | ✅ | ✅ | ✅ | + ## `footer` A component that floats at the bottom of the sheet. Accepts a functional `Component` or `ReactElement`. diff --git a/docs/docs/reference/04-types.mdx b/docs/docs/reference/04-types.mdx index ac4aa698..4ba66f85 100644 --- a/docs/docs/reference/04-types.mdx +++ b/docs/docs/reference/04-types.mdx @@ -100,6 +100,25 @@ Options for customizing scrollable behavior. Applies when the sheet contains a ` | `topScrollEdgeEffect` | [`ScrollEdgeEffect`](#scrolledgeeffect) | The scroll edge effect applied to the top edge of the scroll view and header. iOS 26+ only. | `'hidden'` | | `bottomScrollEdgeEffect` | [`ScrollEdgeEffect`](#scrolledgeeffect) | The scroll edge effect applied to the bottom edge of the scroll view and footer. iOS 26+ only. | `'hidden'` | +## `HeaderOptions` + +Options for customizing header behavior. + +```tsx +} + headerOptions={{ + position: 'absolute', + }} +> + {/* ... */} + +``` + +| Property | Type | Description | Default | +| - | - | - | - | +| `position` | `'relative' \| 'absolute'` | How the header participates in the sheet layout. `'relative'` takes up space above the content and is included in the `auto` detent height. `'absolute'` floats over the content, pinned to the top edge, and is excluded from the `auto` detent height. | `'relative'` | + ## `FooterOptions` Options for customizing footer behavior. diff --git a/example/shared/src/components/sheets/BasicSheet.tsx b/example/shared/src/components/sheets/BasicSheet.tsx index 0c09ae16..c7c49086 100644 --- a/example/shared/src/components/sheets/BasicSheet.tsx +++ b/example/shared/src/components/sheets/BasicSheet.tsx @@ -173,7 +173,7 @@ const styles = StyleSheet.create({ paddingTop: SPACING, paddingBottom: FOOTER_HEIGHT + SPACING, gap: GAP, - } + }, }); BasicSheet.displayName = 'BasicSheet'; diff --git a/example/shared/src/components/sheets/FlatListSheet.tsx b/example/shared/src/components/sheets/FlatListSheet.tsx index 37c687e6..f8e113dd 100644 --- a/example/shared/src/components/sheets/FlatListSheet.tsx +++ b/example/shared/src/components/sheets/FlatListSheet.tsx @@ -28,7 +28,7 @@ export const FlatListSheet = forwardRef((props, r topScrollEdgeEffect: 'soft', }} header={
} - headerStyle={styles.header} + headerOptions={{ position: 'absolute' }} onDidDismiss={() => console.log('Sheet FlatList dismissed!')} onDidPresent={() => console.log(`Sheet FlatList presented!`)} footer={ @@ -82,12 +82,6 @@ const styles = StyleSheet.create({ ios: undefined, }), }, - header: { - position: 'absolute', - left: 0, - right: 0, - zIndex: 1, - }, content: { padding: SPACING, paddingTop: HEADER_HEIGHT + SPACING, diff --git a/example/shared/src/components/sheets/ScrollViewSheet.tsx b/example/shared/src/components/sheets/ScrollViewSheet.tsx index 3beec739..1e6a1eb0 100644 --- a/example/shared/src/components/sheets/ScrollViewSheet.tsx +++ b/example/shared/src/components/sheets/ScrollViewSheet.tsx @@ -73,7 +73,7 @@ export const ScrollViewSheet = forwardRef((prop }} backgroundColor={Platform.select({ android: DARK })} header={
} - headerStyle={styles.header} + headerOptions={{ position: 'absolute' }} footer={
} + footerOptions={{ position: 'absolute' }} onDidDismiss={() => console.log('Sheet ScrollView dismissed!')} onDidPresent={() => console.log(`Sheet ScrollView presented!`)} {...props} @@ -87,7 +87,6 @@ export const ScrollViewSheet = forwardRef((prop } > @@ -113,7 +112,6 @@ const styles = StyleSheet.create({ content: { padding: SPACING, paddingTop: HEADER_HEIGHT + SPACING, - paddingBottom: FOOTER_HEIGHT + SPACING, gap: GAP, }, footer: { diff --git a/ios/TrueSheetView.mm b/ios/TrueSheetView.mm index fa41fc5a..d357e237 100644 --- a/ios/TrueSheetView.mm +++ b/ios/TrueSheetView.mm @@ -266,6 +266,7 @@ - (void)updateProps:(Props::Shared const &)props oldProps:(Props::Shared const & _controller.scrollingExpandsSheet = scrollingExpandsSheet; _controller.absoluteHeader = newProps.headerOptions.position == TrueSheetViewPosition::Absolute; + _controller.absoluteFooter = newProps.footerOptions.footerPosition == TrueSheetViewFooterPosition::Absolute; CGFloat footerKeyboardOffset = newProps.footerOptions.keyboardOffset; if (_controller.footerKeyboardOffset != footerKeyboardOffset) { diff --git a/ios/TrueSheetViewController.h b/ios/TrueSheetViewController.h index 5373121a..e66dd231 100644 --- a/ios/TrueSheetViewController.h +++ b/ios/TrueSheetViewController.h @@ -59,6 +59,7 @@ NS_ASSUME_NONNULL_BEGIN @property (nonatomic, strong, nullable) NSNumber *headerHeight; @property (nonatomic, assign) BOOL absoluteHeader; @property (nonatomic, strong, nullable) NSNumber *footerHeight; +@property (nonatomic, assign) BOOL absoluteFooter; @property (nonatomic, strong, nullable) NSNumber *peekContentHeight; @property (nonatomic, strong, nullable) UIColor *backgroundColor; @property (nonatomic, strong, nullable) NSNumber *cornerRadius; diff --git a/ios/core/TrueSheetDetentCalculator.h b/ios/core/TrueSheetDetentCalculator.h index 17e7f943..beb7cdc7 100644 --- a/ios/core/TrueSheetDetentCalculator.h +++ b/ios/core/TrueSheetDetentCalculator.h @@ -23,6 +23,7 @@ NS_ASSUME_NONNULL_BEGIN @property (nonatomic, strong, readonly, nullable) NSNumber *headerHeight; @property (nonatomic, readonly) BOOL absoluteHeader; @property (nonatomic, strong, readonly, nullable) NSNumber *footerHeight; +@property (nonatomic, readonly) BOOL absoluteFooter; @property (nonatomic, strong, readonly, nullable) NSNumber *peekContentHeight; @end @@ -44,13 +45,14 @@ NS_ASSUME_NONNULL_BEGIN - (CGFloat)detentValueForIndex:(NSInteger)index; /** - Returns the height for auto (-1) detents: content + header height. - An absolute (floating) header contributes no height. + Returns the height for auto (-1) detents: content + header + footer height. + An absolute (floating) header or footer contributes no height. */ - (CGFloat)autoHeight; /** Returns the height for peek (-2) detents: header + footer + peek content height. + A relative footer is pushed off-screen at peek, so it contributes no height. Falls back to 150 when none is present — matches the iOS 26 floating small-detent threshold. */ diff --git a/ios/core/TrueSheetDetentCalculator.mm b/ios/core/TrueSheetDetentCalculator.mm index c3f401ef..42d14aa9 100644 --- a/ios/core/TrueSheetDetentCalculator.mm +++ b/ios/core/TrueSheetDetentCalculator.mm @@ -30,14 +30,18 @@ - (CGFloat)detentValueForIndex:(NSInteger)index { } - (CGFloat)autoHeight { - // An absolute (floating) header overlaps the content, so it contributes no height + // An absolute (floating) header or footer overlaps the content, so it contributes no height CGFloat headerHeight = self.delegate.absoluteHeader ? 0 : [self.delegate.headerHeight floatValue]; - return [self.delegate.contentHeight floatValue] + headerHeight; + CGFloat footerHeight = self.delegate.absoluteFooter ? 0 : [self.delegate.footerHeight floatValue]; + return [self.delegate.contentHeight floatValue] + headerHeight + footerHeight; } - (CGFloat)peekHeight { - CGFloat height = [self.delegate.headerHeight floatValue] + [self.delegate.footerHeight floatValue] + - [self.delegate.peekContentHeight floatValue]; + // A relative footer is laid out below the content, so it's pushed off-screen + // at the peek detent and contributes no height + CGFloat footerHeight = self.delegate.absoluteFooter ? [self.delegate.footerHeight floatValue] : 0; + CGFloat height = + [self.delegate.headerHeight floatValue] + footerHeight + [self.delegate.peekContentHeight floatValue]; return height > 0 ? height : 150; } diff --git a/src/TrueSheet.tsx b/src/TrueSheet.tsx index de600ef3..e834567f 100644 --- a/src/TrueSheet.tsx +++ b/src/TrueSheet.tsx @@ -516,7 +516,10 @@ export class TrueSheet anchorOffset={anchorOffset} scrollableOptions={scrollableOptions} headerOptions={headerOptions} - footerOptions={footerOptions} + footerOptions={{ + footerPosition: footerOptions?.position, + keyboardOffset: footerOptions?.keyboardOffset, + }} presentation={presentation} insetAdjustment={insetAdjustment} onMount={this.onMount} @@ -552,7 +555,15 @@ export class TrueSheet {children} {footer && ( - + {isValidElement(footer) ? footer : createElement(footer)} )} @@ -587,10 +598,16 @@ const styles = StyleSheet.create({ right: 0, zIndex: 1, }, - // Pinned to the sheet's bottom edge via Yoga since the container tracks the - // sheet's visible height footer: { pointerEvents: 'box-none', + }, + // Pinned to the sheet's bottom edge via Yoga since the container tracks the + // sheet's visible height — takes up layout space below the content + relativeFooter: { + marginTop: 'auto', + }, + // Floats over the content so it doesn't take up layout space + absoluteFooter: { position: 'absolute', left: 0, right: 0, diff --git a/src/TrueSheet.types.ts b/src/TrueSheet.types.ts index fa051bfd..8a12edc8 100644 --- a/src/TrueSheet.types.ts +++ b/src/TrueSheet.types.ts @@ -153,6 +153,20 @@ export interface HeaderOptions { * Options for footer behavior */ export interface FooterOptions { + /** + * How the footer participates in the sheet layout. + * + * - `'relative'`: The footer takes up space below the content, pinned to the + * bottom edge. Its height is included in the `auto` detent calculation but + * excluded from the `peek` detent (it's pushed off-screen at peek). + * - `'absolute'`: The footer floats over the content, pinned to the bottom + * edge. Its height is excluded from the `auto` detent calculation but + * included in the `peek` detent. + * + * @default 'relative' + */ + position?: 'relative' | 'absolute'; + /** * Adjusts how far the footer rises when the keyboard opens. * Positive values raise it higher; negative values reduce the rise. diff --git a/src/TrueSheet.web.tsx b/src/TrueSheet.web.tsx index 99687054..f6248c7b 100644 --- a/src/TrueSheet.web.tsx +++ b/src/TrueSheet.web.tsx @@ -89,6 +89,7 @@ const TrueSheetComponent = forwardRef((props, headerOptions, footer, footerStyle, + footerOptions, presentation = 'page', detached = false, detachedOffset = DEFAULT_DETACHED_OFFSET, @@ -280,9 +281,18 @@ const TrueSheetComponent = forwardRef((props, const resolvedHeaderStyle = absoluteHeader ? [absoluteHeaderStyle, headerStyle] : headerStyle; + // Same for an absolute (floating) footer — rendered via vaul's + // `detachedSiblings` instead of in the content flow. + const absoluteFooter = footerOptions?.position === 'absolute'; + const absoluteFooterRef = useRef(absoluteFooter); + absoluteFooterRef.current = absoluteFooter; + + // A relative footer is laid out below the content, so it's pushed off-screen + // at the peek detent and contributes no height. const peekHeight = - (header ? headerHeight : 0) + (footer ? footerHeight : 0) + peekContentHeight || - DEFAULT_PEEK_HEIGHT; + (header ? headerHeight : 0) + + (footer && absoluteFooter ? footerHeight : 0) + + peekContentHeight || DEFAULT_PEEK_HEIGHT; // Web mirror of native's scrollable handling for 'auto' detents: a plugged // scrollable keeps the sized (bounded) layout so its viewport is capped to @@ -293,16 +303,20 @@ const TrueSheetComponent = forwardRef((props, const [scrollableAutoHeight, setScrollableAutoHeight] = useState(0); const pinnedScrollerRef = useRef(null); - // Natural content height (header + content, with a pinned scrollable's - // viewport replaced by its content size) — the height the content wants - // regardless of the sheet's bounds. + // Natural content height (header + content + footer, with a pinned + // scrollable's viewport replaced by its content size) — the height the + // content wants regardless of the sheet's bounds. const measureNaturalHeight = useCallback(() => { const contentEl = contentRef.current as unknown as HTMLElement | null; if (!contentEl || !contentEl.isConnected) return 0; const headerEl = absoluteHeaderRef.current ? null : (headerElRef.current as unknown as HTMLElement | null); - let height = (headerEl?.offsetHeight ?? 0) + contentEl.offsetHeight; + const footerEl = absoluteFooterRef.current + ? null + : (footerElRef.current as unknown as HTMLElement | null); + let height = + (headerEl?.offsetHeight ?? 0) + (footerEl?.offsetHeight ?? 0) + contentEl.offsetHeight; const scroller = pinnedScrollerRef.current; const scrollContent = scroller?.firstElementChild; if (scroller?.isConnected && scrollContent instanceof HTMLElement) { @@ -489,7 +503,10 @@ const TrueSheetComponent = forwardRef((props, // Geometry only runs while the drawer is mounted, so the elements are // measurable here; fall back to the state value when they're absent. const headerEl = headerElRef.current as unknown as HTMLElement | null; - const footerEl = footerElRef.current as unknown as HTMLElement | null; + // A relative footer is pushed off-screen at the peek detent — excluded + const footerEl = absoluteFooterRef.current + ? (footerElRef.current as unknown as HTMLElement | null) + : null; const peekEl = peekElRef.current as unknown as HTMLElement | null; const contentEl = contentRef.current as unknown as HTMLElement | null; const livePeekContentHeight = @@ -1156,7 +1173,7 @@ const TrueSheetComponent = forwardRef((props, style={mergedContentStyle} onPointerDownOutside={handlePointerDownOutside} detachedSiblings={ - footer ? ( + footer && absoluteFooter ? (
{isValidElement(footer) ? footer : createElement(footer)} @@ -1194,6 +1211,15 @@ const TrueSheetComponent = forwardRef((props, > {children} + {footer && !absoluteFooter && ( + + {isValidElement(footer) ? footer : createElement(footer)} + + )}
) : ( // Natural flow so vaul can measure content height — required for @@ -1207,6 +1233,11 @@ const TrueSheetComponent = forwardRef((props, {children} + {footer && !absoluteFooter && ( + + {isValidElement(footer) ? footer : createElement(footer)} + + )} )} @@ -1222,6 +1253,11 @@ const overlayStyle: React.CSSProperties = { backgroundColor: 'rgba(0, 0, 0, 0.5)', }; +// Pinned to the sheet's bottom edge — takes up layout space below the content +const relativeFooterStyle = { + marginTop: 'auto', +} as const; + // Floats over the content so it doesn't take up layout space const absoluteHeaderStyle = { position: 'absolute', diff --git a/src/fabric/TrueSheetViewNativeComponent.ts b/src/fabric/TrueSheetViewNativeComponent.ts index 8ec87e6f..d399eefa 100644 --- a/src/fabric/TrueSheetViewNativeComponent.ts +++ b/src/fabric/TrueSheetViewNativeComponent.ts @@ -31,6 +31,9 @@ type ScrollableOptionsType = Readonly<{ }>; type FooterOptionsType = Readonly<{ + // Named uniquely across option structs — codegen derives the enum name from + // the field name, so a second `position` would redefine TrueSheetViewPosition + footerPosition?: WithDefault<'relative' | 'absolute', 'relative'>; keyboardOffset?: WithDefault; }>; From d6f63a6797a940237dffb852a73b9155c0008580 Mon Sep 17 00:00:00 2001 From: Jovanni Lo Date: Sat, 18 Jul 2026 07:12:11 +0800 Subject: [PATCH 08/20] feat: relative footer owns the bottom inset for auto detent (#749) * feat: relative footer owns the bottom inset for auto detent * docs: changelog --- CHANGELOG.md | 1 + .../core/TrueSheetDetentCalculator.kt | 9 ++++++- .../src/components/sheets/BasicSheet.tsx | 7 +++--- ios/TrueSheetViewController.mm | 25 ++++++++++++++++++- 4 files changed, 36 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fa198a4f..6c2dfd5c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ - **iOS**: `onPositionChange` now tracks the sheet's actual on-screen position everywhere — detent snaps, programmatic resizes, and sheets moving behind a child sheet emit realtime frames from a single native tracker (previously approximated with a JS-side spring or only emitted once settled). `onDetentChange` for programmatic resizes now fires when the animation actually ends instead of after a fixed delay. ([#744](https://github.com/lodev09/react-native-true-sheet/pull/744) by [@lodev09](https://github.com/lodev09)) - The `auto` detent now works with plugged scrollables — the sheet sizes to the scrollable's content height and resizes as content grows or shrinks. ([#743](https://github.com/lodev09/react-native-true-sheet/pull/743) by [@lodev09](https://github.com/lodev09)) - New `headerOptions` prop with a `position` option — set to `'absolute'` to float the header over the content (pinned to the top edge) and exclude it from the `auto` detent height. ([#747](https://github.com/lodev09/react-native-true-sheet/pull/747) by [@lodev09](https://github.com/lodev09)) +- A relative footer now owns the bottom safe-area inset at the `auto` detent — the inset is no longer added to the auto height, so the footer sits flush at the sheet's bottom edge (pad it yourself, e.g. for the home indicator). ([#749](https://github.com/lodev09/react-native-true-sheet/pull/749) by [@lodev09](https://github.com/lodev09)) ### 💥 Breaking changes 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 c62aa0c7..3c61bf33 100644 --- a/android/src/main/java/com/lodev09/truesheet/core/TrueSheetDetentCalculator.kt +++ b/android/src/main/java/com/lodev09/truesheet/core/TrueSheetDetentCalculator.kt @@ -52,6 +52,13 @@ class TrueSheetDetentCalculator(private val reactContext: ThemedReactContext) { (if (delegate?.absoluteHeader == true) 0 else headerHeight) + (if (delegate?.absoluteFooter == true) 0 else footerHeight) + /** + * Bottom inset for auto detents. A relative footer owns the sheet's bottom + * edge, so it absorbs the inset instead of adding it to the auto height. + */ + private val autoDetentBottomInset: Int + get() = if (footerHeight > 0 && delegate?.absoluteFooter == false) 0 else contentBottomInset + /** * Height for peek (-2.0) detents: header + footer + peek content height. * A relative footer is pushed off-screen at peek, so it contributes no height. @@ -71,7 +78,7 @@ class TrueSheetDetentCalculator(private val reactContext: ThemedReactContext) { */ fun getDetentHeight(detent: Double, includeKeyboard: Boolean = true): Int { val baseHeight = if (detent == -1.0) { - autoDetentHeight + contentBottomInset + autoDetentHeight + autoDetentBottomInset } else if (detent == -2.0) { peekDetentHeight + contentBottomInset } else { diff --git a/example/shared/src/components/sheets/BasicSheet.tsx b/example/shared/src/components/sheets/BasicSheet.tsx index 76575953..b5378d4a 100644 --- a/example/shared/src/components/sheets/BasicSheet.tsx +++ b/example/shared/src/components/sheets/BasicSheet.tsx @@ -7,7 +7,7 @@ import { type TrueSheetProps, } from '@lodev09/react-native-true-sheet'; -import { BLUE, DARK, DARK_BLUE, FOOTER_HEIGHT, GAP, SPACING, times } from '../../utils'; +import { BLUE, DARK, DARK_BLUE, GAP, SPACING, times } from '../../utils'; import { DemoContent } from '../DemoContent'; import { Footer } from '../Footer'; import { Button } from '../Button'; @@ -165,12 +165,11 @@ export const BasicSheet = forwardRef((props: BasicSheetProps, ref: Ref_autoDetentHeight; + CGFloat height = self->_autoDetentHeight; + return height - [self autoDetentBottomInsetForHeight:height]; }]; } else { return [UISheetPresentationControllerDetent mediumDetent]; From cea41fa0108e009f7c05916c9fea8c7f3c1e02d2 Mon Sep 17 00:00:00 2001 From: Jovanni Lo Date: Sun, 19 Jul 2026 02:50:18 +0800 Subject: [PATCH 09/20] feat: footer owns the bottom inset adjustment (#750) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: footer owns the bottom inset adjustment A relative footer now absorbs the bottom safe-area inset as padding via a custom shadow node (state-driven, like the content view) — its background fills the inset and the auto height stays consistent with or without a footer. Applied on iOS, Android, and Web. * fix(android): apply footer inset padding synchronously The async state update raced setupSheetDetents — the behavior could be configured against the unpadded footer height, landing the auto detent an inset short. Bridge the footer state through TrueSheetStateUpdater (unstable_Immediate) so the footer is padded in the same frame. * feat: footer owns the inset for absolute position too The footer absorbs the bottom safe-area inset whenever insetAdjustment is automatic, regardless of position. The inset is skipped while the keyboard is open — the footer hugs the keyboard with no gap. Peek detents exclude the inset when an absolute footer carries it, avoiding a double count. * chore(example): set footer background via footerStyle The native footer absorbs the bottom inset, so the background must be on the footer component itself to fill it. Drops the manual inset padding left in GestureSheet. * docs: update changelog --- CHANGELOG.md | 3 +- .../truesheet/TrueSheetContainerView.kt | 11 ++++ .../lodev09/truesheet/TrueSheetFooterView.kt | 41 +++++++++++++++ .../truesheet/TrueSheetFooterViewManager.kt | 7 +++ .../truesheet/TrueSheetStateUpdater.kt | 7 +++ .../com/lodev09/truesheet/TrueSheetView.kt | 1 + .../truesheet/TrueSheetViewController.kt | 6 +++ .../core/TrueSheetDetentCalculator.kt | 9 +++- android/src/main/jni/TrueSheetSpec.h | 1 + .../src/main/jni/TrueSheetStateUpdater.cpp | 45 +++++++++++++--- .../TrueSheetFooterViewComponentDescriptor.h | 24 +++++++++ .../TrueSheetFooterViewShadowNode.cpp | 46 +++++++++++++++++ .../TrueSheetFooterViewShadowNode.h | 28 ++++++++++ .../TrueSheetFooterViewState.cpp | 11 ++++ .../TrueSheetSpec/TrueSheetFooterViewState.h | 37 ++++++++++++++ docs/docs/guides/footer.mdx | 26 +++------- docs/docs/guides/keyboard.mdx | 10 ++-- docs/docs/migration.mdx | 30 +++-------- docs/docs/reference/04-types.mdx | 4 +- example/shared/src/components/Footer.tsx | 49 +++++------------- .../src/components/sheets/BasicSheet.tsx | 7 ++- .../src/components/sheets/FlatListSheet.tsx | 9 +--- .../src/components/sheets/GestureSheet.tsx | 8 +-- .../src/components/sheets/PromptSheet.tsx | 7 +-- .../src/components/sheets/ScrollViewSheet.tsx | 3 +- ios/TrueSheetContainerView.h | 6 +++ ios/TrueSheetContainerView.mm | 6 +++ ios/TrueSheetFooterView.h | 6 +++ ios/TrueSheetFooterView.mm | 51 ++++++++++++++++++- ios/TrueSheetView.mm | 1 + ios/TrueSheetViewController.h | 6 +++ ios/TrueSheetViewController.mm | 31 ++++++++++- ios/tvos/TrueSheetStubs.mm | 1 + react-native.config.js | 1 + src/TrueSheet.web.tsx | 38 +++++++++++--- .../TrueSheetFooterViewNativeComponent.ts | 6 ++- 36 files changed, 459 insertions(+), 124 deletions(-) create mode 100644 common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetFooterViewComponentDescriptor.h create mode 100644 common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetFooterViewShadowNode.cpp create mode 100644 common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetFooterViewShadowNode.h create mode 100644 common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetFooterViewState.cpp create mode 100644 common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetFooterViewState.h diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c2dfd5c..1ff81c60 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,8 @@ - **iOS**: `onPositionChange` now tracks the sheet's actual on-screen position everywhere — detent snaps, programmatic resizes, and sheets moving behind a child sheet emit realtime frames from a single native tracker (previously approximated with a JS-side spring or only emitted once settled). `onDetentChange` for programmatic resizes now fires when the animation actually ends instead of after a fixed delay. ([#744](https://github.com/lodev09/react-native-true-sheet/pull/744) by [@lodev09](https://github.com/lodev09)) - The `auto` detent now works with plugged scrollables — the sheet sizes to the scrollable's content height and resizes as content grows or shrinks. ([#743](https://github.com/lodev09/react-native-true-sheet/pull/743) by [@lodev09](https://github.com/lodev09)) - New `headerOptions` prop with a `position` option — set to `'absolute'` to float the header over the content (pinned to the top edge) and exclude it from the `auto` detent height. ([#747](https://github.com/lodev09/react-native-true-sheet/pull/747) by [@lodev09](https://github.com/lodev09)) -- A relative footer now owns the bottom safe-area inset at the `auto` detent — the inset is no longer added to the auto height, so the footer sits flush at the sheet's bottom edge (pad it yourself, e.g. for the home indicator). ([#749](https://github.com/lodev09/react-native-true-sheet/pull/749) by [@lodev09](https://github.com/lodev09)) +- A relative footer now owns the bottom safe-area inset at the `auto` detent — the inset is no longer added to the auto height, so the footer sits flush at the sheet's bottom edge. ([#749](https://github.com/lodev09/react-native-true-sheet/pull/749) by [@lodev09](https://github.com/lodev09)) +- The footer now absorbs the bottom safe-area inset as padding when `insetAdjustment` is `automatic`, regardless of `footerOptions.position` — content stays above the home indicator while the footer's background fills the inset, so no manual safe-area padding is needed. The inset is skipped while the keyboard is open. ([#750](https://github.com/lodev09/react-native-true-sheet/pull/750) by [@lodev09](https://github.com/lodev09)) ### 💥 Breaking changes diff --git a/android/src/main/java/com/lodev09/truesheet/TrueSheetContainerView.kt b/android/src/main/java/com/lodev09/truesheet/TrueSheetContainerView.kt index 56061285..eebc1ffd 100644 --- a/android/src/main/java/com/lodev09/truesheet/TrueSheetContainerView.kt +++ b/android/src/main/java/com/lodev09/truesheet/TrueSheetContainerView.kt @@ -63,6 +63,16 @@ class TrueSheetContainerView(reactContext: ThemedReactContext) : var insetAdjustment: TrueSheetInsetAdjustment = TrueSheetInsetAdjustment.AUTOMATIC var scrollViewBottomInset: Int = 0 + + /** + * Bottom safe-area inset the footer absorbs as padding — the footer + * owns the sheet's bottom edge, so its background fills the inset. + */ + var footerBottomInset: Int = 0 + set(value) { + field = value + footerView?.setBottomInset(value) + } var scrollableOptions: ScrollableOptions? = null set(value) { field = value @@ -119,6 +129,7 @@ class TrueSheetContainerView(reactContext: ThemedReactContext) : is TrueSheetFooterView -> { child.delegate = this + child.setBottomInset(footerBottomInset) footerView = child } } diff --git a/android/src/main/java/com/lodev09/truesheet/TrueSheetFooterView.kt b/android/src/main/java/com/lodev09/truesheet/TrueSheetFooterView.kt index c0f91086..1cd6116e 100644 --- a/android/src/main/java/com/lodev09/truesheet/TrueSheetFooterView.kt +++ b/android/src/main/java/com/lodev09/truesheet/TrueSheetFooterView.kt @@ -4,10 +4,13 @@ import android.annotation.SuppressLint import android.view.MotionEvent import android.view.View import android.view.ViewParent +import com.facebook.react.bridge.WritableNativeMap import com.facebook.react.uimanager.JSPointerDispatcher import com.facebook.react.uimanager.JSTouchDispatcher +import com.facebook.react.uimanager.PixelUtil.pxToDp import com.facebook.react.uimanager.PointerEvents import com.facebook.react.uimanager.RootView +import com.facebook.react.uimanager.StateWrapper import com.facebook.react.uimanager.ThemedReactContext import com.facebook.react.uimanager.events.EventDispatcher import com.facebook.react.views.view.ReactViewGroup @@ -34,12 +37,50 @@ class TrueSheetFooterView(private val reactContext: ThemedReactContext) : RootView { var delegate: TrueSheetFooterViewDelegate? = null + var stateWrapper: StateWrapper? = null private val eventDispatcher: EventDispatcher? get() = delegate?.eventDispatcher private var lastWidth = 0 private var lastHeight = 0 + private var bottomInset = 0 + + /** + * Skips the inset while the keyboard is open — the footer rises above the + * keyboard, so the inset would leave a gap. + */ + var keyboardVisible = false + set(value) { + if (field == value) return + field = value + pushBottomInsetState() + } + + /** + * Tells the shadow node to pad the footer's bottom edge with the sheet's + * bottom safe-area inset — the footer owns the sheet's bottom edge, so it + * absorbs the inset and its background fills it. + */ + fun setBottomInset(inset: Int) { + if (bottomInset == inset) return + bottomInset = inset + pushBottomInsetState() + } + + private fun pushBottomInsetState() { + val sw = stateWrapper ?: return + val insetDp = (if (keyboardVisible) 0 else bottomInset).toFloat().pxToDp() + + // Synchronous update — the footer must be padded before detents are + // configured, otherwise the auto detent is set up an inset short + if (TrueSheetStateUpdater.updateFooterState(sw, insetDp)) return + + // Fallback: async state update + val newState = WritableNativeMap() + newState.putDouble("bottomInset", insetDp.toDouble()) + sw.updateState(newState) + } private val jsTouchDispatcher = JSTouchDispatcher(this) private var jsPointerDispatcher: JSPointerDispatcher? = null diff --git a/android/src/main/java/com/lodev09/truesheet/TrueSheetFooterViewManager.kt b/android/src/main/java/com/lodev09/truesheet/TrueSheetFooterViewManager.kt index aedf0a32..3f734f34 100644 --- a/android/src/main/java/com/lodev09/truesheet/TrueSheetFooterViewManager.kt +++ b/android/src/main/java/com/lodev09/truesheet/TrueSheetFooterViewManager.kt @@ -2,6 +2,8 @@ package com.lodev09.truesheet import com.facebook.react.module.annotations.ReactModule import com.facebook.react.uimanager.PointerEvents +import com.facebook.react.uimanager.ReactStylesDiffMap +import com.facebook.react.uimanager.StateWrapper import com.facebook.react.uimanager.ThemedReactContext import com.facebook.react.uimanager.ViewGroupManager import com.facebook.react.uimanager.annotations.ReactProp @@ -17,6 +19,11 @@ class TrueSheetFooterViewManager : ViewGroupManager() { override fun createViewInstance(reactContext: ThemedReactContext): TrueSheetFooterView = TrueSheetFooterView(reactContext) + override fun updateState(view: TrueSheetFooterView, props: ReactStylesDiffMap?, stateWrapper: StateWrapper?): Any? { + view.stateWrapper = stateWrapper + return null + } + @ReactProp(name = "pointerEvents") fun setPointerEvents(view: TrueSheetFooterView, pointerEventsStr: String?) { view.pointerEvents = PointerEvents.parsePointerEvents(pointerEventsStr) diff --git a/android/src/main/java/com/lodev09/truesheet/TrueSheetStateUpdater.kt b/android/src/main/java/com/lodev09/truesheet/TrueSheetStateUpdater.kt index 8dbe5986..c1fd66e7 100644 --- a/android/src/main/java/com/lodev09/truesheet/TrueSheetStateUpdater.kt +++ b/android/src/main/java/com/lodev09/truesheet/TrueSheetStateUpdater.kt @@ -22,6 +22,13 @@ object TrueSheetStateUpdater { fun updateState(stateWrapper: StateWrapper, widthDp: Float, heightDp: Float): Boolean = isAvailable && nativeUpdateState(stateWrapper, widthDp, heightDp) + /** Returns false when unavailable — caller should fall back to async updateState. */ + fun updateFooterState(stateWrapper: StateWrapper, bottomInsetDp: Float): Boolean = + isAvailable && nativeUpdateFooterState(stateWrapper, bottomInsetDp) + @JvmStatic private external fun nativeUpdateState(stateWrapper: Any, width: Float, height: Float): Boolean + + @JvmStatic + private external fun nativeUpdateFooterState(stateWrapper: Any, bottomInset: Float): Boolean } diff --git a/android/src/main/java/com/lodev09/truesheet/TrueSheetView.kt b/android/src/main/java/com/lodev09/truesheet/TrueSheetView.kt index 2a41c540..adc7f676 100644 --- a/android/src/main/java/com/lodev09/truesheet/TrueSheetView.kt +++ b/android/src/main/java/com/lodev09/truesheet/TrueSheetView.kt @@ -299,6 +299,7 @@ class TrueSheetView(private val reactContext: ThemedReactContext) : it.scrollViewBottomInset = viewController.contentBottomInset it.scrollableOptions = viewController.scrollableOptions it.hasAutoDetent = viewController.detents.contains(-1.0) + it.footerBottomInset = viewController.contentBottomInset it.setupScrollable() } } diff --git a/android/src/main/java/com/lodev09/truesheet/TrueSheetViewController.kt b/android/src/main/java/com/lodev09/truesheet/TrueSheetViewController.kt index bbce3ee2..3b5e9100 100644 --- a/android/src/main/java/com/lodev09/truesheet/TrueSheetViewController.kt +++ b/android/src/main/java/com/lodev09/truesheet/TrueSheetViewController.kt @@ -1057,6 +1057,9 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) : delegate = object : TrueSheetKeyboardObserverDelegate { override fun keyboardWillShow(height: Int) { if (!shouldHandleKeyboard()) return + // The footer rises above the keyboard, so it drops the bottom inset + // padding — before detents are configured against its height + containerView?.footerView?.keyboardVisible = true // If a resize is in flight, restore to its target — not the stale current detentIndexBeforeKeyboard = if (pendingDetentIndex >= 0) pendingDetentIndex else currentDetentIndex pendingDetentIndex = -1 @@ -1068,6 +1071,7 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) : override fun keyboardWillHide() { if (!shouldHandleKeyboard(checkFocus = false)) return + containerView?.footerView?.keyboardVisible = false val restoring = !isBeingDismissed && detentIndexBeforeKeyboard >= 0 // Skip reconfigure during interactive keyboard dismiss (e.g. keyboardDismissMode="on-drag") @@ -1088,6 +1092,7 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) : override fun keyboardDidHide() { if (!shouldHandleKeyboard(checkFocus = false)) return + containerView?.footerView?.keyboardVisible = false detentIndexBeforeKeyboard = -1 isKeyboardDismissProgrammatic = false setupSheetDetents(applyState = false) @@ -1109,6 +1114,7 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) : // Handle case where keyboard is already visible and focus moves into the sheet if (!shouldHandleKeyboard()) return if (detentIndexBeforeKeyboard < 0 && (keyboardObserver?.currentHeight ?: 0) > 0) { + containerView?.footerView?.keyboardVisible = true detentIndexBeforeKeyboard = currentDetentIndex currentDetentIndex = detents.size - 1 setupSheetDetents() 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 3c61bf33..241a265b 100644 --- a/android/src/main/java/com/lodev09/truesheet/core/TrueSheetDetentCalculator.kt +++ b/android/src/main/java/com/lodev09/truesheet/core/TrueSheetDetentCalculator.kt @@ -59,6 +59,13 @@ class TrueSheetDetentCalculator(private val reactContext: ThemedReactContext) { private val autoDetentBottomInset: Int get() = if (footerHeight > 0 && delegate?.absoluteFooter == false) 0 else contentBottomInset + /** + * Bottom inset for peek detents. An absolute footer counts toward the peek + * height and absorbs the inset, so it isn't added again. + */ + private val peekDetentBottomInset: Int + get() = if (footerHeight > 0 && delegate?.absoluteFooter == true) 0 else contentBottomInset + /** * Height for peek (-2.0) detents: header + footer + peek content height. * A relative footer is pushed off-screen at peek, so it contributes no height. @@ -80,7 +87,7 @@ class TrueSheetDetentCalculator(private val reactContext: ThemedReactContext) { val baseHeight = if (detent == -1.0) { autoDetentHeight + autoDetentBottomInset } else if (detent == -2.0) { - peekDetentHeight + contentBottomInset + peekDetentHeight + peekDetentBottomInset } else { if (detent <= 0.0 || detent > 1.0) { throw IllegalArgumentException("TrueSheet: detent fraction ($detent) must be between 0 and 1") diff --git a/android/src/main/jni/TrueSheetSpec.h b/android/src/main/jni/TrueSheetSpec.h index c1d52ce5..aa9068f2 100644 --- a/android/src/main/jni/TrueSheetSpec.h +++ b/android/src/main/jni/TrueSheetSpec.h @@ -4,6 +4,7 @@ #include #include #include +#include #include namespace facebook { diff --git a/android/src/main/jni/TrueSheetStateUpdater.cpp b/android/src/main/jni/TrueSheetStateUpdater.cpp index 1eb923e5..13c4e2a5 100644 --- a/android/src/main/jni/TrueSheetStateUpdater.cpp +++ b/android/src/main/jni/TrueSheetStateUpdater.cpp @@ -8,11 +8,23 @@ #include #include +#include #include #include namespace facebook::react { +static std::shared_ptr getStateFromWrapper(jni::alias_ref stateWrapper) { + static const auto stateWrapperImplClass = + jni::findClassStatic(StateWrapperImpl::StateWrapperImplJavaDescriptor); + if (!stateWrapper->isInstanceOf(stateWrapperImplClass)) { + return nullptr; + } + + auto impl = jni::static_ref_cast(stateWrapper); + return impl->cthis()->getState(); +} + // Kotlin's StateWrapper.updateState is hardcoded async. This bridges to // ConcreteState::updateState with unstable_Immediate so the commit — and, when // called from the UI thread, the mount — run synchronously, letting Yoga resize @@ -22,14 +34,7 @@ static jboolean updateStateImmediate( jni::alias_ref stateWrapper, jfloat containerWidth, jfloat containerHeight) { - static const auto stateWrapperImplClass = - jni::findClassStatic(StateWrapperImpl::StateWrapperImplJavaDescriptor); - if (!stateWrapper->isInstanceOf(stateWrapperImplClass)) { - return JNI_FALSE; - } - - auto impl = jni::static_ref_cast(stateWrapper); - auto state = impl->cthis()->getState(); + auto state = getStateFromWrapper(stateWrapper); if (!state) { return JNI_FALSE; } @@ -44,6 +49,29 @@ static jboolean updateStateImmediate( return JNI_TRUE; } +// Same synchronous bridge for the footer's bottom inset — the footer must be +// padded (and resized) before detents are configured, otherwise the behavior +// is set up against the unpadded footer height and the auto detent lands an +// inset short. +static jboolean updateFooterStateImmediate( + jni::alias_ref /*clazz*/, + jni::alias_ref stateWrapper, + jfloat bottomInset) { + auto state = getStateFromWrapper(stateWrapper); + if (!state) { + return JNI_FALSE; + } + + auto concreteState = + std::static_pointer_cast>(state); + + TrueSheetFooterViewState newState{}; + newState.bottomInset = bottomInset; + concreteState->updateState(std::move(newState), EventQueue::UpdateMode::unstable_Immediate); + + return JNI_TRUE; +} + } // namespace facebook::react JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM *vm, void * /*reserved*/) { @@ -51,6 +79,7 @@ JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM *vm, void * /*reserved*/) { facebook::jni::findClassStatic("com/lodev09/truesheet/TrueSheetStateUpdater") ->registerNatives({ makeNativeMethod("nativeUpdateState", facebook::react::updateStateImmediate), + makeNativeMethod("nativeUpdateFooterState", facebook::react::updateFooterStateImmediate), }); }); } diff --git a/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetFooterViewComponentDescriptor.h b/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetFooterViewComponentDescriptor.h new file mode 100644 index 00000000..2d81f202 --- /dev/null +++ b/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetFooterViewComponentDescriptor.h @@ -0,0 +1,24 @@ +#pragma once + +#include +#include + +namespace facebook::react { + +/* + * Descriptor for component. + */ +class TrueSheetFooterViewComponentDescriptor final + : public ConcreteComponentDescriptor { + using ConcreteComponentDescriptor::ConcreteComponentDescriptor; + + void adopt(ShadowNode &shadowNode) const override { + auto &concreteShadowNode = + static_cast(shadowNode); + concreteShadowNode.adjustLayoutWithState(); + + ConcreteComponentDescriptor::adopt(shadowNode); + } +}; + +} // namespace facebook::react diff --git a/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetFooterViewShadowNode.cpp b/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetFooterViewShadowNode.cpp new file mode 100644 index 00000000..4a327e26 --- /dev/null +++ b/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetFooterViewShadowNode.cpp @@ -0,0 +1,46 @@ +#include "TrueSheetFooterViewShadowNode.h" + +#include + +namespace facebook::react { + +using namespace yoga; + +extern const char TrueSheetFooterViewComponentName[] = "TrueSheetFooterView"; + +void TrueSheetFooterViewShadowNode::adjustLayoutWithState() { + ensureUnsealed(); + + auto state = std::static_pointer_cast< + const TrueSheetFooterViewShadowNode::ConcreteState>(getState()); + auto stateData = state->getData(); + + auto &props = getConcreteProps(); + + // A footer pinned to the sheet's bottom edge owns the bottom safe-area + // inset — pad it on top of any style padding so its content clears the + // home indicator while the background fills the inset. + auto padding = props.yogaStyle.padding(Edge::Bottom); + if (stateData.bottomInset > 0) { + // Base bottom padding from the style — Edge::Bottom wins over + // Vertical/All, mirroring Yoga's own resolution. Points only. + float base = 0; + for (auto edge : {Edge::Bottom, Edge::Vertical, Edge::All}) { + auto length = props.yogaStyle.padding(edge); + if (length.isDefined()) { + base = length.isPoints() ? length.value().unwrap() : 0; + break; + } + } + padding = StyleLength::points(base + stateData.bottomInset); + } + + if (yogaNode_.style().padding(Edge::Bottom) != padding) { + yoga::Style adjustedStyle = props.yogaStyle; + adjustedStyle.setPadding(Edge::Bottom, padding); + yogaNode_.setStyle(adjustedStyle); + yogaNode_.setDirty(true); + } +} + +} // namespace facebook::react diff --git a/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetFooterViewShadowNode.h b/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetFooterViewShadowNode.h new file mode 100644 index 00000000..758ceeeb --- /dev/null +++ b/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetFooterViewShadowNode.h @@ -0,0 +1,28 @@ +#pragma once + +#include +#include +#include +#include +#include + +namespace facebook::react { + +JSI_EXPORT extern const char TrueSheetFooterViewComponentName[]; + +/* + * `ShadowNode` for component. + */ +class JSI_EXPORT TrueSheetFooterViewShadowNode final + : public ConcreteViewShadowNode< + TrueSheetFooterViewComponentName, + TrueSheetFooterViewProps, + TrueSheetFooterViewEventEmitter, + TrueSheetFooterViewState> { + using ConcreteViewShadowNode::ConcreteViewShadowNode; + + public: + void adjustLayoutWithState(); +}; + +} // namespace facebook::react diff --git a/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetFooterViewState.cpp b/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetFooterViewState.cpp new file mode 100644 index 00000000..e447e61b --- /dev/null +++ b/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetFooterViewState.cpp @@ -0,0 +1,11 @@ +#include "TrueSheetFooterViewState.h" + +namespace facebook::react { + +#ifdef ANDROID +folly::dynamic TrueSheetFooterViewState::getDynamic() const { + return folly::dynamic::object("bottomInset", bottomInset); +} +#endif + +} // namespace facebook::react diff --git a/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetFooterViewState.h b/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetFooterViewState.h new file mode 100644 index 00000000..b7ce504b --- /dev/null +++ b/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetFooterViewState.h @@ -0,0 +1,37 @@ +#pragma once + +#ifdef ANDROID +#include +#include +#include +#endif + +namespace facebook::react { + +/* + * State for component. + * Set from native with the sheet's bottom safe-area inset so the shadow node + * pads the footer's bottom edge (see TrueSheetFooterViewShadowNode). + */ +class TrueSheetFooterViewState final { + public: + TrueSheetFooterViewState() = default; + +#ifdef ANDROID + TrueSheetFooterViewState( + TrueSheetFooterViewState const &previousState, + folly::dynamic data) + : bottomInset(static_cast(data["bottomInset"].getDouble())) {} +#endif + + float bottomInset{0}; + +#ifdef ANDROID + folly::dynamic getDynamic() const; + MapBuffer getMapBuffer() const { + return MapBufferBuilder::EMPTY(); + } +#endif +}; + +} // namespace facebook::react diff --git a/docs/docs/guides/footer.mdx b/docs/docs/guides/footer.mdx index 353750bd..2c9d2e38 100644 --- a/docs/docs/guides/footer.mdx +++ b/docs/docs/guides/footer.mdx @@ -57,44 +57,34 @@ const App = () => { ## Safe Area Handling -The footer is pinned to the bottom edge of the sheet. The sheet height automatically includes the bottom safe area on both iOS and Android. To ensure your footer extends properly into the safe area, add bottom padding: +The footer owns the sheet's bottom edge, so it automatically absorbs the bottom safe-area inset as padding when [`insetAdjustment`](../reference/configuration#insetadjustment) is `"automatic"` (the default). Your footer content stays above the home indicator while its background fills the inset — no manual padding needed. ```tsx -import { Platform } from 'react-native'; -import { useSafeAreaInsets } from 'react-native-safe-area-context'; - -const isIPad = Platform.OS === 'ios' && Platform.isPad; - const MyFooter = () => { - const insets = useSafeAreaInsets(); - const bottomInset = isIPad ? 0 : insets.bottom; - return ( - - - Footer Content - + + Footer Content ); }; // Usage -}> +} footerStyle={{ backgroundColor: '#333' }}> {/* content */} ``` -This ensures the footer background extends into the safe area while keeping the content above the home indicator. +The inset is applied to the footer component itself, so set your background via [`footerStyle`](../reference/configuration#footerstyle) (or on your footer's root view) for it to fill the safe area. -When the keyboard opens, that bottom padding leaves a gap above the keyboard. Pass `-insets.bottom` to [`footerOptions.keyboardOffset`](../reference/configuration#footeroptions) to tuck it behind the keyboard — see [Keyboard Handling](./keyboard#footer-behavior). +When the keyboard opens, the footer rises above it and the inset is skipped automatically — no gap above the keyboard. See [Keyboard Handling](./keyboard#footer-behavior). :::note -On iPad, the sheet is displayed as a floating modal, so bottom padding is not needed. +On iPad, the sheet is displayed as a floating modal, so no inset is applied. Set `insetAdjustment="never"` to opt out entirely. ::: ## Floating Footer -By default, the footer takes up space below the content and its height is included in the [`auto`](../reference/types#sheetdetent) detent calculation. Set `footerOptions.position` to `'absolute'` to float the footer over the content instead — it stays pinned to the bottom edge but no longer adds to the `auto` detent height. +By default, the footer takes up space below the content and its height is included in the [`auto`](../reference/types#sheetdetent) detent calculation. Set `footerOptions.position` to `'absolute'` to float the footer over the content instead — it stays pinned to the bottom edge but no longer adds to the `auto` detent height. An absolute footer absorbs the bottom safe-area inset the same way a relative one does — see [Safe Area Handling](#safe-area-handling). ```tsx {5-5} const App = () => { diff --git a/docs/docs/guides/keyboard.mdx b/docs/docs/guides/keyboard.mdx index 7eff4ca3..313e0a42 100644 --- a/docs/docs/guides/keyboard.mdx +++ b/docs/docs/guides/keyboard.mdx @@ -56,17 +56,17 @@ This is especially useful for forms with multiple inputs — as the user taps be When using a [`footer`](../reference/configuration#footer) component, it automatically repositions above the keyboard, staying visible while the user types. -If your footer renders its own bottom inset (e.g. safe-area padding for the home indicator), that padding leaves a gap above the keyboard. Use `keyboardOffset` in [`footerOptions`](../reference/configuration#footeroptions) to adjust the rise — pass `-insets.bottom` to tuck the inset behind the keyboard: +The footer's built-in [safe-area inset](./footer#safe-area-handling) is skipped while the keyboard is open — the footer hugs the keyboard with no gap, and the inset is restored when the keyboard hides. -```tsx -const insets = useSafeAreaInsets() +If your footer renders its own bottom padding, that padding leaves a gap above the keyboard. Use `keyboardOffset` in [`footerOptions`](../reference/configuration#footeroptions) to adjust the rise — pass a negative value to tuck it behind the keyboard: -} footerOptions={{ keyboardOffset: -insets.bottom }}> +```tsx +} footerOptions={{ keyboardOffset: -16 }}> {/* ... */} ``` -The footer slides up by the keyboard height plus `keyboardOffset`. A negative value reduces the rise so the inset sits behind the keyboard; a positive value raises the footer higher. +The footer slides up by the keyboard height plus `keyboardOffset`. A negative value reduces the rise so the padding sits behind the keyboard; a positive value raises the footer higher. ## Autofocus Limitation diff --git a/docs/docs/migration.mdx b/docs/docs/migration.mdx index b5a5f525..5c112f7b 100644 --- a/docs/docs/migration.mdx +++ b/docs/docs/migration.mdx @@ -143,43 +143,29 @@ Both iOS and Android now handle bottom safe area insets natively for the sheet. - The `position`, `index`, and `detent` values are calculated purely from the actual sheet position relative to screen height - A sheet at detent `0.5` will be taller than `0.5 * screenHeight` because the system adds the bottom safe area internally -**Footer with auto detent:** +**Footer:** -The footer is pinned to the bottom edge of the sheet. When using an `auto` detent, the sheet height includes the safe area, so your footer needs to extend into it: +The footer is pinned to the bottom edge of the sheet and automatically absorbs the bottom safe-area inset as padding — its content stays above the home indicator while its background fills the inset. Remove any manual safe-area padding from your footer: ```tsx -import { Platform } from 'react-native'; -import { useSafeAreaInsets } from 'react-native-safe-area-context'; - -const isIPad = Platform.OS === 'ios' && Platform.isPad; - +// ❌ v2 — manual safe-area padding const MyFooter = () => { const insets = useSafeAreaInsets(); - const bottomInset = isIPad ? 0 : insets.bottom; - return ( - - - Footer Content - + + ); }; -// Usage -}> +// ✅ v3 — the footer absorbs the inset natively +} footerStyle={{ backgroundColor: '#333' }}> {/* content */} ``` -This ensures the footer background extends into the safe area while keeping the content above the home indicator. - -:::note -On iPad, the sheet is displayed as a floating modal, so bottom padding is not needed. -::: - :::tip -See the [Footer guide](guides/footer) for more details. +See the [Footer guide](guides/footer#safe-area-handling) for more details. ::: ### 6. Background and Blur Behavior Changes diff --git a/docs/docs/reference/04-types.mdx b/docs/docs/reference/04-types.mdx index eb323454..2fb84cce 100644 --- a/docs/docs/reference/04-types.mdx +++ b/docs/docs/reference/04-types.mdx @@ -128,7 +128,7 @@ Options for customizing footer behavior. footer={