@lodev09/react-native-true-sheet 4.0.0-beta.3 → 4.0.0-beta.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/README.md +21 -2
  2. package/android/src/main/java/com/lodev09/truesheet/TrueSheetContainerView.kt +24 -7
  3. package/android/src/main/java/com/lodev09/truesheet/TrueSheetContentView.kt +195 -174
  4. package/android/src/main/java/com/lodev09/truesheet/TrueSheetContentViewManager.kt +5 -1
  5. package/android/src/main/java/com/lodev09/truesheet/TrueSheetFooterView.kt +6 -17
  6. package/android/src/main/java/com/lodev09/truesheet/TrueSheetView.kt +50 -8
  7. package/android/src/main/java/com/lodev09/truesheet/TrueSheetViewController.kt +101 -18
  8. package/android/src/main/java/com/lodev09/truesheet/TrueSheetViewManager.kt +24 -1
  9. package/android/src/main/java/com/lodev09/truesheet/core/TrueSheetCoordinatorLayout.kt +3 -3
  10. package/android/src/main/java/com/lodev09/truesheet/core/TrueSheetDetentCalculator.kt +14 -24
  11. package/android/src/main/java/com/lodev09/truesheet/utils/ViewUtils.kt +0 -7
  12. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetContentViewComponentDescriptor.h +0 -8
  13. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetContentViewShadowNode.cpp +54 -29
  14. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetContentViewShadowNode.h +6 -1
  15. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetContentViewState.cpp +1 -1
  16. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetContentViewState.h +6 -4
  17. package/ios/TrueSheetContainerView.h +9 -7
  18. package/ios/TrueSheetContainerView.mm +6 -7
  19. package/ios/TrueSheetContentView.h +28 -14
  20. package/ios/TrueSheetContentView.mm +122 -179
  21. package/ios/TrueSheetFooterView.mm +17 -0
  22. package/ios/TrueSheetView.mm +13 -10
  23. package/ios/TrueSheetViewController.mm +35 -27
  24. package/ios/utils/UIView+ScrollEdgeInteraction.h +1 -0
  25. package/ios/utils/UIView+ScrollEdgeInteraction.mm +24 -2
  26. package/lib/module/TrueSheet.js +47 -14
  27. package/lib/module/TrueSheet.js.map +1 -1
  28. package/lib/module/TrueSheet.web.js +26 -24
  29. package/lib/module/TrueSheet.web.js.map +1 -1
  30. package/lib/module/fabric/TrueSheetContentViewNativeComponent.ts +1 -1
  31. package/lib/module/fabric/TrueSheetViewNativeComponent.ts +4 -0
  32. package/lib/typescript/src/TrueSheet.d.ts +2 -0
  33. package/lib/typescript/src/TrueSheet.d.ts.map +1 -1
  34. package/lib/typescript/src/TrueSheet.types.d.ts +64 -1
  35. package/lib/typescript/src/TrueSheet.types.d.ts.map +1 -1
  36. package/lib/typescript/src/TrueSheet.web.d.ts.map +1 -1
  37. package/lib/typescript/src/fabric/TrueSheetViewNativeComponent.d.ts +3 -0
  38. package/lib/typescript/src/fabric/TrueSheetViewNativeComponent.d.ts.map +1 -1
  39. package/lib/typescript/src/navigation/types.d.ts +1 -1
  40. package/lib/typescript/src/navigation/types.d.ts.map +1 -1
  41. package/package.json +1 -1
  42. package/src/TrueSheet.tsx +48 -13
  43. package/src/TrueSheet.types.ts +68 -1
  44. package/src/TrueSheet.web.tsx +39 -24
  45. package/src/fabric/TrueSheetContentViewNativeComponent.ts +1 -1
  46. package/src/fabric/TrueSheetViewNativeComponent.ts +4 -0
  47. package/src/navigation/types.ts +1 -0
package/README.md CHANGED
@@ -22,6 +22,23 @@ The true native bottom sheet experience for your React Native Apps. 💩
22
22
  * 🧭 **React Navigation** - Built-in [sheet navigator](https://sheet.lodev09.com/guides/navigation) for seamless navigation integration
23
23
  * 🌐 **Web Support** - Full [web support](https://sheet.lodev09.com/guides/web) out of the box
24
24
 
25
+ ## 🎉 What's New in v4
26
+
27
+ > [!TIP]
28
+ > ✨ **v4 is in beta!** It rewrites the layout engine — the sheet now lays out synchronously per detent, so the container is sized to the sheet's visible height and tracks it in realtime while dragging.
29
+
30
+ * 📏 **Synchronous layout** - Content resizes in the same frame as the sheet, no more lag
31
+ * 📐 **`auto` detent with scrollables** - The sheet now sizes itself to a `ScrollView`/`FlatList`'s content height
32
+ * 📜 **`scrollableRef`** - Pass a `ScrollView`/`FlatList` ref to wire nested scrolling, keyboard insets, and `auto` detent sizing
33
+ * 🦶 **Relative footers** - Footers take up space below the content by default
34
+ * 🧭 **Expo Router** - First-class support via the `Sheet` layout from `/navigation/expo-router`
35
+
36
+ ```sh
37
+ npx expo install @lodev09/react-native-true-sheet@beta
38
+ ```
39
+
40
+ See the [Migration Guide](docs/docs/migration.mdx) to upgrade. **v3 is still supported** for React Native 0.81+ and remains the `latest` release on npm.
41
+
25
42
  ## Installation
26
43
 
27
44
  > [!IMPORTANT]
@@ -30,7 +47,7 @@ The true native bottom sheet experience for your React Native Apps. 💩
30
47
 
31
48
  ### Prerequisites
32
49
 
33
- - React Native 0.82+
50
+ - React Native 0.82+ (0.81+ on v3)
34
51
  - New Architecture enabled
35
52
  - Xcode 26.1+
36
53
 
@@ -55,6 +72,8 @@ yarn add @lodev09/react-native-true-sheet
55
72
  cd ios && pod install
56
73
  ```
57
74
 
75
+ These install v3. For v4, append `@beta` — see [🎉 What's New in v4](#-whats-new-in-v4).
76
+
58
77
  ## Documentation
59
78
 
60
79
  - [Example](example)
@@ -63,7 +82,7 @@ cd ios && pod install
63
82
  - [React Navigation](https://sheet.lodev09.com/guides/navigation)
64
83
  - [Troubleshooting](https://sheet.lodev09.com/troubleshooting)
65
84
  - [Testing with Jest](https://sheet.lodev09.com/guides/jest)
66
- - [Migrating to v3](https://sheet.lodev09.com/migration)
85
+ - [Migrating to v3](https://sheet.lodev09.com/migration-v3)
67
86
 
68
87
  ## Usage
69
88
 
@@ -61,8 +61,6 @@ class TrueSheetContainerView(reactContext: ThemedReactContext) :
61
61
  return bottom
62
62
  }
63
63
 
64
- var insetAdjustment: TrueSheetInsetAdjustment = TrueSheetInsetAdjustment.AUTOMATIC
65
- var scrollViewBottomInset: Int = 0
66
64
  var absoluteFooter: Boolean = false
67
65
 
68
66
  /**
@@ -80,10 +78,21 @@ class TrueSheetContainerView(reactContext: ThemedReactContext) :
80
78
  contentView?.scrollableOptions = value
81
79
  }
82
80
 
83
- var hasAutoDetent = false
81
+ // React tag of the user-provided scrollable within the content (see the scrollableRef prop)
82
+ var scrollableHandle: Int = -1
84
83
  set(value) {
85
84
  field = value
86
- contentView?.hasAutoDetent = value
85
+ contentView?.scrollableHandle = value
86
+ }
87
+
88
+ /**
89
+ * Bottom safe-area inset the plugged scrollable applies as content padding
90
+ * while it can scroll (see ScrollableOptions.contentInsetAdjustment)
91
+ */
92
+ var scrollableBottomInset: Int = 0
93
+ set(value) {
94
+ field = value
95
+ contentView?.bottomInset = value
87
96
  }
88
97
 
89
98
  override val eventDispatcher: EventDispatcher?
@@ -96,8 +105,7 @@ class TrueSheetContainerView(reactContext: ThemedReactContext) :
96
105
  }
97
106
 
98
107
  fun setupScrollable() {
99
- val bottomInset = if (insetAdjustment == TrueSheetInsetAdjustment.AUTOMATIC) scrollViewBottomInset else 0
100
- contentView?.setupScrollable(bottomInset)
108
+ contentView?.setupScrollable()
101
109
  }
102
110
 
103
111
  fun setupKeyboardHandler() {
@@ -115,9 +123,13 @@ class TrueSheetContainerView(reactContext: ThemedReactContext) :
115
123
  is TrueSheetContentView -> {
116
124
  child.delegate = this
117
125
  child.scrollableOptions = scrollableOptions
118
- child.hasAutoDetent = hasAutoDetent
126
+ child.scrollableHandle = scrollableHandle
127
+ child.bottomInset = scrollableBottomInset
119
128
  contentView = child
120
129
 
130
+ // State lands before the view is attached, so pull the current value
131
+ contentHeight = child.naturalHeight
132
+
121
133
  // Children mount bottom-up, so the content subtree is complete here.
122
134
  // Late-mounted peek views attach themselves instead (see TrueSheetPeekView).
123
135
  findPeekView(child)?.let { attachPeekView(it) }
@@ -205,6 +217,9 @@ class TrueSheetContainerView(reactContext: ThemedReactContext) :
205
217
  override val footerKeyboardOcclusion: Int
206
218
  get() = if (absoluteFooter) footerView?.keyboardOcclusionHeight ?: 0 else -(footerView?.height ?: 0)
207
219
 
220
+ override val hasRelativeFooter: Boolean
221
+ get() = footerView != null && !absoluteFooter
222
+
208
223
  override fun contentViewDidScroll() {
209
224
  delegate?.containerViewContentDidScroll()
210
225
  }
@@ -221,6 +236,8 @@ class TrueSheetContainerView(reactContext: ThemedReactContext) :
221
236
  override fun footerViewDidChangeSize(width: Int, height: Int) {
222
237
  footerHeight = height
223
238
  delegate?.containerViewFooterDidChangeSize(width, height)
239
+ // A relative footer takes over the bottom inset — drop the scrollable's
240
+ contentView?.updateContentInset()
224
241
  }
225
242
 
226
243
  override fun peekViewDidChangeSize(width: Int, height: Int) {
@@ -1,26 +1,27 @@
1
1
  package com.lodev09.truesheet
2
2
 
3
3
  import android.annotation.SuppressLint
4
+ import android.os.Build
4
5
  import android.text.Editable
5
6
  import android.text.TextWatcher
6
7
  import android.view.View
7
8
  import android.view.ViewGroup
8
9
  import android.widget.EditText
9
- import android.widget.ScrollView
10
- import androidx.core.widget.NestedScrollView
11
10
  import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
12
- import com.facebook.react.bridge.WritableNativeMap
13
11
  import com.facebook.react.uimanager.PixelUtil.dpToPx
14
- import com.facebook.react.uimanager.StateWrapper
15
12
  import com.facebook.react.uimanager.ThemedReactContext
16
13
  import com.facebook.react.views.view.ReactViewGroup
17
14
  import com.lodev09.truesheet.core.TrueSheetKeyboardObserver
18
15
  import com.lodev09.truesheet.core.TrueSheetKeyboardObserverDelegate
19
16
  import com.lodev09.truesheet.utils.isDescendantOf
20
- import com.lodev09.truesheet.utils.smoothScrollBy
21
17
  import com.lodev09.truesheet.utils.smoothScrollTo
22
18
 
23
- data class ScrollableOptions(val keyboardScrollOffset: Float = 0f, val scrollingExpandsSheet: Boolean = true)
19
+ data class ScrollableOptions(
20
+ val keyboardScrollOffset: Float = 0f,
21
+ val keyboardOffset: Float = 0f,
22
+ val scrollingExpandsSheet: Boolean = true,
23
+ val contentInsetAdjustment: Boolean = true
24
+ )
24
25
 
25
26
  /**
26
27
  * Delegate interface for content view size changes
@@ -32,10 +33,19 @@ interface TrueSheetContentViewDelegate {
32
33
 
33
34
  /**
34
35
  * Keyboard occlusion adjustment below the content — positive for an absolute
35
- * footer risen above the keyboard covering the content's bottom edge,
36
- * negative for a relative footer sitting behind the keyboard shielding it.
36
+ * footer floating over the viewport's bottom edge (extends the caret reveal),
37
+ * negative for a relative footer sitting behind the keyboard shielding that
38
+ * much of its overlap (reduces the keyboard padding).
37
39
  */
38
40
  val footerKeyboardOcclusion: Int
41
+
42
+ /**
43
+ * A relative footer sits below the content and absorbs the bottom safe-area
44
+ * inset — the scrollable doesn't reach the sheet's bottom edge, so no
45
+ * content inset is applied (mirrors iOS, where UIKit resolves this
46
+ * geometrically via the scroll view's safe area).
47
+ */
48
+ val hasRelativeFooter: Boolean
39
49
  }
40
50
 
41
51
  /**
@@ -45,44 +55,43 @@ interface TrueSheetContentViewDelegate {
45
55
  @SuppressLint("ViewConstructor")
46
56
  class TrueSheetContentView(private val reactContext: ThemedReactContext) : ReactViewGroup(reactContext) {
47
57
  var delegate: TrueSheetContentViewDelegate? = null
48
- var stateWrapper: StateWrapper? = null
49
58
 
50
- private var lastWidth = 0
51
- private var lastHeight = 0
52
-
53
- private var pinnedScrollView: ViewGroup? = null
54
- private var observedScrollChild: View? = null
59
+ private var detectedScrollView: ViewGroup? = null
55
60
  private var originalScrollViewPaddingBottom: Int = 0
56
- private var bottomInset: Int = 0
57
- private var scrollableBounded = false
58
- private var isReportPending = false
59
-
60
- // Content growth is invisible to layout once the viewport is bounded, so track
61
- // the scroll content size directly to keep the auto detent height in sync.
62
- private val scrollChildLayoutListener =
63
- View.OnLayoutChangeListener { _, _, top, _, bottom, _, oldTop, _, oldBottom ->
64
- if (bottom - top != oldBottom - oldTop) {
65
- reportSizeIfChanged()
66
- }
67
- }
68
61
 
69
62
  /**
70
- * Content height with the pinned ScrollView's viewport replaced by its content
71
- * size — the height the content wants regardless of container bounds.
72
- * Falls back to the view height when no ScrollView is pinned.
63
+ * Content height measured unconstrained by the shadow node the height the
64
+ * content wants regardless of container bounds (see
65
+ * TrueSheetContentViewShadowNode). Falls back to the view height before the
66
+ * first state update.
73
67
  */
74
68
  val naturalHeight: Int
75
- get() {
76
- var naturalHeight = height
77
- pinnedScrollView?.let { scrollView ->
78
- scrollView.getChildAt(0)?.let { child ->
79
- naturalHeight += child.height - scrollView.height
80
- }
81
- }
82
- return maxOf(0, naturalHeight)
83
- }
69
+ get() = if (lastNaturalHeight > 0) lastNaturalHeight else height
70
+
71
+ private var lastNaturalHeight = 0
72
+
73
+ fun updateNaturalHeight(heightDp: Double) {
74
+ val heightPx = heightDp.toFloat().dpToPx().toInt()
75
+ if (heightPx == lastNaturalHeight) return
76
+ lastNaturalHeight = heightPx
77
+ delegate?.contentViewDidChangeSize(width, heightPx)
78
+ }
84
79
 
85
80
  private var keyboardScrollOffset: Float = 0f
81
+
82
+ /**
83
+ * Adjustment added to the keyboard bottom inset applied to the detected
84
+ * scrollable — negative values reduce the inset (e.g. to cancel out safe-area
85
+ * padding already baked into the content's paddingBottom).
86
+ */
87
+ private var keyboardOffset: Float = 0f
88
+
89
+ /**
90
+ * How much of keyboardOffset actually landed in the applied padding — the
91
+ * caret reveal compensates by this so it still targets the real keyboard edge.
92
+ */
93
+ private var appliedKeyboardOffset = 0
94
+
86
95
  private var keyboardObserver: TrueSheetKeyboardObserver? = null
87
96
 
88
97
  private var watchedEditText: EditText? = null
@@ -92,20 +101,40 @@ class TrueSheetContentView(private val reactContext: ThemedReactContext) : React
92
101
  set(value) {
93
102
  field = value
94
103
  keyboardScrollOffset = value?.keyboardScrollOffset?.dpToPx() ?: 0f
104
+ keyboardOffset = value?.keyboardOffset?.dpToPx() ?: 0f
105
+ updateContentInset()
95
106
  }
96
107
 
97
108
  /**
98
- * Whether the sheet has an `auto` detent. Deriving the sheet height from the
99
- * scroll content is circular with natural layout, so the pinned ScrollView's
100
- * viewport is force-bounded to the container only in this case.
109
+ * Bottom safe-area inset applied to the scrollable's content padding while
110
+ * the content can scroll Android counterpart of iOS's
111
+ * `contentInsetAdjustmentBehavior="automatic"`. Already zero when the
112
+ * sheet's `insetAdjustment` is `'never'`.
101
113
  */
102
- var hasAutoDetent = false
114
+ var bottomInset: Int = 0
103
115
  set(value) {
104
116
  if (field == value) return
105
117
  field = value
106
- if (pinnedScrollView != null) {
107
- setScrollableBounded(value)
108
- }
118
+ updateContentInset()
119
+ }
120
+
121
+ private var baseBottomInset = 0
122
+ private var keyboardBottomInset = 0
123
+
124
+ private val scrollableLayoutListener =
125
+ View.OnLayoutChangeListener { _, _, top, _, bottom, _, oldTop, _, oldBottom ->
126
+ if (bottom - top != oldBottom - oldTop) updateContentInset()
127
+ }
128
+
129
+ /**
130
+ * React tag of the user-provided scrollable (see the `scrollableRef` prop).
131
+ * Setting a new handle clears the currently resolved ScrollView.
132
+ */
133
+ var scrollableHandle: Int = -1
134
+ set(value) {
135
+ if (field == value) return
136
+ field = value
137
+ clearScrollable()
109
138
  }
110
139
 
111
140
  override fun addView(child: View?, index: Int) {
@@ -119,98 +148,41 @@ class TrueSheetContentView(private val reactContext: ThemedReactContext) : React
119
148
  }
120
149
 
121
150
  private fun checkScrollViewChanged() {
122
- if (pinnedScrollView == null || pinnedScrollView?.isDescendantOf(this) == false) {
151
+ if (detectedScrollView == null || detectedScrollView?.isDescendantOf(this) == false) {
123
152
  delegate?.contentViewScrollViewDidChange()
124
153
  }
125
154
  }
126
155
 
127
- override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
128
- super.onSizeChanged(w, h, oldw, oldh)
129
- reportSizeIfChanged()
130
- }
131
-
132
- private fun reportSizeIfChanged() {
133
- // naturalHeight mixes sizes from different views; mid-layout they're
134
- // momentarily inconsistent (parent resizes before its children), which
135
- // feeds back into the auto detent and oscillates. Coalesce to the next
136
- // frame so sizes are settled before measuring.
137
- if (pinnedScrollView != null) {
138
- if (isReportPending) return
139
- isReportPending = true
140
-
141
- post {
142
- isReportPending = false
143
- reportSizeNow()
144
- }
145
- return
146
- }
147
-
148
- reportSizeNow()
149
- }
150
-
151
- private fun reportSizeNow() {
152
- val newHeight = naturalHeight
153
- if (width != lastWidth || newHeight != lastHeight) {
154
- lastWidth = width
155
- lastHeight = newHeight
156
- delegate?.contentViewDidChangeSize(width, newHeight)
157
- }
158
- }
159
-
160
- /**
161
- * Tells the shadow node to fill the container (flexGrow/flexShrink) so the
162
- * pinned ScrollView's viewport is bounded to the visible space. Only applied
163
- * for auto detents — otherwise content lays out naturally like a regular view.
164
- */
165
- private fun setScrollableBounded(bounded: Boolean) {
166
- if (scrollableBounded == bounded) return
167
- scrollableBounded = bounded
168
-
169
- stateWrapper?.let {
170
- val newState = WritableNativeMap()
171
- newState.putBoolean("scrollableBounded", bounded)
172
- it.updateState(newState)
173
- }
174
- }
175
-
176
- fun setupScrollable(bottomInset: Int) {
177
- // Check if pinned scroll view is still valid (still in view hierarchy)
178
- if (pinnedScrollView != null && pinnedScrollView?.isDescendantOf(this) == false) {
156
+ fun setupScrollable() {
157
+ // Check if the detected scroll view is still valid (still in view hierarchy)
158
+ if (detectedScrollView != null && detectedScrollView?.isDescendantOf(this) == false) {
179
159
  clearScrollable()
180
160
  }
181
161
 
182
- // Already set up with same inset and valid scroll view
183
- if (pinnedScrollView != null && this.bottomInset == bottomInset) {
162
+ if (detectedScrollView != null) {
184
163
  return
185
164
  }
186
165
 
187
- val scrollView = findScrollView(this) ?: return
166
+ val scrollView = findScrollView() ?: return
188
167
 
189
- // Only capture originals on first pin
190
- if (pinnedScrollView == null) {
191
- originalScrollViewPaddingBottom = scrollView.paddingBottom
192
- pinnedScrollView = scrollView
168
+ originalScrollViewPaddingBottom = scrollView.paddingBottom
169
+ detectedScrollView = scrollView
193
170
 
194
- scrollView.isNestedScrollingEnabled = true
195
- (scrollView.parent as? SwipeRefreshLayout)?.isNestedScrollingEnabled = false
171
+ scrollView.isNestedScrollingEnabled = true
172
+ (scrollView.parent as? SwipeRefreshLayout)?.isNestedScrollingEnabled = false
196
173
 
197
- scrollView.setOnScrollChangeListener { _, _, scrollY, _, oldScrollY ->
198
- if (scrollY != oldScrollY) {
199
- delegate?.contentViewDidScroll()
200
- }
174
+ scrollView.setOnScrollChangeListener { _, _, scrollY, _, oldScrollY ->
175
+ if (scrollY != oldScrollY) {
176
+ delegate?.contentViewDidScroll()
201
177
  }
202
-
203
- scrollView.getChildAt(0)?.let { child ->
204
- observedScrollChild = child
205
- child.addOnLayoutChangeListener(scrollChildLayoutListener)
206
- }
207
- setScrollableBounded(hasAutoDetent)
208
- reportSizeIfChanged()
209
178
  }
210
179
 
211
- this.bottomInset = bottomInset
180
+ // Track viewport and content size changes — the content inset only applies
181
+ // while the content can scroll (mirrors iOS's automatic behavior)
182
+ scrollView.addOnLayoutChangeListener(scrollableLayoutListener)
183
+ scrollView.getChildAt(0)?.addOnLayoutChangeListener(scrollableLayoutListener)
212
184
 
213
- setScrollViewPaddingBottom(originalScrollViewPaddingBottom + bottomInset)
185
+ updateContentInset()
214
186
 
215
187
  // If keyboard is currently showing, re-apply the keyboard inset to the new ScrollView
216
188
  val keyboardHeight = keyboardObserver?.currentHeight ?: 0
@@ -219,8 +191,38 @@ class TrueSheetContentView(private val reactContext: ThemedReactContext) : React
219
191
  }
220
192
  }
221
193
 
194
+ /**
195
+ * Re-applies the base content inset — the bottom safe-area inset while the
196
+ * content can scroll, no relative footer absorbs it, and
197
+ * `contentInsetAdjustmentBehavior` isn't disabled.
198
+ */
199
+ fun updateContentInset() {
200
+ val scrollView = detectedScrollView ?: return
201
+
202
+ val enabled = scrollableOptions?.contentInsetAdjustment ?: true
203
+ baseBottomInset = if (enabled && delegate?.hasRelativeFooter != true && canScrollContent(scrollView)) {
204
+ bottomInset
205
+ } else {
206
+ 0
207
+ }
208
+
209
+ applyBottomPadding()
210
+ }
211
+
212
+ // Measured against the original padding so the applied inset doesn't feed
213
+ // back into the check
214
+ private fun canScrollContent(scrollView: ViewGroup): Boolean {
215
+ val child = scrollView.getChildAt(0) ?: return false
216
+ return child.height > scrollView.height - scrollView.paddingTop - originalScrollViewPaddingBottom
217
+ }
218
+
219
+ // The keyboard covers the safe-area region, so the larger inset wins over a sum
220
+ private fun applyBottomPadding() {
221
+ setScrollViewPaddingBottom(originalScrollViewPaddingBottom + maxOf(baseBottomInset, keyboardBottomInset))
222
+ }
223
+
222
224
  private fun setScrollViewPaddingBottom(paddingBottom: Int) {
223
- val scrollView = pinnedScrollView ?: return
225
+ val scrollView = detectedScrollView ?: return
224
226
  scrollView.clipToPadding = false
225
227
  scrollView.setPadding(
226
228
  scrollView.paddingLeft,
@@ -231,39 +233,27 @@ class TrueSheetContentView(private val reactContext: ThemedReactContext) : React
231
233
  }
232
234
 
233
235
  fun clearScrollable() {
234
- pinnedScrollView?.setOnScrollChangeListener(null as View.OnScrollChangeListener?)
235
- pinnedScrollView?.isNestedScrollingEnabled = false
236
- (pinnedScrollView?.parent as? SwipeRefreshLayout)?.isNestedScrollingEnabled = true
236
+ detectedScrollView?.setOnScrollChangeListener(null as View.OnScrollChangeListener?)
237
+ detectedScrollView?.removeOnLayoutChangeListener(scrollableLayoutListener)
238
+ detectedScrollView?.getChildAt(0)?.removeOnLayoutChangeListener(scrollableLayoutListener)
239
+ detectedScrollView?.isNestedScrollingEnabled = false
240
+ (detectedScrollView?.parent as? SwipeRefreshLayout)?.isNestedScrollingEnabled = true
237
241
  setScrollViewPaddingBottom(originalScrollViewPaddingBottom)
238
- observedScrollChild?.removeOnLayoutChangeListener(scrollChildLayoutListener)
239
- observedScrollChild = null
240
- setScrollableBounded(false)
241
- pinnedScrollView = null
242
+ detectedScrollView = null
242
243
  originalScrollViewPaddingBottom = 0
243
- bottomInset = 0
244
- reportSizeIfChanged()
244
+ baseBottomInset = 0
245
+ keyboardBottomInset = 0
246
+ appliedKeyboardOffset = 0
245
247
  }
246
248
 
249
+ /**
250
+ * Resolves the user-provided `scrollableHandle` within the content subtree —
251
+ * React tags are view ids on Android.
252
+ */
247
253
  fun findScrollView(): ViewGroup? {
248
- if (pinnedScrollView != null) return pinnedScrollView
249
- return findScrollView(this as View)
250
- }
251
-
252
- private fun findScrollView(view: View): ViewGroup? {
253
- if (view is ScrollView || view is NestedScrollView) {
254
- return view as ViewGroup
255
- }
256
-
257
- if (view is ViewGroup) {
258
- for (i in 0 until view.childCount) {
259
- val scrollView = findScrollView(view.getChildAt(i))
260
- if (scrollView != null) {
261
- return scrollView
262
- }
263
- }
264
- }
265
-
266
- return null
254
+ if (detectedScrollView != null) return detectedScrollView
255
+ if (scrollableHandle <= 0) return null
256
+ return findViewById<View>(scrollableHandle) as? ViewGroup
267
257
  }
268
258
 
269
259
  // ==================== Keyboard Handling ====================
@@ -273,8 +263,19 @@ class TrueSheetContentView(private val reactContext: ThemedReactContext) : React
273
263
 
274
264
  keyboardObserver = TrueSheetKeyboardObserver(this, reactContext).apply {
275
265
  delegate = object : TrueSheetKeyboardObserverDelegate {
276
- override fun keyboardWillShow(height: Int) {
266
+ // Drive the inset per keyboard animation frame so the content tracks
267
+ // the keyboard edge instead of jumping to the final inset up front.
268
+ // On hide, the shrinking inset gradually clamps the scroll position
269
+ // back in sync with the keyboard.
270
+ override fun keyboardDidChangeHeight(height: Int) {
277
271
  updateScrollViewInsetForKeyboard(height)
272
+
273
+ // Follow the caret frame-by-frame — only API 30+ delivers
274
+ // incremental heights; legacy fires once after the keyboard is
275
+ // already up, where keyboardDidShow's smooth reveal takes over
276
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && !isHiding) {
277
+ scrollToFocusedInput(smooth = false)
278
+ }
278
279
  }
279
280
 
280
281
  override fun keyboardDidShow(height: Int) {
@@ -284,7 +285,6 @@ class TrueSheetContentView(private val reactContext: ThemedReactContext) : React
284
285
 
285
286
  override fun keyboardWillHide() {
286
287
  detachCaretListener()
287
- updateScrollViewInsetForKeyboard(0)
288
288
  }
289
289
 
290
290
  override fun focusDidChange(newFocus: View) {
@@ -329,30 +329,39 @@ class TrueSheetContentView(private val reactContext: ThemedReactContext) : React
329
329
  }
330
330
 
331
331
  private fun updateScrollViewInsetForKeyboard(keyboardHeight: Int) {
332
- val scrollView = pinnedScrollView ?: return
333
-
334
- // An absolute footer rises above the keyboard and covers the content's
335
- // bottom edge include it so the caret clears the footer, not just the
336
- // keyboard. A relative footer stays behind the keyboard below the content,
337
- // so its height shields that much of the keyboard's overlap.
338
- val totalBottomInset = if (keyboardHeight > 0) {
339
- maxOf(0, keyboardHeight + (delegate?.footerKeyboardOcclusion ?: 0))
332
+ val scrollView = detectedScrollView ?: return
333
+
334
+ // A relative footer stays behind the keyboard below the content, so its
335
+ // height shields that much of the keyboard's overlap. An absolute footer
336
+ // floats within the viewport its clearance is the content padding's job
337
+ // (the caret reveal accounts for it, see scrollToFocusedInput).
338
+ keyboardBottomInset = if (keyboardHeight > 0) {
339
+ val baseInset = maxOf(0, keyboardHeight + minOf(0, delegate?.footerKeyboardOcclusion ?: 0))
340
+ val adjustedInset = maxOf(0, baseInset + keyboardOffset.toInt())
341
+ appliedKeyboardOffset = adjustedInset - baseInset
342
+ adjustedInset
340
343
  } else {
341
- bottomInset
344
+ appliedKeyboardOffset = 0
345
+ 0
342
346
  }
343
- setScrollViewPaddingBottom(originalScrollViewPaddingBottom + totalBottomInset)
344
-
345
- scrollView.post { nudgeScrollView() }
347
+ applyBottomPadding()
348
+ clampScrollPosition()
346
349
  }
347
350
 
348
- private fun nudgeScrollView() {
349
- val scrollView = pinnedScrollView ?: return
350
- scrollView.smoothScrollBy(0, 1)
351
- scrollView.smoothScrollBy(0, -1)
351
+ // Padding changes don't re-clamp the scroll position until the next layout
352
+ // pass clamp synchronously so the content follows the shrinking inset on
353
+ // every keyboard frame instead of snapping afterwards
354
+ private fun clampScrollPosition() {
355
+ val scrollView = detectedScrollView ?: return
356
+ val child = scrollView.getChildAt(0) ?: return
357
+ val maxScrollY = maxOf(0, child.height - (scrollView.height - scrollView.paddingTop - scrollView.paddingBottom))
358
+ if (scrollView.scrollY > maxScrollY) {
359
+ scrollView.scrollTo(scrollView.scrollX, maxScrollY)
360
+ }
352
361
  }
353
362
 
354
- private fun scrollToFocusedInput() {
355
- val scrollView = pinnedScrollView ?: findScrollView() ?: return
363
+ private fun scrollToFocusedInput(smooth: Boolean = true) {
364
+ val scrollView = detectedScrollView ?: findScrollView() ?: return
356
365
  val focusedView = findFocus() ?: return
357
366
 
358
367
  val focusedLocation = IntArray(2)
@@ -379,17 +388,29 @@ class TrueSheetContentView(private val reactContext: ThemedReactContext) : React
379
388
  caretBottom = viewTop + focusedView.height
380
389
  }
381
390
 
391
+ // An absolute footer floats over the viewport's bottom edge — extend the
392
+ // caret target so it clears the footer, not just the keyboard.
393
+ val footerOcclusion = maxOf(0, delegate?.footerKeyboardOcclusion ?: 0)
394
+
382
395
  val offset = keyboardScrollOffset.toInt()
383
- val visibleHeight = scrollView.height - scrollView.paddingBottom
396
+ val visibleHeight = scrollView.height - scrollView.paddingBottom + appliedKeyboardOffset
384
397
  val visibleTop = scrollView.scrollY
385
398
  val visibleBottom = scrollView.scrollY + visibleHeight
386
399
 
387
- when {
388
- caretBottom + offset > visibleBottom ->
389
- scrollView.smoothScrollTo(0, caretBottom + offset - visibleHeight)
400
+ val targetY = when {
401
+ caretBottom + offset + footerOcclusion > visibleBottom ->
402
+ caretBottom + offset + footerOcclusion - visibleHeight
390
403
 
391
404
  caretTop - offset < visibleTop ->
392
- scrollView.smoothScrollTo(0, (caretTop - offset).coerceAtLeast(0))
405
+ (caretTop - offset).coerceAtLeast(0)
406
+
407
+ else -> return
408
+ }
409
+
410
+ if (smooth) {
411
+ scrollView.smoothScrollTo(0, targetY)
412
+ } else {
413
+ scrollView.scrollTo(0, targetY)
393
414
  }
394
415
  }
395
416
 
@@ -20,7 +20,11 @@ class TrueSheetContentViewManager : ViewGroupManager<TrueSheetContentView>() {
20
20
  override fun createViewInstance(reactContext: ThemedReactContext): TrueSheetContentView = TrueSheetContentView(reactContext)
21
21
 
22
22
  override fun updateState(view: TrueSheetContentView, props: ReactStylesDiffMap?, stateWrapper: StateWrapper?): Any? {
23
- view.stateWrapper = stateWrapper
23
+ stateWrapper?.stateData?.let { data ->
24
+ if (data.hasKey("naturalHeight")) {
25
+ view.updateNaturalHeight(data.getDouble("naturalHeight"))
26
+ }
27
+ }
24
28
  return null
25
29
  }
26
30