Skip to content

Multiple ScrollView bug in Android + height issue in iOS #708

@sriksm19

Description

@sriksm19

Before submitting a new issue

  • I tested using the latest version of the library.
  • I tested using a supported version of React Native.
  • I checked for existing issues that might answer my question.

Bug Summary

  1. 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
Screen_Recording_20260607_013806_truesheet-bug.mp4
  1. ScrollView Height issue (iOS):
  • 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
  1. 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).
Screen_Recording_20260607_013845_truesheet-bug.webm

Affected Platforms

  • iOS
  • Android
  • Web
  • Other

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: true

Steps to Reproduce

in this repo: https://github.com/sriksm19/truesheet-multi-scroll-bug-repro

A. Android multi scrollview bug

  1. press open filter in the app
  2. try dragging the flatlist on the right. it wont work

B. iOS scrollable prop increasing the size of the (first) scrollview

  1. 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)

  1. press open filter in the app
  2. first try normal drag to close without scrolling, works smooth
  3. reopen the filter, scroll the 1st scrollview a bit and the scroll back up. try dragging now. the drag becomes harder (increased threshold)

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:

1. android/.../core/TrueSheetBottomSheetBehavior.kt — fix drag-to-dismiss "ramp-up"

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.

3. android/.../core/TrueSheetCoordinatorLayout.kt — single-line touch-routing fix

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)
 

Hope this helps

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingrepro providedGood! Repro is provided

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions