You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
I checked for existing issues that might answer my question.
Bug Summary
Multiple Scroll View issue (Android):
When there are 2 scrollables (ScrollView or Flatlist) present in a Truesheet, only the first scrollView is scrollable, the second scrollview doesn't scroll at all (this is despite scrollable prop enabled, and doesn't seem to work even with the prop disabled). Works perfectly in iOS
I even wrapping both the scrollviews inside another scrollview wont work cos only the parent scrollview works then
If scrollable prop is enabled, then the (first) ScrollView occupies larger height for some reason, and doesn't even acknowledge the explicit height (had to resort to workarounds in my production app which is not present in this repro). This works fine on Android
truesheet.ios.bug.mov
Drag Threshold issue (Android) (Minor):
Drag to close seems to have a higher threshold in Android after a scroll - sometimes i had to drag harder (Could be my nit pick).
try dragging the flatlist on the right. it wont work
B. iOS scrollable prop increasing the size of the (first) scrollview
Close and open the filter modal each time scrollable prop is toggled. you will find that the first scrollview's height changes for each toggle, with scrollable={false} taking the correct height
C. Drag to close on a scrollable (Android only)
press open filter in the app
first try normal drag to close without scrolling, works smooth
reopen the filter, scroll the 1st scrollview a bit and the scroll back up. try dragging now. the drag becomes harder (increased threshold)
As a fix, i used an LLM to patch the above issues since i have no clue of writing Kotlin/Java for Android development (native side in general). This is how it approached and solved:
Problem observed: After the user has scrolled the inner content at all (even just a small scroll down and back to top), dragging the sheet down to dismiss via that same scroll content feels noticeably stiffer than dragging from a non-scrollable region (header/grabber). The dismiss requires significantly more travel before it commits. Dragging from non-scrollable regions of the sheet remains responsive. This points to Material's BottomSheetBehavior holding a stale nestedScrollingChildRef from the previous scroll stream and never committing to STATE_HIDDEN on a nested-scroll release that has clearly crossed a reasonable dismiss travel.
Fix:
Override onStartNestedScroll to repoint Material's private nestedScrollingChildRef (via reflection — same precedent as TrueSheetCoordinatorLayout.clearStaleNestedScrollingChildRef) at the actual scrolling target. This ensures BottomSheetBehavior isn't holding a stale child from a previous scroll stream.
In onNestedPreScroll, latch the sheet's child.top the first time a downward nested scroll actually drags the sheet (state == STATE_DRAGGING, consumed[1] < 0). Latched once per stream; reset in onStartNestedScroll and onStopNestedScroll.
In onStopNestedScroll, if the sheet has traveled down ≥ 40dp (density-independent via PixelUtil.dpToPx) since the latch point and isHideable, set state = STATE_HIDDEN. Always calls super.onStopNestedScroll afterwards so Material can complete its bookkeeping.
Reflection is wrapped in try/catch with Log.w on failure so the fallback is observable rather than silent.
2. android/.../TrueSheetContentView.kt — nested scrolling for multiple/dynamic ScrollViews
Problem observed:setupScrollable() finds only the first descendant ScrollView / NestedScrollView via findScrollView() and enables nested scrolling on that one alone. Sheets with multiple scrollables (e.g. tabbed content, two stacked lists, dynamically remounted lists) end up with only one scrollable that drags/dismisses the sheet via nested scroll — the others either dead-end or behave inconsistently across remounts. Additionally, scrollables that mount after the sheet opens never get nested scrolling enabled at all, because setupScrollable early-returns when pinnedScrollView is already set.
Fix:
New helpers setupNestedScrollingForScrollViews() / restoreNestedScrollingForScrollViews() walk the content subtree, find every ScrollView / NestedScrollView, enable nested scrolling on each, and disable nested scrolling on each one's SwipeRefreshLayout parent.
Each view's original isNestedScrollingEnabled flag is captured in a WeakHashMap<ViewGroup, Boolean> so removed/recycled scrollviews can be GC'd, and is restored on teardown.
findScrollViews intentionally does not recurse into a scrollview's children (nested scrollables within scrollables aren't a supported pattern and would compete for the same nested-scroll stream).
The keyboard/padding/scroll-listener logic is intentionally kept on the existing single pinnedScrollView — only the nested-scroll flag is extended to all scrollables. Migrating the rest to multi-scrollview semantics needs a separate decision (which scrollview owns "focused input"?). Comment in code calls this out.
Re-runs setupNestedScrollingForScrollViews() on the existing early-return path so scrollviews that mount after the sheet opens get picked up.
Problem observed: In onInterceptTouchEvent, when the sheet contains a ScrollView that's at the top and can't scroll further (e.g. content fits the viewport), the cannotScroll branch runs for every touch on the sheet — including touches well outside the scrollview's bounds. This eats touches that should reach sibling children (buttons next to a small list, header controls, etc.) and routes them into sheet drag instead.
Fix: Add isPointInChildBounds(scrollView, ev.x.toInt(), ev.y.toInt()) to the cannotScroll condition so the drag-intercept fast path only triggers when the touch is actually inside the scrollview's rect. Touches on other parts of the sheet fall through to normal handling.
Patch metadata
Format: Yarn 4 patch protocol (.yarn/patches/...)
Base version: exactly @lodev09/react-native-true-sheet@npm:3.10.1
Applies cleanly to current main (3.11.0-beta) as well — none of the three files have been refactored in the beta branch.
I'm happy to share the raw .patch file or open PRs directly; let me know which is more useful.
Threshold choice (40dp)
iOS uses dynamic heuristics for sheet dismiss (velocity + position), not a fixed value, so there's no direct numeric parity target. 40dp was chosen as a density-independent equivalent of the ~120 raw pixels that "felt right" during testing on a 3x device — small enough that dismiss feels responsive after a real downward gesture, large enough that scroll overscroll/bounce at the top edge doesn't accidentally dismiss.
here is the patch:
diff --git a/android/src/main/java/com/lodev09/truesheet/TrueSheetContentView.kt b/android/src/main/java/com/lodev09/truesheet/TrueSheetContentView.kt
index 1fcb0a1037f1fe72b982f1d92e278e87cf4a8f57..2df03e9c6acbb83db9bc0f557c27f454f750dcd0 100644
--- a/android/src/main/java/com/lodev09/truesheet/TrueSheetContentView.kt
+++ b/android/src/main/java/com/lodev09/truesheet/TrueSheetContentView.kt
@@ -14,6 +14,7 @@ import com.lodev09.truesheet.core.TrueSheetKeyboardObserverDelegate
import com.lodev09.truesheet.utils.isDescendantOf
import com.lodev09.truesheet.utils.smoothScrollBy
import com.lodev09.truesheet.utils.smoothScrollTo
+import java.util.WeakHashMap
data class ScrollableOptions(val keyboardScrollOffset: Float = 0f, val scrollingExpandsSheet: Boolean = true)
@@ -38,6 +39,19 @@ class TrueSheetContentView(private val reactContext: ThemedReactContext) : React
private var lastHeight = 0
private var pinnedScrollView: ViewGroup? = null
+
+ // NOTE on multi-ScrollView support:
+ // `pinnedScrollView` (and the keyboard/padding logic that targets it) still
+ // tracks a single scrollview — the one returned by `findScrollView()`. The
+ // map below extends *only* nested-scroll flag management to every scrollview
+ // in the subtree so secondary lists (e.g. tabbed content) still drag/dismiss
+ // the sheet correctly. Keyboard insets, scrollExpansionPadding, and
+ // `contentViewDidScroll` events are intentionally bound to the first
+ // scrollview to match upstream behavior; revisit if/when sheets need full
+ // multi-scrollview keyboard handling.
+ // WeakHashMap so transient scrollviews (added then removed without an
+ // explicit clearScrollable) don't pin native view memory.
+ private val originalNestedScrollingEnabled = WeakHashMap<ViewGroup, Boolean>()
private var originalScrollViewPaddingBottom: Int = 0
private var bottomInset: Int = 0
private var scrollExpansionPadding: Int = 0
@@ -90,6 +104,7 @@ class TrueSheetContentView(private val reactContext: ThemedReactContext) : React
// Already set up with same inset and valid scroll view
if (pinnedScrollView != null && this.bottomInset == bottomInset) {
+ setupNestedScrollingForScrollViews()
return
}
@@ -100,9 +115,6 @@ class TrueSheetContentView(private val reactContext: ThemedReactContext) : React
originalScrollViewPaddingBottom = scrollView.paddingBottom
pinnedScrollView = scrollView
- scrollView.isNestedScrollingEnabled = true
- (scrollView.parent as? SwipeRefreshLayout)?.isNestedScrollingEnabled = false
-
scrollView.setOnScrollChangeListener { _, _, scrollY, _, oldScrollY ->
if (scrollY != oldScrollY) {
delegate?.contentViewDidScroll()
@@ -110,6 +122,8 @@ class TrueSheetContentView(private val reactContext: ThemedReactContext) : React
}
}
+ setupNestedScrollingForScrollViews()
+
this.bottomInset = bottomInset
setScrollViewPaddingBottom(originalScrollViewPaddingBottom + bottomInset)
@@ -147,8 +161,7 @@ class TrueSheetContentView(private val reactContext: ThemedReactContext) : React
fun clearScrollable() {
pinnedScrollView?.setOnScrollChangeListener(null as View.OnScrollChangeListener?)
- pinnedScrollView?.isNestedScrollingEnabled = false
- (pinnedScrollView?.parent as? SwipeRefreshLayout)?.isNestedScrollingEnabled = true
+ restoreNestedScrollingForScrollViews()
scrollExpansionPadding = 0
setScrollViewPaddingBottom(originalScrollViewPaddingBottom)
pinnedScrollView = null
@@ -178,6 +191,55 @@ class TrueSheetContentView(private val reactContext: ThemedReactContext) : React
return null
}
+ private fun setupNestedScrollingForScrollViews() {
+ val scrollViews = mutableListOf<ViewGroup>()
+ findScrollViews(this, scrollViews)
+
+ scrollViews.forEach { scrollView ->
+ originalNestedScrollingEnabled.putIfAbsent(scrollView, scrollView.isNestedScrollingEnabled)
+ scrollView.isNestedScrollingEnabled = true
+ (scrollView.parent as? SwipeRefreshLayout)?.isNestedScrollingEnabled = false
+ }
+
+ originalNestedScrollingEnabled.keys
+ .filter { it !in scrollViews || !it.isDescendantOf(this) }
+ .forEach { scrollView ->
+ restoreNestedScrollingForScrollView(scrollView)
+ }
+ }
+
+ private fun restoreNestedScrollingForScrollView(scrollView: ViewGroup) {
+ val originalEnabled = originalNestedScrollingEnabled.remove(scrollView) ?: return
+ scrollView.isNestedScrollingEnabled = originalEnabled
+ (scrollView.parent as? SwipeRefreshLayout)?.isNestedScrollingEnabled = true
+ }
+
+ private fun restoreNestedScrollingForScrollViews() {
+ originalNestedScrollingEnabled.keys.toList().forEach { scrollView ->
+ restoreNestedScrollingForScrollView(scrollView)
+ }
+ // Defensive: ensure the map is empty even if some entries couldn't be
+ // restored (e.g. the view was already detached/collected).
+ originalNestedScrollingEnabled.clear()
+ }
+
+ private fun findScrollViews(view: View, scrollViews: MutableList<ViewGroup>) {
+ if (view is ScrollView || view is NestedScrollView) {
+ scrollViews.add(view as ViewGroup)
+ // Intentionally don't recurse into a scrollview's children: nested
+ // scrollables-within-scrollables aren't a supported pattern here and
+ // enabling nested scrolling on both layers would fight for the same
+ // scroll stream.
+ return
+ }
+
+ if (view is ViewGroup) {
+ for (i in 0 until view.childCount) {
+ findScrollViews(view.getChildAt(i), scrollViews)
+ }
+ }
+ }
+
// ==================== Keyboard Handling ====================
fun setupKeyboardHandler() {
diff --git a/android/src/main/java/com/lodev09/truesheet/core/TrueSheetBottomSheetBehavior.kt b/android/src/main/java/com/lodev09/truesheet/core/TrueSheetBottomSheetBehavior.kt
index 068508732f678b8b1b87ff4cc671f953fb064fd1..3e91dc030fc49b3091cce523091e4f4a6a7acfa4 100644
--- a/android/src/main/java/com/lodev09/truesheet/core/TrueSheetBottomSheetBehavior.kt
+++ b/android/src/main/java/com/lodev09/truesheet/core/TrueSheetBottomSheetBehavior.kt
@@ -1,11 +1,59 @@
package com.lodev09.truesheet.core
+import android.util.Log
import android.view.View
import androidx.coordinatorlayout.widget.CoordinatorLayout
+import androidx.core.view.ViewCompat
+import com.facebook.react.uimanager.PixelUtil.dpToPx
import com.google.android.material.bottomsheet.BottomSheetBehavior
+import com.lodev09.truesheet.utils.isDescendantOf
+import java.lang.ref.WeakReference
class TrueSheetBottomSheetBehavior<V : View> : BottomSheetBehavior<V>() {
+ companion object {
+ private const val TAG = "TrueSheetBehavior"
+
+ // Distance the sheet must travel down via a nested-scroll drag before we
+ // commit to dismissing it. Expressed in dp so the threshold is consistent
+ // across screen densities (iOS uses dynamic heuristics; this is the closest
+ // density-independent equivalent).
+ private const val NESTED_DISMISS_THRESHOLD_DP = 40f
+
+ private val nestedScrollingChildRefField by lazy {
+ try {
+ BottomSheetBehavior::class.java
+ .getDeclaredField("nestedScrollingChildRef")
+ .apply { isAccessible = true }
+ } catch (e: NoSuchFieldException) {
+ Log.w(TAG, "BottomSheetBehavior.nestedScrollingChildRef not found; " +
+ "nested-scroll target override disabled. Library may have been minified or upgraded.", e)
+ null
+ }
+ }
+ }
+
+ private val nestedDismissThresholdPx: Int by lazy {
+ NESTED_DISMISS_THRESHOLD_DP.dpToPx().toInt()
+ }
+
var scrollingExpandsSheet: Boolean = true
+ private var nestedDragStartTop: Int? = null
+
+ override fun onStartNestedScroll(
+ coordinatorLayout: CoordinatorLayout,
+ child: V,
+ directTargetChild: View,
+ target: View,
+ axes: Int,
+ type: Int
+ ): Boolean {
+ if (axes and ViewCompat.SCROLL_AXIS_VERTICAL != 0 && target.isDescendantOf(child)) {
+ setNestedScrollingChildRef(target)
+ nestedDragStartTop = null
+ }
+
+ return super.onStartNestedScroll(coordinatorLayout, child, directTargetChild, target, axes, type)
+ }
override fun onNestedPreScroll(
coordinatorLayout: CoordinatorLayout,
@@ -20,6 +68,33 @@ class TrueSheetBottomSheetBehavior<V : View> : BottomSheetBehavior<V>() {
// Block expansion from scroll, but allow if sheet is already being dragged
if (!scrollingExpandsSheet && dy > 0 && state != STATE_DRAGGING) return
super.onNestedPreScroll(coordinatorLayout, child, target, dx, dy, consumed, type)
+
+ // Record the sheet's top position the first time a downward nested-scroll
+ // actually drags the sheet. We only latch once per stream (reset in
+ // onStartNestedScroll / onStopNestedScroll) so the dismiss decision is
+ // based on the total downward travel for this single drag gesture.
+ if (dy < 0 && state == STATE_DRAGGING && consumed[1] < 0 && nestedDragStartTop == null) {
+ nestedDragStartTop = child.top - consumed[1]
+ }
+ }
+
+ override fun onStopNestedScroll(
+ coordinatorLayout: CoordinatorLayout,
+ child: V,
+ target: View,
+ type: Int
+ ) {
+ val dragStartTop = nestedDragStartTop
+ nestedDragStartTop = null
+
+ if (dragStartTop != null && child.top - dragStartTop >= nestedDismissThresholdPx && isHideable) {
+ // Setting STATE_HIDDEN drives Material's own settle animation. We still
+ // call super afterwards so the behavior can finish its bookkeeping
+ // (clear nested-scrolling refs, fire callbacks) in a consistent state.
+ state = STATE_HIDDEN
+ }
+
+ super.onStopNestedScroll(coordinatorLayout, child, target, type)
}
override fun onNestedPreFling(
@@ -33,4 +108,13 @@ class TrueSheetBottomSheetBehavior<V : View> : BottomSheetBehavior<V>() {
if (!scrollingExpandsSheet) return false
return super.onNestedPreFling(coordinatorLayout, child, target, velocityX, velocityY)
}
+
+ private fun setNestedScrollingChildRef(target: View) {
+ val field = nestedScrollingChildRefField ?: return
+ try {
+ field.set(this, WeakReference(target))
+ } catch (e: IllegalAccessException) {
+ Log.w(TAG, "Failed to set nestedScrollingChildRef on BottomSheetBehavior", e)
+ }
+ }
}
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 94fa1d50e1973243b3efd82be669bdda4f3049c6..8b477fcaa65e7cc6495a91e6a8fc5d77b8eccf1c 100644
--- a/android/src/main/java/com/lodev09/truesheet/core/TrueSheetCoordinatorLayout.kt
+++ b/android/src/main/java/com/lodev09/truesheet/core/TrueSheetCoordinatorLayout.kt
@@ -102,6 +102,7 @@ class TrueSheetCoordinatorLayout(context: Context) :
val hasRefreshControl = scrollView?.parent is SwipeRefreshLayout
val cannotScroll = scrollView != null &&
!hasRefreshControl &&
+ isPointInChildBounds(scrollView, ev.x.toInt(), ev.y.toInt()) &&
scrollView.scrollY == 0 &&
!scrollView.canScrollVertically(1)
Before submitting a new issue
Bug Summary
ScrollVieworFlatlist) present in a Truesheet, only the first scrollView is scrollable, the second scrollview doesn't scroll at all (this is despitescrollableprop enabled, and doesn't seem to work even with the prop disabled). Works perfectly in iOSScreen_Recording_20260607_013806_truesheet-bug.mp4
scrollableprop is enabled, then the (first) ScrollView occupies larger height for some reason, and doesn't even acknowledge the explicitheight(had to resort to workarounds in my production app which is not present in this repro). This works fine on Androidtruesheet.ios.bug.mov
Screen_Recording_20260607_013845_truesheet-bug.webm
Affected Platforms
Library Version
3.10.1
Environment Info
System: OS: macOS 26.5.1 CPU: (12) arm64 Apple M2 Pro Memory: 125.63 MB / 16.00 GB Shell: version: "5.9" path: /bin/zsh Binaries: Node: version: 22.22.0 path: /Users/userdirectory/.nvm/versions/node/v22.22.0/bin/node Yarn: version: 1.22.22 path: /Users/userdirectory/.nvm/versions/node/v22.22.0/bin/yarn npm: version: 11.14.1 path: /Users/userdirectory/.nvm/versions/node/v22.22.0/bin/npm Watchman: Not Found Managers: CocoaPods: version: 1.16.2 path: /Users/userdirectory/.rbenv/shims/pod SDKs: iOS SDK: Platforms: - DriverKit 25.5 - iOS 26.5 - macOS 26.5 - tvOS 26.5 - visionOS 26.5 - watchOS 26.5 Android SDK: API Levels: - "30" - "31" - "33" - "34" - "35" - "36" Build Tools: - 35.0.0 - 36.0.0 System Images: - android-34 | Google APIs ARM 64 v8a - android-34 | Google Play ARM 64 v8a - android-35 | Google APIs ARM 64 v8a Android NDK: Not Found IDEs: Android Studio: 2025.3 AI-253.32098.37.2534.15336583 Xcode: version: 26.5/17F42 path: /usr/bin/xcodebuild Languages: Java: version: 17.0.19 path: /usr/bin/javac Ruby: version: 3.2.9 path: /Users/userdirectory/.rbenv/shims/ruby npmPackages: "@react-native-community/cli": installed: 20.1.3 wanted: ^20.1.3 react: installed: 19.1.0 wanted: 19.1.0 react-native: installed: 0.81.5 wanted: 0.81.5 react-native-macos: Not Found npmGlobalPackages: "*react-native*": Not Found Android: hermesEnabled: true newArchEnabled: true iOS: hermesEnabled: true newArchEnabled: trueSteps to Reproduce
in this repo: https://github.com/sriksm19/truesheet-multi-scroll-bug-repro
A. Android multi scrollview bug
B. iOS scrollable prop increasing the size of the (first) scrollview
scrollableprop is toggled. you will find that the first scrollview's height changes for each toggle, withscrollable={false}taking the correct heightC. Drag to close on a scrollable (Android only)
Repro
https://github.com/sriksm19/truesheet-multi-scroll-bug-repro
Additional Context
As a fix, i used an LLM to patch the above issues since i have no clue of writing Kotlin/Java for Android development (native side in general). This is how it approached and solved:
here is the patch:
Hope this helps