diff --git a/.yarn/patches/react-native-npm-0.85.3.patch b/.yarn/patches/react-native-npm-0.85.3.patch deleted file mode 100644 index 0321acd7..00000000 --- a/.yarn/patches/react-native-npm-0.85.3.patch +++ /dev/null @@ -1,12 +0,0 @@ -diff --git a/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm b/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm ---- a/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm -+++ b/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm -@@ -1614,7 +1614,7 @@ - - - (BOOL)canBecomeFirstResponder - { -- return YES; -+ return ReactNativeFeatureFlags::enableImperativeFocus(); - } - - - (void)handleCommand:(const NSString *)commandName args:(const NSArray *)args diff --git a/AGENTS.md b/AGENTS.md index d7b501ec..2759b078 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -83,8 +83,11 @@ Add entry to `Unreleased` section in `CHANGELOG.md` for user-facing changes: - `🎉 New features` - New functionality - `🐛 Bug fixes` - Bug fixes +- `💥 Breaking changes` - Changes requiring user action to upgrade - `💡 Others` - Refactors, internal 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 3be07a68..3c507bfb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,31 @@ ## Unreleased +### 🎉 New features + +- **iOS**: `onPositionChange` now reports a sheet's live position while it moves behind a dragging child sheet. ([#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. ([#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)) + +### 🐛 Bug fixes + +- **iOS**: Smoother, more reliable `onPositionChange` — drags emit at the touch rate, transitions track at the display's native refresh rate (120Hz on ProMotion), and a sheet behind a presenting child animates to its collapsed position instead of jumping or going stale. `onWillDismiss` now fires only when a drag-to-dismiss is committed on release, not prematurely mid-drag. ([#756](https://github.com/lodev09/react-native-true-sheet/pull/756) by [@lodev09](https://github.com/lodev09)) +- **iOS**: A sheet stacked behind a child sheet no longer resizes its content in the background — UIKit's push-back scaling was reported as a size change, triggering spurious `onLayout`. ([#753](https://github.com/lodev09/react-native-true-sheet/pull/753) by [@lodev09](https://github.com/lodev09)) +- The keyboard-driven scroll inset now accounts for an absolute footer's height — focused inputs clear both the keyboard and the footer instead of hiding behind it. ([#752](https://github.com/lodev09/react-native-true-sheet/pull/752) by [@lodev09](https://github.com/lodev09)) +- A relative footer no longer floats above the keyboard — it stays in the layout flow behind it and keeps its safe-area inset. Only an absolute footer rises above the keyboard (`footerOptions.keyboardOffset` now only applies there). ([#754](https://github.com/lodev09/react-native-true-sheet/pull/754) by [@lodev09](https://github.com/lodev09)) + +### 💥 Breaking changes + +- The footer now lays out relative by default — it takes up space below the content (still pinned to the bottom edge) and its height is included in the `auto` detent calculation, but excluded from the `peek` detent (it's pushed off-screen at peek). Set the new `footerOptions.position` to `'absolute'` to restore the previous floating behavior. ([#748](https://github.com/lodev09/react-native-true-sheet/pull/748) by [@lodev09](https://github.com/lodev09)) +- Content now lays out naturally like a regular view or a react-navigation screen — it wraps its children's height by default instead of filling the sheet. Pass `flex: 1` via `style` to fill, and bound scrollables the same way (except `auto` detents, which stay auto-sized). ([#746](https://github.com/lodev09/react-native-true-sheet/pull/746) 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)) + +### 💡 Others + +- Upgrade the examples to Expo SDK 57 and React Native 0.86. ([#755](https://github.com/lodev09/react-native-true-sheet/pull/755) by [@lodev09](https://github.com/lodev09)) + ## 3.11.9 ### 🐛 Bug fixes diff --git a/README.md b/README.md index 519c4b61..867da709 100644 --- a/README.md +++ b/README.md @@ -30,16 +30,17 @@ The true native bottom sheet experience for your React Native Apps. 💩 ### Prerequisites -- React Native 0.81+ +- React Native 0.82+ - New Architecture enabled - Xcode 26.1+ ### Compatibility -| TrueSheet | React Native | Expo SDK | -|-----------|--------------|----------| -| 3.7+ | 0.81+ | 54+ | -| 3.6 | 0.80 | 52-53 | +| TrueSheet | React Native | Expo SDK | +|------------|--------------|----------| +| 4.0+ | 0.82+ | 55+ | +| 3.7 - 3.11 | 0.81+ | 54+ | +| 3.6 | 0.80 | 52-53 | ### Expo diff --git a/android/src/main/java/com/lodev09/truesheet/TrueSheetContainerView.kt b/android/src/main/java/com/lodev09/truesheet/TrueSheetContainerView.kt index 59259a30..29b4a0a8 100644 --- a/android/src/main/java/com/lodev09/truesheet/TrueSheetContainerView.kt +++ b/android/src/main/java/com/lodev09/truesheet/TrueSheetContainerView.kt @@ -63,13 +63,29 @@ class TrueSheetContainerView(reactContext: ThemedReactContext) : var insetAdjustment: TrueSheetInsetAdjustment = TrueSheetInsetAdjustment.AUTOMATIC var scrollViewBottomInset: Int = 0 - var scrollableEnabled: Boolean = false + var absoluteFooter: Boolean = false + + /** + * 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 contentView?.scrollableOptions = value } + var hasAutoDetent = false + set(value) { + field = value + contentView?.hasAutoDetent = value + } + override val eventDispatcher: EventDispatcher? get() = delegate?.eventDispatcher @@ -81,7 +97,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() { @@ -99,6 +115,7 @@ class TrueSheetContainerView(reactContext: ThemedReactContext) : is TrueSheetContentView -> { child.delegate = this child.scrollableOptions = scrollableOptions + child.hasAutoDetent = hasAutoDetent contentView = child // Children mount bottom-up, so the content subtree is complete here. @@ -113,6 +130,7 @@ class TrueSheetContainerView(reactContext: ThemedReactContext) : is TrueSheetFooterView -> { child.delegate = this + child.setBottomInset(footerBottomInset) footerView = child } } @@ -184,6 +202,9 @@ class TrueSheetContainerView(reactContext: ThemedReactContext) : delegate?.containerViewContentDidChangeSize(width, height) } + override val footerKeyboardOcclusion: Int + get() = if (absoluteFooter) footerView?.keyboardOcclusionHeight ?: 0 else -(footerView?.height ?: 0) + override fun contentViewDidScroll() { delegate?.containerViewContentDidScroll() } diff --git a/android/src/main/java/com/lodev09/truesheet/TrueSheetContentView.kt b/android/src/main/java/com/lodev09/truesheet/TrueSheetContentView.kt index 5fe2e2ca..af3383d4 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 @@ -27,6 +29,13 @@ interface TrueSheetContentViewDelegate { fun contentViewDidChangeSize(width: Int, height: Int) fun contentViewDidScroll() fun contentViewScrollViewDidChange() + + /** + * Keyboard occlusion adjustment below the content — positive for an absolute + * footer risen above the keyboard covering the content's bottom edge, + * negative for a relative footer sitting behind the keyboard shielding it. + */ + val footerKeyboardOcclusion: Int } /** @@ -36,14 +45,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 scrollExpansionPadding: 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 @@ -57,6 +94,20 @@ class TrueSheetContentView(private val reactContext: ThemedReactContext) : React keyboardScrollOffset = value?.keyboardScrollOffset?.dpToPx() ?: 0f } + /** + * Whether the sheet has an `auto` detent. Deriving the sheet height from the + * scroll content is circular with natural layout, so the pinned ScrollView's + * viewport is force-bounded to the container only in this case. + */ + var hasAutoDetent = false + set(value) { + if (field == value) return + field = value + if (pinnedScrollView != null) { + setScrollableBounded(value) + } + } + override fun addView(child: View?, index: Int) { super.addView(child, index) checkScrollViewChanged() @@ -75,20 +126,54 @@ 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() + } - if (w != lastWidth || h != lastHeight) { - lastWidth = w - lastHeight = h - delegate?.contentViewDidChangeSize(w, h) + 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 } + + reportSizeNow() } - fun setupScrollable(enabled: Boolean, bottomInset: Int) { - if (!enabled) { - clearScrollable() - return + 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. Only applied + * for auto detents — otherwise content lays out naturally like a regular view. + */ + private fun setScrollableBounded(bounded: Boolean) { + if (scrollableBounded == bounded) return + scrollableBounded = bounded + + stateWrapper?.let { + val newState = WritableNativeMap() + newState.putBoolean("scrollableBounded", bounded) + it.updateState(newState) + } + } + fun setupScrollable(bottomInset: Int) { // Check if pinned scroll view is still valid (still in view hierarchy) if (pinnedScrollView != null && pinnedScrollView?.isDescendantOf(this) == false) { clearScrollable() @@ -114,6 +199,13 @@ class TrueSheetContentView(private val reactContext: ThemedReactContext) : React delegate?.contentViewDidScroll() } } + + scrollView.getChildAt(0)?.let { child -> + observedScrollChild = child + child.addOnLayoutChangeListener(scrollChildLayoutListener) + } + setScrollableBounded(hasAutoDetent) + reportSizeIfChanged() } this.bottomInset = bottomInset @@ -123,23 +215,10 @@ class TrueSheetContentView(private val reactContext: ThemedReactContext) : React // If keyboard is currently showing, re-apply the keyboard inset to the new ScrollView val keyboardHeight = keyboardObserver?.currentHeight ?: 0 if (keyboardHeight > 0) { - setScrollViewPaddingBottom(originalScrollViewPaddingBottom + keyboardHeight) + updateScrollViewInsetForKeyboard(keyboardHeight) } } - // 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 +226,7 @@ class TrueSheetContentView(private val reactContext: ThemedReactContext) : React scrollView.paddingLeft, scrollView.paddingTop, scrollView.paddingRight, - paddingBottom + scrollExpansionPadding + paddingBottom ) } @@ -155,11 +234,14 @@ 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) + observedScrollChild?.removeOnLayoutChangeListener(scrollChildLayoutListener) + observedScrollChild = null + setScrollableBounded(false) pinnedScrollView = null originalScrollViewPaddingBottom = 0 bottomInset = 0 + reportSizeIfChanged() } fun findScrollView(): ViewGroup? { @@ -249,7 +331,15 @@ class TrueSheetContentView(private val reactContext: ThemedReactContext) : React private fun updateScrollViewInsetForKeyboard(keyboardHeight: Int) { val scrollView = pinnedScrollView ?: return - val totalBottomInset = if (keyboardHeight > 0) keyboardHeight else bottomInset + // An absolute footer rises above the keyboard and covers the content's + // bottom edge — include it so the caret clears the footer, not just the + // keyboard. A relative footer stays behind the keyboard below the content, + // so its height shields that much of the keyboard's overlap. + val totalBottomInset = if (keyboardHeight > 0) { + maxOf(0, keyboardHeight + (delegate?.footerKeyboardOcclusion ?: 0)) + } else { + bottomInset + } setScrollViewPaddingBottom(originalScrollViewPaddingBottom + totalBottomInset) scrollView.post { nudgeScrollView() } 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/java/com/lodev09/truesheet/TrueSheetFooterView.kt b/android/src/main/java/com/lodev09/truesheet/TrueSheetFooterView.kt index c0f91086..bd46edc0 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,57 @@ 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 — an absolute footer rises + * above the keyboard, so the inset would leave a gap. + */ + var keyboardVisible = false + set(value) { + if (field == value) return + field = value + pushBottomInsetState() + } + + /** + * Height the footer occupies above the keyboard — its layout height minus + * the safe-area inset it drops while the keyboard is open. + */ + val keyboardOcclusionHeight: Int + get() = maxOf(0, height - if (keyboardVisible) 0 else bottomInset) + + /** + * 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 new file mode 100644 index 00000000..c1fd66e7 --- /dev/null +++ b/android/src/main/java/com/lodev09/truesheet/TrueSheetStateUpdater.kt @@ -0,0 +1,34 @@ +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) + + /** 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 6f03b91c..50fe32cb 100644 --- a/android/src/main/java/com/lodev09/truesheet/TrueSheetView.kt +++ b/android/src/main/java/com/lodev09/truesheet/TrueSheetView.kt @@ -267,6 +267,7 @@ class TrueSheetView(private val reactContext: ThemedReactContext) : fun setDetents(newDetents: MutableList) { viewController.detents = newDetents + setupScrollable() } fun setInsetAdjustment(insetAdjustment: String) { @@ -274,16 +275,19 @@ class TrueSheetView(private val reactContext: ThemedReactContext) : setupScrollable() } - fun setScrollable(scrollable: Boolean) { - viewController.scrollable = scrollable - setupScrollable() - } - fun setScrollableOptions(options: ScrollableOptions?) { viewController.scrollableOptions = options setupScrollable() } + fun setAbsoluteHeader(absolute: Boolean) { + viewController.absoluteHeader = absolute + } + + fun setAbsoluteFooter(absolute: Boolean) { + viewController.absoluteFooter = absolute + } + fun setFooterKeyboardOffset(offset: Float) { viewController.footerKeyboardOffset = offset.dpToPx().toInt() viewController.positionFooter() @@ -292,9 +296,11 @@ 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.hasAutoDetent = viewController.detents.contains(-1.0) + it.footerBottomInset = viewController.contentBottomInset + it.absoluteFooter = viewController.absoluteFooter it.setupScrollable() } } @@ -336,9 +342,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 +575,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..f46e301d 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() @@ -284,9 +281,13 @@ 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 + override var absoluteFooter: Boolean = false + override val peekContentHeight: Int get() = containerView?.peekContentHeight ?: cachedPeekContentHeight @@ -561,29 +562,20 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) : else -> { } } - updateScrollExpansionPadding(sheetView.top) emitChangePositionDelegate(sheetView.top) - // On older APIs, use onSlide for footer positioning during keyboard transitions - val useLegacyKeyboardHandling = Build.VERSION.SDK_INT < Build.VERSION_CODES.R - if (!isKeyboardTransitioning || useLegacyKeyboardHandling) { - positionFooter(slideOffset) - } + // Position on every slide frame — during keyboard transitions the IME + // animation callbacks are sparser than the sheet's settle frames, and the + // footer rides the sheet between them, jittering against the keyboard. + // positionFooter derives from current state, so double-driving it with + // keyboardDidChangeHeight is harmless. + positionFooter(slideOffset) if (!isKeyboardTransitioning) { updateDimAmount(sheetView.top) } } - 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 +623,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 +730,7 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) : } pendingDetentIndex = detentIndex + updateStateDimensions(deferShrink = true) setupDimmedBackground() setStateForDetentIndex(detentIndex) resizePromise?.invoke() @@ -880,7 +876,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 +906,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() } @@ -994,15 +995,28 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) : val footerView = containerView?.footerView ?: return val sheet = sheetView ?: return + val keyboardShift = if (currentKeyboardInset > 0) maxOf(0, currentKeyboardInset + footerKeyboardOffset) else 0 + + // A relative footer is laid out by Yoga (pinned to the container's bottom + // edge) and stays in the layout flow behind the keyboard — only an + // absolute footer floats above it + if (!absoluteFooter) { + footerView.translationY = 0f + return + } + val footerHeight = footerView.height val sheetHeight = sheet.height val sheetTop = sheet.top - val keyboardShift = if (currentKeyboardInset > 0) maxOf(0, currentKeyboardInset + footerKeyboardOffset) else 0 var footerY = (sheetHeight - sheetTop - footerHeight - keyboardShift).toFloat() - // Adjust during dismiss animation when slideOffset is negative - if (slideOffset != null && slideOffset < 0) { + // Adjust during dismiss animation when slideOffset is negative. Skipped + // while the keyboard is open — the detents collapse to the expanded + // position, so any downward drag reads as a negative slideOffset and the + // adjustment would sink the footer behind the keyboard, snapping back up + // when the keyboard detents reset on release. + if (slideOffset != null && slideOffset < 0 && keyboardShift == 0) { footerY -= (footerHeight * slideOffset) } @@ -1049,17 +1063,22 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) : delegate = object : TrueSheetKeyboardObserverDelegate { override fun keyboardWillShow(height: Int) { if (!shouldHandleKeyboard()) return + // An absolute footer rises above the keyboard, so it drops the bottom + // inset padding — before detents are configured against its height + if (absoluteFooter) 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 - setupSheetDetents() + // Commit the target index first so the container grows before the sheet expands currentDetentIndex = detents.size - 1 - setStateForDetentIndex(currentDetentIndex) + setupSheetDetents() + positionFooter() updateDimAmount(animated = true) } 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") @@ -1072,6 +1091,10 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) : } } + // Same-frame reposition — the reconfigure above shrinks the container + // to the target detent, which yanks the bottom-pinned footer up until + // the next keyboard animation frame repositions it. + positionFooter() updateDimAmount( sheetTop = detentCalculator.getSheetTopForDetentIndex(currentDetentIndex), animated = true @@ -1080,6 +1103,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) @@ -1101,9 +1125,11 @@ 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) { + if (absoluteFooter) containerView?.footerView?.keyboardVisible = true detentIndexBeforeKeyboard = currentDetentIndex currentDetentIndex = detents.size - 1 setupSheetDetents() + positionFooter() } } } @@ -1173,6 +1199,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 +1269,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..8e4dab38 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 @@ -230,8 +225,17 @@ 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 position = if (options != null && options.hasKey("footerPosition")) options.getString("footerPosition") else null + view.setAbsoluteFooter(position == "absolute") + val keyboardOffset = if (options != null && options.hasKey("keyboardOffset")) options.getDouble("keyboardOffset").toFloat() else 0f view.setFooterKeyboardOffset(keyboardOffset) diff --git a/android/src/main/java/com/lodev09/truesheet/core/TrueSheetCoordinatorLayout.kt b/android/src/main/java/com/lodev09/truesheet/core/TrueSheetCoordinatorLayout.kt index ac32023e..cc06e2fa 100644 --- a/android/src/main/java/com/lodev09/truesheet/core/TrueSheetCoordinatorLayout.kt +++ b/android/src/main/java/com/lodev09/truesheet/core/TrueSheetCoordinatorLayout.kt @@ -118,8 +118,9 @@ class TrueSheetCoordinatorLayout(context: Context) : override fun requestDisallowInterceptTouchEvent(disallowIntercept: Boolean) { // RN's ReactEditText fires this(true) on ACTION_DOWN, killing sheet drag over an input. // Swallow only that case — nothing between the touched EditText and the sheet scrolls - // (an overflowing multiline EditText or an input inside a scrollable still scrolls). - // Other children (maps, sliders, scrollables) keep the standard disallow contract. + // (an overflowing multiline EditText or an input inside a scrollable that can actually + // scroll still scrolls). Other children (maps, sliders, scrollables) keep the standard + // disallow contract. if (disallowIntercept && streamDraggableEditText) return super.requestDisallowInterceptTouchEvent(disallowIntercept) } @@ -138,8 +139,16 @@ class TrueSheetCoordinatorLayout(context: Context) : .firstNotNullOfOrNull { it.getView() } ?: return if (target !== sheet && !target.isDescendantOf(sheet)) return + // A pinned scrollable that can scroll (or hosts pull-to-refresh) owns its + // touches — nested scrolling drags the sheet. When it can't scroll (e.g. + // the sheet is fully expanded and the content fits) it eats the gesture + // instead, so fall through and let the sheet drag from it — an EditText + // target needs the disallow-intercept swallow or its stream dies. val scrollView = delegate?.findScrollView() - if (scrollView != null && (target === scrollView || target.isDescendantOf(scrollView))) return + if (scrollView != null && (target === scrollView || target.isDescendantOf(scrollView))) { + val hasRefreshControl = scrollView.parent is SwipeRefreshLayout + if (hasRefreshControl || scrollView.scrollY > 0 || scrollView.canScrollVertically(1)) return + } var view: View? = target while (view != null && view !== this) { 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..241a265b 100644 --- a/android/src/main/java/com/lodev09/truesheet/core/TrueSheetDetentCalculator.kt +++ b/android/src/main/java/com/lodev09/truesheet/core/TrueSheetDetentCalculator.kt @@ -15,7 +15,9 @@ interface TrueSheetDetentCalculatorDelegate { val detents: MutableList val contentHeight: Int val headerHeight: Int + val absoluteHeader: Boolean val footerHeight: Int + val absoluteFooter: Boolean val peekContentHeight: Int val contentBottomInset: Int val maxContentHeight: Int? @@ -33,6 +35,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,13 +43,39 @@ 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 + footer height. + * An absolute (floating) header or footer overlaps the content, so it contributes no height. + */ + private val autoDetentHeight: Int + get() = contentHeight + + (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 + + /** + * 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. * Falls back to 150dp when none is present. */ private val peekDetentHeight: Int get() { - val height = headerHeight + footerHeight + peekContentHeight + val height = headerHeight + + (if (delegate?.absoluteFooter == true) footerHeight else 0) + + peekContentHeight return if (height > 0) height else DEFAULT_PEEK_HEIGHT.dpToPx().toInt() } @@ -56,9 +85,9 @@ class TrueSheetDetentCalculator(private val reactContext: ThemedReactContext) { */ fun getDetentHeight(detent: Double, includeKeyboard: Boolean = true): Int { val baseHeight = if (detent == -1.0) { - contentHeight + headerHeight + contentBottomInset + 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") @@ -99,7 +128,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/android/src/main/jni/TrueSheetSpec.h b/android/src/main/jni/TrueSheetSpec.h index 9569f3d3..aa9068f2 100644 --- a/android/src/main/jni/TrueSheetSpec.h +++ b/android/src/main/jni/TrueSheetSpec.h @@ -3,6 +3,8 @@ #include #include #include +#include +#include #include namespace facebook { diff --git a/android/src/main/jni/TrueSheetStateUpdater.cpp b/android/src/main/jni/TrueSheetStateUpdater.cpp new file mode 100644 index 00000000..c54fac63 --- /dev/null +++ b/android/src/main/jni/TrueSheetStateUpdater.cpp @@ -0,0 +1,87 @@ +// +// 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 +#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 +// 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) { + auto state = getStateFromWrapper(stateWrapper); + if (!state) { + return JNI_FALSE; + } + + auto concreteState = std::static_pointer_cast>(state); + + // Copy the latest data so fields this bridge doesn't set survive the update + TrueSheetViewState newState = concreteState->getData(); + newState.containerWidth = containerWidth; + newState.containerHeight = containerHeight; + concreteState->updateState(std::move(newState), EventQueue::UpdateMode::unstable_Immediate); + + 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); + + // Copy the latest data so fields this bridge doesn't set survive the update + TrueSheetFooterViewState newState = concreteState->getData(); + 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*/) { + return facebook::jni::initialize(vm, [] { + 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/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..a52226a4 --- /dev/null +++ b/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetContentViewShadowNode.cpp @@ -0,0 +1,43 @@ +#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 under an auto detent, fill the container so + // descendant flex layouts (flex:1 wrappers, the ScrollView itself) resolve + // against a definite height and the viewport is bounded — natural layout is + // circular there since the sheet height derives from the ScrollView's content + // size instead (see ContentView's naturalHeight on each platform). For fixed + // detents, content lays out naturally like a regular view. + 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/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 bcf6b23f..b6a23054 100644 --- a/docs/docs/guides/footer.mdx +++ b/docs/docs/guides/footer.mdx @@ -1,12 +1,12 @@ --- title: Adding Footer -description: Add a floating footer component to the bottom sheet. +description: Add a footer component pinned to the bottom of the sheet. keywords: [bottom sheet footer, bottom sheet floating footer, bottom sheet sticky footer] --- -Do you need a FAB (Floating Action Button) on the sheet? Or perhaps a small banner at the bottom? We've got you covered! +Do you need action buttons on the sheet? Or perhaps a small banner at the bottom? We've got you covered! -`TrueSheet` provides first-class support for a `Footer` component. This component will float at the bottom of the sheet. +`TrueSheet` provides first-class support for a `Footer` component. The footer is pinned to the bottom of the sheet, takes up space below the content, and its height is included in the [`auto`](../reference/types#sheetdetent) detent calculation. ## How? @@ -57,52 +57,73 @@ 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, an absolute footer rises above it and the inset is skipped automatically — no gap above the keyboard. A relative footer stays in the layout flow and keeps its inset. 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. 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 = () => { + return ( + } + > + } + /> + + ) +} +``` + +:::tip +Since the footer overlaps the content, add bottom padding to your content (e.g. `contentContainerStyle`) so it ends above the footer. +::: + +:::info +A relative footer is laid out below the content — if your content is taller than the sheet's visible height (e.g. fixed detents), bound it with `flex: 1` so the footer stays visible. Use `'absolute'` if you want the footer to float regardless of the content's height. ::: ## Collapsing to the Footer -The footer height counts toward the [`"peek"`](../reference/types#sheetdetent) detent — collapse the sheet down to just the [`header`](header), footer, and any peeking content. +An **absolute** footer's height counts toward the [`"peek"`](../reference/types#sheetdetent) detent — collapse the sheet down to just the [`header`](header), footer, and any peeking content. -```tsx {4-4} +```tsx {4-6} const App = () => { return ( } + footerOptions={{ position: 'absolute' }} > @@ -110,4 +131,4 @@ const App = () => { } ``` -Since the footer floats, it stays visible at every detent. See [Peeking Content](peeking) for the full guide. +Since an absolute footer floats over the content, it stays visible at every detent. A relative footer is laid out below the content, so it's pushed off-screen at the peek detent and is excluded from the peek height. See [Peeking Content](peeking) for the full guide. diff --git a/docs/docs/guides/header.mdx b/docs/docs/guides/header.mdx index 06e6214a..26e5ee85 100644 --- a/docs/docs/guides/header.mdx +++ b/docs/docs/guides/header.mdx @@ -65,9 +65,35 @@ 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. +Use the [`"peek"`](../reference/types#sheetdetent) detent to collapse the sheet down to just the header (plus an [absolute `footer`](footer#floating-footer), if provided). Great for map-style sheets with a compact summary that expands into full content. ```tsx {4-4} const App = () => { @@ -77,7 +103,7 @@ const App = () => { initialDetentIndex={0} header={} footer={} - scrollable + footerOptions={{ position: 'absolute' }} > diff --git a/docs/docs/guides/keyboard.mdx b/docs/docs/guides/keyboard.mdx index 22390d6a..957c0c23 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 - + @@ -54,19 +54,19 @@ This is especially useful for forms with multiple inputs — as the user taps be ## Footer Behavior -When using a [`footer`](../reference/configuration#footer) component, it automatically repositions above the keyboard, staying visible while the user types. +A relative [`footer`](../reference/configuration#footer) (the default) stays in the layout flow — it doesn't float above the keyboard. The keyboard simply covers it, and it's revealed again when the keyboard hides. -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: +An absolute footer automatically repositions above the keyboard, staying visible while the user types. The automatic scroll accounts for the footer's height — focused inputs clear both the keyboard and the footer. Its 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 absolute 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={{ position: 'absolute', 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/guides/peeking.mdx b/docs/docs/guides/peeking.mdx index c99a9d0d..448c94be 100644 --- a/docs/docs/guides/peeking.mdx +++ b/docs/docs/guides/peeking.mdx @@ -8,7 +8,7 @@ Building a map-style sheet with a compact summary that expands into full content ## The `"peek"` Detent -Add `"peek"` to your [`detents`](../reference/configuration#detents) to get a collapsed size based on the measured [`header`](header) and [`footer`](footer) heights. +Add `"peek"` to your [`detents`](../reference/configuration#detents) to get a collapsed size based on the measured [`header`](header) height, plus an [absolute footer](footer#floating-footer) if provided — a relative footer is laid out below the content, so it's pushed off-screen at peek and doesn't count. ```tsx {4-4} const App = () => { @@ -18,7 +18,7 @@ const App = () => { initialDetentIndex={0} header={} footer={} - scrollable + footerOptions={{ position: 'absolute' }} > @@ -26,7 +26,7 @@ const App = () => { } ``` -The peek height automatically follows the measured header and footer sizes. When neither `header` nor `footer` is provided, it falls back to a fixed height of `150`. +The peek height automatically follows the measured header and footer sizes. When no `header`, absolute `footer`, or peeking content is provided, it falls back to a fixed height of `150`. ## Peeking Part of the Content diff --git a/docs/docs/guides/scrolling.mdx b/docs/docs/guides/scrolling.mdx index 37332129..a755b22d 100644 --- a/docs/docs/guides/scrolling.mdx +++ b/docs/docs/guides/scrolling.mdx @@ -6,23 +6,22 @@ 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 like a regular view — bound the scroll view's height with `flex: 1` and plug a `ScrollView` or `FlatList` in directly. 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. + +Like any scroll view in React Native, it needs a bounded height to scroll. Pass `flex: 1` via the sheet's [`style`](../reference/configuration#style) prop so the content fills the sheet's visible height per detent — resizing in realtime while dragging: -```tsx {5-7} +```tsx const App = () => { const sheet = useRef(null) return ( - - {/* Header content */} + @@ -31,22 +30,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: +Wrapping it in a container `View` works too: ```tsx - + {/* Header content */} @@ -54,12 +41,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 +On Android, nested scrolling is managed internally — any `nestedScrollEnabled` prop on your `ScrollView` or `FlatList` is ignored. `RefreshControl` is also fully supported. +::: :::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. +The [`auto`](../reference/types#sheetdetent) detent works with scrolling content too — no `flex: 1` needed. The sheet sizes to the scroll view's content height and resizes as content grows or shrinks, while the viewport is automatically bounded to the sheet's visible height. ::: ## Preventing Scroll-to-Expand @@ -68,7 +55,6 @@ By default, scrolling content to the top edge expands the sheet to the next dete ```tsx @@ -88,7 +74,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/install.mdx b/docs/docs/install.mdx index 8631279a..e9f81fd3 100644 --- a/docs/docs/install.mdx +++ b/docs/docs/install.mdx @@ -8,16 +8,17 @@ keywords: [bottom sheet installation, bottom sheet yarn, bottom sheet npm, ios b ## Prerequisites -- React Native >= 0.80 +- React Native >= 0.82 - New Architecture enabled (default in RN 0.76+) - Xcode 26.1+ (liquid glass support) ### Compatibility -| TrueSheet | React Native | Expo SDK | -|-----------|--------------|----------| -| 3.7+ | 0.81+ | 54+ | -| 3.6 | 0.80 | 52-53 | +| TrueSheet | React Native | Expo SDK | +|------------|--------------|----------| +| 4.0+ | 0.82+ | 55+ | +| 3.7 - 3.11 | 0.81+ | 54+ | +| 3.6 | 0.80 | 52-53 | :::info This package is not compatible with [Expo Go](https://docs.expo.dev/get-started/expo-go/). Use this with [Expo CNG](https://docs.expo.dev/workflow/continuous-native-generation/) instead. diff --git a/docs/docs/migration-v3.mdx b/docs/docs/migration-v3.mdx new file mode 100644 index 00000000..5da4dfed --- /dev/null +++ b/docs/docs/migration-v3.mdx @@ -0,0 +1,463 @@ +--- +sidebar_position: 5 +description: Migration guide from v2 to v3 +keywords: [bottom sheet migration, upgrading bottom sheet, v2 to v3, breaking changes] +--- + +# Migrating to v3 + +This guide will help you migrate from TrueSheet v2 to v3. Version 3 brings full Fabric (New Architecture) support and several improvements. + +:::info +Upgrading from v3? See [Migrating to v4](migration). +::: + +## Breaking Changes + +### 1. Fabric Architecture Required + +Version 3 is built exclusively for React Native's **New Architecture (Fabric)**. The old Paper architecture is no longer supported. + +**Requirements:** +- React Native >= 0.76 (Expo SDK 52+) +- New Architecture enabled (default in RN 0.76+) + +:::tip +If you're on React Native 0.76+, the New Architecture is enabled by default. No additional configuration is needed. +::: + +### 2. Prop Renames + +Several props have been renamed for better clarity: + +| v2 Prop | v3 Prop | +| - | - | +| `sizes` | `detents` | +| `initialIndex` | `initialDetentIndex` | +| `initialIndexAnimated` | `initialDetentAnimated` | +| `contentContainerStyle` | _(removed - no longer needed)_ | +| `FooterComponent` | `footer` | +| `edgeToEdge` | _(removed)_ | +| `onPresent` | `onDidPresent` | +| `onDismiss` | `onDidDismiss` | +| `onSizeChange` | `onDetentChange` | +| `dimmedIndex` | `dimmedDetentIndex` | + +**Migration:** + +```tsx +// ❌ v2 +} + edgeToEdge + onPresent={(e) => console.log(e.nativeEvent)} + onDismiss={() => console.log('dismissed')} + onSizeChange={(e) => console.log(e.nativeEvent)} +/> + +// ✅ v3 +} + onDidPresent={(e) => console.log(e.nativeEvent)} + onDidDismiss={() => console.log('dismissed')} + onDetentChange={(e) => console.log(e.nativeEvent)} +/> +``` + +### 3. Removed Props + +The following props have been removed in v3: + +#### `grabberProps` (Android) + +The `grabberProps` prop has been removed. The grabber is now rendered **natively** on both iOS and Android following Material Design 3 specifications. Use the `grabber` boolean prop to show or hide the native grabber. + +**Migration:** + +```tsx +// ❌ v2 + + {/* ... */} + + +// ✅ v3 - grabber is native, use boolean to show/hide + + {/* ... */} + +``` + +#### `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. + +**Migration:** + +```tsx +// ❌ v2 +const scrollViewRef = useRef(null) + + + {/* ... */} + + +// ✅ v3 + + {/* ... */} + +``` + +:::tip +The `scrollable` prop was later removed in v4 — see [Migrating to v4](migration#2-scrollable-prop-removed). +::: + +#### `contentContainerStyle` + +The `contentContainerStyle` prop has been removed as it's no longer needed with the new architecture. Style your content directly instead. + +### 4. Detent Value Changes + +Detent values now use **fractional values (0-1)** instead of percentage strings. + +**Migration:** + +```tsx +// ❌ v2 + + +// ✅ v3 + +``` + +:::tip +The `"auto"` detent still works the same way! +::: + +### 5. Native Bottom Safe Area Handling + +Both iOS and Android now handle bottom safe area insets natively for the sheet. This means the sheet height includes the safe area automatically, providing consistent behavior across all devices. + +**What this means:** +- 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:** + +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: + +```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 + + + ); +}; + +// Usage +}> + {/* 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. +::: + +### 6. Background and Blur Behavior Changes + +#### `backgroundColor` Default Value + +The `backgroundColor` prop no longer defaults to `"white"`. It now uses the **system default** when not provided, which matches the platform's native sheet background. + +**Migration:** + +```tsx +// ❌ v2 - backgroundColor defaults to white + + +// ✅ v3 - backgroundColor uses system default (transparent on iOS) +// If you want white background, explicitly set it: + +``` + +#### `backgroundBlur` No Longer Overrides `backgroundColor` + +In v2, setting `backgroundBlur` (previously `blurTint`) would completely override the `backgroundColor`. In v3, `backgroundBlur` now **applies a blur effect over** the `backgroundColor`, allowing you to combine both for richer visual effects. + +**Migration:** + +```tsx +// ❌ v2 - backgroundBlur overrides backgroundColor (backgroundColor is ignored) + + +// ✅ v3 - backgroundBlur applies over backgroundColor (red + light blur) + + +// If you want only blur without background color: + +``` + +## New Features in v3 + +### 1. Automatic Edge-to-Edge Detection (Android) + +TrueSheet now automatically detects and adapts to Android's edge-to-edge display mode. The sheet respects the status bar when fully expanded. + +:::info +This feature is **Android-only** and requires React Native 0.81+ with `edgeToEdgeEnabled=true` in your `android/gradle.properties`. +::: + +See the [Edge-to-Edge guide](guides/edge-to-edge) for details. + +### 2. Enhanced Event System + +Version 3 introduces new lifecycle and interaction events for better control: + +#### New Events: +- **`onMount`** - Called when the sheet's content is mounted and ready +- **`onWillPresent`** - Called when the sheet is about to be presented +- **`onDidPresent`** - Called when the sheet has been presented +- **`onWillDismiss`** - Called when the sheet is about to be dismissed +- **`onDidDismiss`** - Called when the sheet has been dismissed +- **`onDragBegin`** - Called when the sheet begins dragging +- **`onDragChange`** - Called continuously while the sheet is being dragged +- **`onDragEnd`** - Called when sheet dragging has ended +- **`onPositionChange`** - Called continuously when the sheet's position changes +- **`onWillFocus`** - Called when the sheet is about to regain focus after a sheet on top begins dismissing +- **`onDidFocus`** - Called when the sheet has regained focus after a sheet on top is dismissed +- **`onWillBlur`** - Called when the sheet is about to lose focus because another sheet is about to be presented on top +- **`onDidBlur`** - Called when the sheet has lost focus because another sheet is presented on top + +These events provide more granular control over the sheet's lifecycle and user interactions. + +See the [Lifecycle Events documentation](reference/events) for detailed usage and examples. + +### 3. iPad Support with Presentation + +Version 3 now includes first-class iPad support with the new `presentation` prop. `'page'` (default) uses a large page sheet for better readability on iPad. `'form'` uses a centered floating form sheet. + +```tsx + +``` + +:::info +This feature is **iOS 17+ only** and defaults to `'page'`. Set to `'form'` to use a centered form sheet presentation on iPad. +::: + +### 4. Improved Performance + +- Faster rendering with Fabric architecture +- Built-in lazy loading by default +- Smoother animations and transitions + +### 5. First-Class Reanimated v4 Support + +Version 3 introduces dedicated components for seamless integration with `react-native-reanimated` v4: + +#### New Components: +- **`ReanimatedTrueSheetProvider`** - Context provider for managing shared values +- **`ReanimatedTrueSheet`** - Drop-in replacement for `TrueSheet` with automatic position tracking +- **`useReanimatedTrueSheet`** - Hook to access the sheet's animated position + +#### Example: + +```tsx +import { + ReanimatedTrueSheetProvider, + ReanimatedTrueSheet, + useReanimatedTrueSheet, +} from '@lodev09/react-native-true-sheet/reanimated' +import Animated, { useAnimatedStyle, interpolate, Extrapolation } from 'react-native-reanimated' + +// 1. Wrap your app with the provider +const App = () => ( + + + +) + +// 2. Use ReanimatedTrueSheet instead of TrueSheet +const MySheet = () => ( + + Sheet Content + +) + +// 3. Access the sheet's position for custom animations +const MyAnimatedComponent = () => { + const { animatedPosition, animatedIndex } = useReanimatedTrueSheet() + + const positionStyle = useAnimatedStyle(() => ({ + transform: [{ translateY: -animatedPosition.value }], + })) + + // Example: Fade in as sheet expands from index 0 to 1 + const opacityStyle = useAnimatedStyle(() => ({ + opacity: interpolate(animatedIndex.value, [0, 1], [0, 1], Extrapolation.CLAMP) + })) + + return ( + + This moves with the sheet + + ) +} +``` + +:::tip +The `onPositionChange` event now runs on the UI thread (worklet) when using `ReanimatedTrueSheet`. Make sure to add the `'worklet'` directive to your handler if you override it. +::: + +See the [Reanimated Integration guide](guides/reanimated) for complete examples. + +### 6. Native Header Support + +Version 3 introduces a new `header` prop for adding fixed headers above your scrollable content. The header is rendered as a native view and is properly accounted for in layout calculations. + +```tsx +}> + + +``` + +This ensures proper scrolling behavior on both iOS and Android when using `ScrollView` or `FlatList` with a header. + +See the [Header guide](guides/header) for more details. + +### 7. Draggable Control + +Version 3 introduces the `draggable` prop to disable user dragging entirely. When set to `false`, the sheet becomes static and can only be resized programmatically using the [`resize`](reference/methods#resize) method. + +```tsx + +