Skip to content

Commit 3c26b69

Browse files
authored
feat: add scrollingExpandsSheet to scrollableOptions (#585)
* feat: add scrollingExpandsSheet to scrollableOptions * feat(android): implement scrollingExpandsSheet via custom behavior * feat(android): auto-enable nestedScrollingEnabled for scrollable sheets * refactor(android): support NestedScrollView for future RN compatibility * docs: update scrollable guides and add scrollableOptions reference * fix(android): add scroll expansion padding for scrollable sheets Adds bottom padding to ScrollView to compensate for the container being sized to the largest detent. Without this, the scroll range is reduced at smaller detents since the viewport extends beyond the visible area. * refactor: type scrollableOptions and grabberOptions on both platforms Replace NSDictionary/ReadableMap with typed ScrollableOptions and GrabberOptions classes. ViewManager (Android) and TrueSheetView (iOS) normalize raw props into typed objects. * chore: add changelog entries for scrollingExpandsSheet
1 parent 8cd94ff commit 3c26b69

26 files changed

Lines changed: 310 additions & 90 deletions

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,19 @@
22

33
## Unreleased
44

5+
### 🎉 New features
6+
7+
- Add `scrollingExpandsSheet` option to `scrollableOptions`. ([#585](https://github.com/lodev09/react-native-true-sheet/pull/585) by [@lodev09](https://github.com/lodev09))
8+
59
### 🐛 Bug fixes
610

711
- **iOS**: Fixed position change not emitting when detent or index changed. ([#584](https://github.com/lodev09/react-native-true-sheet/pull/584) by [@lodev09](https://github.com/lodev09))
812
- **Android**: Use RN `BackHandler` for back press detection for reliability across Android versions. ([#580](https://github.com/lodev09/react-native-true-sheet/pull/580) by [@lodev09](https://github.com/lodev09))
913

14+
### ⚠️ Breaking
15+
16+
- **Android**: `nestedScrollingEnabled` is now automatically managed when `scrollable` is enabled. ([#585](https://github.com/lodev09/react-native-true-sheet/pull/585))
17+
1018
## 3.9.9
1119

1220
### 🐛 Bug fixes

android/src/main/java/com/lodev09/truesheet/TrueSheetContainerView.kt

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ package com.lodev09.truesheet
22

33
import android.annotation.SuppressLint
44
import android.view.View
5-
import com.facebook.react.bridge.ReadableMap
65
import com.facebook.react.uimanager.ThemedReactContext
76
import com.facebook.react.uimanager.events.EventDispatcher
87
import com.facebook.react.views.view.ReactViewGroup
@@ -40,7 +39,7 @@ class TrueSheetContainerView(reactContext: ThemedReactContext) :
4039
var insetAdjustment: TrueSheetInsetAdjustment = TrueSheetInsetAdjustment.AUTOMATIC
4140
var scrollViewBottomInset: Int = 0
4241
var scrollableEnabled: Boolean = false
43-
var scrollableOptions: ReadableMap? = null
42+
var scrollableOptions: ScrollableOptions? = null
4443
set(value) {
4544
field = value
4645
contentView?.scrollableOptions = value

android/src/main/java/com/lodev09/truesheet/TrueSheetContentView.kt

Lines changed: 44 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,21 @@ import android.annotation.SuppressLint
44
import android.view.View
55
import android.view.ViewGroup
66
import android.widget.ScrollView
7-
import com.facebook.react.bridge.ReadableMap
7+
import androidx.core.widget.NestedScrollView
8+
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
89
import com.facebook.react.uimanager.PixelUtil.dpToPx
910
import com.facebook.react.uimanager.ThemedReactContext
1011
import com.facebook.react.views.view.ReactViewGroup
1112
import com.lodev09.truesheet.core.TrueSheetKeyboardObserver
1213
import com.lodev09.truesheet.core.TrueSheetKeyboardObserverDelegate
1314
import com.lodev09.truesheet.utils.isDescendantOf
15+
import com.lodev09.truesheet.utils.smoothScrollBy
16+
import com.lodev09.truesheet.utils.smoothScrollTo
17+
18+
data class ScrollableOptions(
19+
val keyboardScrollOffset: Float = 0f,
20+
val scrollingExpandsSheet: Boolean = true
21+
)
1422

1523
/**
1624
* Delegate interface for content view size changes
@@ -32,17 +40,18 @@ class TrueSheetContentView(private val reactContext: ThemedReactContext) : React
3240
private var lastWidth = 0
3341
private var lastHeight = 0
3442

35-
private var pinnedScrollView: ScrollView? = null
43+
private var pinnedScrollView: ViewGroup? = null
3644
private var originalScrollViewPaddingBottom: Int = 0
3745
private var bottomInset: Int = 0
46+
private var scrollExpansionPadding: Int = 0
3847

3948
private var keyboardScrollOffset: Float = 0f
4049
private var keyboardObserver: TrueSheetKeyboardObserver? = null
4150

42-
var scrollableOptions: ReadableMap? = null
51+
var scrollableOptions: ScrollableOptions? = null
4352
set(value) {
4453
field = value
45-
keyboardScrollOffset = value?.getDouble("keyboardScrollOffset")?.toFloat()?.dpToPx() ?: 0f
54+
keyboardScrollOffset = value?.keyboardScrollOffset?.dpToPx() ?: 0f
4655
}
4756

4857
override fun addView(child: View?, index: Int) {
@@ -94,6 +103,9 @@ class TrueSheetContentView(private val reactContext: ThemedReactContext) : React
94103
originalScrollViewPaddingBottom = scrollView.paddingBottom
95104
pinnedScrollView = scrollView
96105

106+
scrollView.isNestedScrollingEnabled = true
107+
(scrollView.parent as? SwipeRefreshLayout)?.isNestedScrollingEnabled = false
108+
97109
scrollView.setOnScrollChangeListener { _, _, scrollY, _, oldScrollY ->
98110
if (scrollY != oldScrollY) {
99111
delegate?.contentViewDidScroll()
@@ -112,33 +124,49 @@ class TrueSheetContentView(private val reactContext: ThemedReactContext) : React
112124
}
113125
}
114126

127+
// TODO: Replace this workaround with synchronous state layout updates on every sheet resize.
128+
// The container is currently sized to the largest detent, so at smaller detents the ScrollView
129+
// viewport extends beyond the visible area, reducing the effective scroll range. This padding
130+
// compensates for that difference until we can resize the container per-detent synchronously.
131+
fun updateScrollExpansionPadding(padding: Int) {
132+
if (scrollExpansionPadding == padding) return
133+
scrollExpansionPadding = padding
134+
val keyboardHeight = keyboardObserver?.currentHeight ?: 0
135+
val basePadding = if (keyboardHeight > 0) keyboardHeight else bottomInset
136+
setScrollViewPaddingBottom(originalScrollViewPaddingBottom + basePadding)
137+
nudgeScrollView()
138+
}
139+
115140
private fun setScrollViewPaddingBottom(paddingBottom: Int) {
116141
val scrollView = pinnedScrollView ?: return
117142
scrollView.clipToPadding = false
118143
scrollView.setPadding(
119144
scrollView.paddingLeft,
120145
scrollView.paddingTop,
121146
scrollView.paddingRight,
122-
paddingBottom
147+
paddingBottom + scrollExpansionPadding
123148
)
124149
}
125150

126151
fun clearScrollable() {
127152
pinnedScrollView?.setOnScrollChangeListener(null as View.OnScrollChangeListener?)
153+
pinnedScrollView?.isNestedScrollingEnabled = false
154+
(pinnedScrollView?.parent as? SwipeRefreshLayout)?.isNestedScrollingEnabled = true
155+
scrollExpansionPadding = 0
128156
setScrollViewPaddingBottom(originalScrollViewPaddingBottom)
129157
pinnedScrollView = null
130158
originalScrollViewPaddingBottom = 0
131159
bottomInset = 0
132160
}
133161

134-
fun findScrollView(): ScrollView? {
162+
fun findScrollView(): ViewGroup? {
135163
if (pinnedScrollView != null) return pinnedScrollView
136164
return findScrollView(this as View)
137165
}
138166

139-
private fun findScrollView(view: View): ScrollView? {
140-
if (view is ScrollView) {
141-
return view
167+
private fun findScrollView(view: View): ViewGroup? {
168+
if (view is ScrollView || view is NestedScrollView) {
169+
return view as ViewGroup
142170
}
143171

144172
if (view is ViewGroup) {
@@ -191,11 +219,13 @@ class TrueSheetContentView(private val reactContext: ThemedReactContext) : React
191219
val totalBottomInset = if (keyboardHeight > 0) keyboardHeight else bottomInset
192220
setScrollViewPaddingBottom(originalScrollViewPaddingBottom + totalBottomInset)
193221

194-
// Trigger a scroll to force update
195-
scrollView.post {
196-
scrollView.smoothScrollBy(0, 1)
197-
scrollView.smoothScrollBy(0, -1)
198-
}
222+
scrollView.post { nudgeScrollView() }
223+
}
224+
225+
private fun nudgeScrollView() {
226+
val scrollView = pinnedScrollView ?: return
227+
scrollView.smoothScrollBy(0, 1)
228+
scrollView.smoothScrollBy(0, -1)
199229
}
200230

201231
private fun scrollToFocusedInput() {

android/src/main/java/com/lodev09/truesheet/TrueSheetView.kt

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ import android.view.ViewGroup
66
import android.view.accessibility.AccessibilityEvent
77
import androidx.annotation.UiThread
88
import com.facebook.react.bridge.LifecycleEventListener
9-
import com.facebook.react.bridge.ReadableMap
109
import com.facebook.react.bridge.WritableNativeMap
1110
import com.facebook.react.uimanager.PixelUtil.pxToDp
1211
import com.facebook.react.uimanager.StateWrapper
@@ -282,7 +281,7 @@ class TrueSheetView(private val reactContext: ThemedReactContext) :
282281
setupScrollable()
283282
}
284283

285-
fun setScrollableOptions(options: ReadableMap?) {
284+
fun setScrollableOptions(options: ScrollableOptions?) {
286285
viewController.scrollableOptions = options
287286
setupScrollable()
288287
}

android/src/main/java/com/lodev09/truesheet/TrueSheetViewController.kt

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,10 @@ import android.view.View
88
import android.view.ViewGroup
99
import android.view.accessibility.AccessibilityNodeInfo
1010
import android.widget.ImageView
11-
import android.widget.ScrollView
1211
import androidx.coordinatorlayout.widget.CoordinatorLayout
1312
import androidx.core.graphics.createBitmap
1413
import androidx.core.view.isNotEmpty
1514
import com.facebook.react.R
16-
import com.facebook.react.bridge.ReadableMap
1715
import com.facebook.react.uimanager.JSPointerDispatcher
1816
import com.facebook.react.uimanager.JSTouchDispatcher
1917
import com.facebook.react.uimanager.PixelUtil.dpToPx
@@ -24,6 +22,7 @@ import com.facebook.react.uimanager.events.EventDispatcher
2422
import com.facebook.react.util.RNLog
2523
import com.facebook.react.views.view.ReactViewGroup
2624
import com.google.android.material.bottomsheet.BottomSheetBehavior
25+
import com.lodev09.truesheet.core.TrueSheetBottomSheetBehavior
2726
import com.lodev09.truesheet.core.GrabberOptions
2827
import com.lodev09.truesheet.core.TrueSheetBottomSheetView
2928
import com.lodev09.truesheet.core.TrueSheetBottomSheetViewDelegate
@@ -208,7 +207,12 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
208207

209208
var scrollable: Boolean = false
210209

211-
var scrollableOptions: ReadableMap? = null
210+
var scrollableOptions: ScrollableOptions? = null
211+
set(value) {
212+
field = value
213+
behavior?.scrollingExpandsSheet = value?.scrollingExpandsSheet ?: true
214+
if (isPresented) sheetView?.let { updateScrollExpansionPadding(it.top) }
215+
}
212216

213217
override var sheetCornerRadius: Float = DEFAULT_CORNER_RADIUS.dpToPx()
214218
set(value) {
@@ -240,7 +244,7 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
240244
// =============================================================================
241245

242246
// Behavior
243-
private val behavior: BottomSheetBehavior<TrueSheetBottomSheetView>?
247+
private val behavior: TrueSheetBottomSheetBehavior<TrueSheetBottomSheetView>?
244248
get() = sheetView?.behavior
245249

246250
internal val containerView: TrueSheetContainerView?
@@ -425,7 +429,7 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
425429
sheetView?.let { emitChangePositionDelegate(it.top, realtime = false) }
426430
}
427431

428-
override fun findScrollView(): ScrollView? = containerView?.contentView?.findScrollView()
432+
override fun findScrollView(): ViewGroup? = containerView?.contentView?.findScrollView()
429433
override fun findSheetView(): TrueSheetBottomSheetView? = sheetView
430434

431435
// =============================================================================
@@ -524,6 +528,7 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
524528
else -> { }
525529
}
526530

531+
updateScrollExpansionPadding(sheetView.top)
527532
emitChangePositionDelegate(sheetView.top)
528533

529534
// On older APIs, use onSlide for footer positioning during keyboard transitions
@@ -537,6 +542,15 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
537542
}
538543
}
539544

545+
private fun updateScrollExpansionPadding(sheetTop: Int) {
546+
if (!scrollable) {
547+
containerView?.contentView?.updateScrollExpansionPadding(0)
548+
return
549+
}
550+
val expandedOffset = behavior?.expandedOffset ?: return
551+
containerView?.contentView?.updateScrollExpansionPadding(maxOf(0, sheetTop - expandedOffset))
552+
}
553+
540554
private fun handleStateSettled(sheetView: View, newState: Int) {
541555
if (interactionState is InteractionState.Reconfiguring) return
542556

@@ -696,11 +710,12 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
696710
val params = sheet.createLayoutParams()
697711

698712
@Suppress("UNCHECKED_CAST")
699-
val behavior = params.behavior as BottomSheetBehavior<TrueSheetBottomSheetView>
713+
val behavior = params.behavior as TrueSheetBottomSheetBehavior<TrueSheetBottomSheetView>
700714

701715
// Configure behavior
702716
behavior.isHideable = true
703717
behavior.isDraggable = draggable
718+
behavior.scrollingExpandsSheet = scrollableOptions?.scrollingExpandsSheet ?: true
704719
behavior.state = BottomSheetBehavior.STATE_HIDDEN
705720
behavior.addBottomSheetCallback(sheetCallback)
706721

android/src/main/java/com/lodev09/truesheet/TrueSheetViewManager.kt

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -218,7 +218,16 @@ class TrueSheetViewManager :
218218

219219
@ReactProp(name = "scrollableOptions")
220220
override fun setScrollableOptions(view: TrueSheetView, options: ReadableMap?) {
221-
view.setScrollableOptions(options)
221+
if (options == null) {
222+
view.setScrollableOptions(null)
223+
return
224+
}
225+
226+
val scrollableOptions = ScrollableOptions(
227+
keyboardScrollOffset = if (options.hasKey("keyboardScrollOffset")) options.getDouble("keyboardScrollOffset").toFloat() else 0f,
228+
scrollingExpandsSheet = if (options.hasKey("scrollingExpandsSheet")) options.getBoolean("scrollingExpandsSheet") else true
229+
)
230+
view.setScrollableOptions(scrollableOptions)
222231
}
223232

224233
companion object {
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
package com.lodev09.truesheet.core
2+
3+
import android.view.View
4+
import androidx.coordinatorlayout.widget.CoordinatorLayout
5+
import com.google.android.material.bottomsheet.BottomSheetBehavior
6+
7+
class TrueSheetBottomSheetBehavior<V : View> : BottomSheetBehavior<V>() {
8+
var scrollingExpandsSheet: Boolean = true
9+
10+
override fun onNestedPreScroll(
11+
coordinatorLayout: CoordinatorLayout,
12+
child: V,
13+
target: View,
14+
dx: Int,
15+
dy: Int,
16+
consumed: IntArray,
17+
type: Int
18+
) {
19+
// dy > 0 = user swiping up = sheet expanding
20+
// Block expansion from scroll, but allow if sheet is already being dragged
21+
if (!scrollingExpandsSheet && dy > 0 && state != STATE_DRAGGING) return
22+
super.onNestedPreScroll(coordinatorLayout, child, target, dx, dy, consumed, type)
23+
}
24+
25+
override fun onNestedPreFling(
26+
coordinatorLayout: CoordinatorLayout,
27+
child: V,
28+
target: View,
29+
velocityX: Float,
30+
velocityY: Float
31+
): Boolean {
32+
// Don't consume flings — let the ScrollView decelerate naturally
33+
if (!scrollingExpandsSheet) return false
34+
return super.onNestedPreFling(coordinatorLayout, child, target, velocityX, velocityY)
35+
}
36+
}

android/src/main/java/com/lodev09/truesheet/core/TrueSheetBottomSheetView.kt

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -63,9 +63,9 @@ class TrueSheetBottomSheetView(private val reactContext: ThemedReactContext) : F
6363

6464
// Behavior reference (set after adding to CoordinatorLayout)
6565
@Suppress("UNCHECKED_CAST")
66-
val behavior: BottomSheetBehavior<TrueSheetBottomSheetView>?
66+
val behavior: TrueSheetBottomSheetBehavior<TrueSheetBottomSheetView>?
6767
get() = (layoutParams as? CoordinatorLayout.LayoutParams)
68-
?.behavior as? BottomSheetBehavior<TrueSheetBottomSheetView>
68+
?.behavior as? TrueSheetBottomSheetBehavior<TrueSheetBottomSheetView>
6969

7070
// =============================================================================
7171
// MARK: - Initialization
@@ -106,7 +106,7 @@ class TrueSheetBottomSheetView(private val reactContext: ThemedReactContext) : F
106106
fun createLayoutParams(): CoordinatorLayout.LayoutParams {
107107
val applyMaxWidth = delegate?.maxContentWidth != null && !ScreenUtils.isPortraitPhone(reactContext)
108108
val effectiveMaxWidth = if (applyMaxWidth) delegate!!.maxContentWidth!! else DEFAULT_MAX_WIDTH.dpToPx().toInt()
109-
val behavior = BottomSheetBehavior<TrueSheetBottomSheetView>().apply {
109+
val behavior = TrueSheetBottomSheetBehavior<TrueSheetBottomSheetView>().apply {
110110
isHideable = true
111111
maxWidth = effectiveMaxWidth
112112
}

android/src/main/java/com/lodev09/truesheet/core/TrueSheetCoordinatorLayout.kt

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import android.content.Context
55
import android.content.res.Configuration
66
import android.view.MotionEvent
77
import android.view.ViewConfiguration
8-
import android.widget.ScrollView
8+
import android.view.ViewGroup
99
import androidx.coordinatorlayout.widget.CoordinatorLayout
1010
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
1111
import com.facebook.react.uimanager.PointerEvents
@@ -15,7 +15,7 @@ import com.lodev09.truesheet.utils.isDescendantOf
1515
interface TrueSheetCoordinatorLayoutDelegate {
1616
fun coordinatorLayoutDidLayout(changed: Boolean)
1717
fun coordinatorLayoutDidChangeConfiguration()
18-
fun findScrollView(): ScrollView?
18+
fun findScrollView(): ViewGroup?
1919
fun findSheetView(): TrueSheetBottomSheetView?
2020
}
2121

@@ -75,7 +75,7 @@ class TrueSheetCoordinatorLayout(context: Context) :
7575
val sheet = delegate?.findSheetView() ?: return
7676
val behavior = sheet.behavior ?: return
7777
try {
78-
val field = behavior.javaClass.getDeclaredField("nestedScrollingChildRef")
78+
val field = behavior.javaClass.superclass.getDeclaredField("nestedScrollingChildRef")
7979
field.isAccessible = true
8080
@Suppress("UNCHECKED_CAST")
8181
val ref = field.get(behavior) as? java.lang.ref.WeakReference<android.view.View> ?: return
@@ -105,6 +105,7 @@ class TrueSheetCoordinatorLayout(context: Context) :
105105
scrollView.scrollY == 0 &&
106106
!scrollView.canScrollVertically(1)
107107

108+
108109
if (cannotScroll) {
109110
when (ev.action and MotionEvent.ACTION_MASK) {
110111
MotionEvent.ACTION_DOWN -> {

0 commit comments

Comments
 (0)