Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
4b9b0e8
feat!: synchronous per-detent container layout (#735)
lodev09 Jul 15, 2026
ad6dcbc
feat: support auto detent with scrollables (#743)
lodev09 Jul 16, 2026
ce4e50e
chore(example): fix BasicSheet
lodev09 Jul 16, 2026
95fc60a
refactor(ios): unify position tracking into a single display link (#744)
lodev09 Jul 16, 2026
2970017
feat!: content lays out naturally like a regular view (#746)
lodev09 Jul 17, 2026
097e738
feat: add headerOptions prop with absolute header position (#747)
lodev09 Jul 17, 2026
29b6260
feat!: add footerOptions prop with relative footer position (#748)
lodev09 Jul 17, 2026
d6f63a6
feat: relative footer owns the bottom inset for auto detent (#749)
lodev09 Jul 17, 2026
cea41fa
feat: footer owns the bottom inset adjustment (#750)
lodev09 Jul 18, 2026
9fa18dc
chore(example): align footer content padding with relative/absolute p…
lodev09 Jul 18, 2026
b3a4fad
fix: keyboard scroll misses the bottom inset with a footer (#752)
lodev09 Jul 20, 2026
9ff59b7
fix(android): footer jumps when keyboard hides on drag release
lodev09 Jul 20, 2026
31f2714
fix(android): sheet won't drag down from an input when fully expanded
lodev09 Jul 21, 2026
0498a7a
fix(ios): skip size reports while stacked behind a child sheet (#753)
lodev09 Jul 21, 2026
422c270
fix: relative footer stays in the layout flow behind the keyboard (#754)
lodev09 Jul 21, 2026
88963e7
chore: upgrade examples to Expo SDK 57 and RN 0.86 (#755)
lodev09 Jul 22, 2026
24d6373
refactor(ios): restore per-path position tracking (#756)
lodev09 Jul 22, 2026
9499aca
fix: address v4 review findings
lodev09 Jul 22, 2026
64a4130
fix(navigation): expose style, headerOptions, and footerOptions in sc…
lodev09 Jul 22, 2026
1607412
fix(ios): keep capped detent heights distinct to preserve the deck an…
lodev09 Jul 23, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 0 additions & 12 deletions .yarn/patches/react-native-npm-0.85.3.patch

This file was deleted.

3 changes: 3 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
25 changes: 25 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 6 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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() {
Expand All @@ -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.
Expand All @@ -113,6 +130,7 @@ class TrueSheetContainerView(reactContext: ThemedReactContext) :

is TrueSheetFooterView -> {
child.delegate = this
child.setBottomInset(footerBottomInset)
footerView = child
}
}
Expand Down Expand Up @@ -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()
}
Expand Down
142 changes: 116 additions & 26 deletions android/src/main/java/com/lodev09/truesheet/TrueSheetContentView.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
}

/**
Expand All @@ -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
Expand All @@ -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()
Expand All @@ -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()
Expand All @@ -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
Expand All @@ -123,43 +215,33 @@ 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
scrollView.setPadding(
scrollView.paddingLeft,
scrollView.paddingTop,
scrollView.paddingRight,
paddingBottom + scrollExpansionPadding
paddingBottom
)
}

fun clearScrollable() {
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? {
Expand Down Expand Up @@ -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() }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -17,6 +19,11 @@ class TrueSheetContentViewManager : ViewGroupManager<TrueSheetContentView>() {

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)
Expand Down
Loading
Loading