@lodev09/react-native-true-sheet 4.0.0-beta.2 → 4.0.0-beta.4

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 (48) hide show
  1. package/android/src/main/java/com/lodev09/truesheet/TrueSheetContainerView.kt +28 -4
  2. package/android/src/main/java/com/lodev09/truesheet/TrueSheetContentView.kt +206 -149
  3. package/android/src/main/java/com/lodev09/truesheet/TrueSheetContentViewManager.kt +5 -0
  4. package/android/src/main/java/com/lodev09/truesheet/TrueSheetFooterView.kt +6 -17
  5. package/android/src/main/java/com/lodev09/truesheet/TrueSheetView.kt +73 -13
  6. package/android/src/main/java/com/lodev09/truesheet/TrueSheetViewController.kt +101 -18
  7. package/android/src/main/java/com/lodev09/truesheet/TrueSheetViewManager.kt +25 -1
  8. package/android/src/main/java/com/lodev09/truesheet/core/TrueSheetCoordinatorLayout.kt +3 -3
  9. package/android/src/main/java/com/lodev09/truesheet/core/TrueSheetDetentCalculator.kt +14 -24
  10. package/android/src/main/java/com/lodev09/truesheet/utils/ViewUtils.kt +0 -7
  11. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetContentViewShadowNode.cpp +50 -4
  12. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetContentViewShadowNode.h +7 -0
  13. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetContentViewState.cpp +2 -1
  14. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetContentViewState.h +11 -3
  15. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetFooterViewShadowNode.cpp +7 -0
  16. package/ios/TrueSheetContainerView.h +18 -5
  17. package/ios/TrueSheetContainerView.mm +10 -6
  18. package/ios/TrueSheetContentView.h +31 -10
  19. package/ios/TrueSheetContentView.mm +137 -162
  20. package/ios/TrueSheetFooterView.h +6 -0
  21. package/ios/TrueSheetFooterView.mm +48 -2
  22. package/ios/TrueSheetView.mm +95 -21
  23. package/ios/TrueSheetViewController.h +9 -0
  24. package/ios/TrueSheetViewController.mm +108 -25
  25. package/ios/utils/UIView+ScrollEdgeInteraction.h +1 -0
  26. package/ios/utils/UIView+ScrollEdgeInteraction.mm +24 -2
  27. package/lib/module/TrueSheet.js +77 -11
  28. package/lib/module/TrueSheet.js.map +1 -1
  29. package/lib/module/TrueSheet.web.js +26 -24
  30. package/lib/module/TrueSheet.web.js.map +1 -1
  31. package/lib/module/fabric/TrueSheetContentViewNativeComponent.ts +1 -1
  32. package/lib/module/fabric/TrueSheetViewNativeComponent.ts +4 -0
  33. package/lib/typescript/src/TrueSheet.d.ts +4 -0
  34. package/lib/typescript/src/TrueSheet.d.ts.map +1 -1
  35. package/lib/typescript/src/TrueSheet.types.d.ts +50 -1
  36. package/lib/typescript/src/TrueSheet.types.d.ts.map +1 -1
  37. package/lib/typescript/src/TrueSheet.web.d.ts.map +1 -1
  38. package/lib/typescript/src/fabric/TrueSheetViewNativeComponent.d.ts +3 -0
  39. package/lib/typescript/src/fabric/TrueSheetViewNativeComponent.d.ts.map +1 -1
  40. package/lib/typescript/src/navigation/types.d.ts +1 -1
  41. package/lib/typescript/src/navigation/types.d.ts.map +1 -1
  42. package/package.json +1 -1
  43. package/src/TrueSheet.tsx +78 -9
  44. package/src/TrueSheet.types.ts +53 -1
  45. package/src/TrueSheet.web.tsx +37 -23
  46. package/src/fabric/TrueSheetContentViewNativeComponent.ts +1 -1
  47. package/src/fabric/TrueSheetViewNativeComponent.ts +4 -0
  48. package/src/navigation/types.ts +1 -0
@@ -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,6 +78,23 @@ class TrueSheetContainerView(reactContext: ThemedReactContext) :
80
78
  contentView?.scrollableOptions = value
81
79
  }
82
80
 
81
+ // React tag of the user-provided scrollable within the content (see the scrollableRef prop)
82
+ var scrollableHandle: Int = -1
83
+ set(value) {
84
+ field = 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
96
+ }
97
+
83
98
  var hasAutoDetent = false
84
99
  set(value) {
85
100
  field = value
@@ -96,8 +111,7 @@ class TrueSheetContainerView(reactContext: ThemedReactContext) :
96
111
  }
97
112
 
98
113
  fun setupScrollable() {
99
- val bottomInset = if (insetAdjustment == TrueSheetInsetAdjustment.AUTOMATIC) scrollViewBottomInset else 0
100
- contentView?.setupScrollable(bottomInset)
114
+ contentView?.setupScrollable()
101
115
  }
102
116
 
103
117
  fun setupKeyboardHandler() {
@@ -115,9 +129,14 @@ class TrueSheetContainerView(reactContext: ThemedReactContext) :
115
129
  is TrueSheetContentView -> {
116
130
  child.delegate = this
117
131
  child.scrollableOptions = scrollableOptions
132
+ child.scrollableHandle = scrollableHandle
133
+ child.bottomInset = scrollableBottomInset
118
134
  child.hasAutoDetent = hasAutoDetent
119
135
  contentView = child
120
136
 
137
+ // State lands before the view is attached, so pull the current value
138
+ contentHeight = child.naturalHeight
139
+
121
140
  // Children mount bottom-up, so the content subtree is complete here.
122
141
  // Late-mounted peek views attach themselves instead (see TrueSheetPeekView).
123
142
  findPeekView(child)?.let { attachPeekView(it) }
@@ -205,6 +224,9 @@ class TrueSheetContainerView(reactContext: ThemedReactContext) :
205
224
  override val footerKeyboardOcclusion: Int
206
225
  get() = if (absoluteFooter) footerView?.keyboardOcclusionHeight ?: 0 else -(footerView?.height ?: 0)
207
226
 
227
+ override val hasRelativeFooter: Boolean
228
+ get() = footerView != null && !absoluteFooter
229
+
208
230
  override fun contentViewDidScroll() {
209
231
  delegate?.containerViewContentDidScroll()
210
232
  }
@@ -221,6 +243,8 @@ class TrueSheetContainerView(reactContext: ThemedReactContext) :
221
243
  override fun footerViewDidChangeSize(width: Int, height: Int) {
222
244
  footerHeight = height
223
245
  delegate?.containerViewFooterDidChangeSize(width, height)
246
+ // A relative footer takes over the bottom inset — drop the scrollable's
247
+ contentView?.updateContentInset()
224
248
  }
225
249
 
226
250
  override fun peekViewDidChangeSize(width: Int, height: Int) {
@@ -1,13 +1,12 @@
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
11
  import com.facebook.react.bridge.WritableNativeMap
13
12
  import com.facebook.react.uimanager.PixelUtil.dpToPx
@@ -17,10 +16,14 @@ import com.facebook.react.views.view.ReactViewGroup
17
16
  import com.lodev09.truesheet.core.TrueSheetKeyboardObserver
18
17
  import com.lodev09.truesheet.core.TrueSheetKeyboardObserverDelegate
19
18
  import com.lodev09.truesheet.utils.isDescendantOf
20
- import com.lodev09.truesheet.utils.smoothScrollBy
21
19
  import com.lodev09.truesheet.utils.smoothScrollTo
22
20
 
23
- data class ScrollableOptions(val keyboardScrollOffset: Float = 0f, val scrollingExpandsSheet: Boolean = true)
21
+ data class ScrollableOptions(
22
+ val keyboardScrollOffset: Float = 0f,
23
+ val keyboardOffset: Float = 0f,
24
+ val scrollingExpandsSheet: Boolean = true,
25
+ val contentInsetAdjustment: Boolean = true
26
+ )
24
27
 
25
28
  /**
26
29
  * Delegate interface for content view size changes
@@ -32,10 +35,19 @@ interface TrueSheetContentViewDelegate {
32
35
 
33
36
  /**
34
37
  * 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.
38
+ * footer floating over the viewport's bottom edge (extends the caret reveal),
39
+ * negative for a relative footer sitting behind the keyboard shielding that
40
+ * much of its overlap (reduces the keyboard padding).
37
41
  */
38
42
  val footerKeyboardOcclusion: Int
43
+
44
+ /**
45
+ * A relative footer sits below the content and absorbs the bottom safe-area
46
+ * inset — the scrollable doesn't reach the sheet's bottom edge, so no
47
+ * content inset is applied (mirrors iOS, where UIKit resolves this
48
+ * geometrically via the scroll view's safe area).
49
+ */
50
+ val hasRelativeFooter: Boolean
39
51
  }
40
52
 
41
53
  /**
@@ -47,42 +59,43 @@ class TrueSheetContentView(private val reactContext: ThemedReactContext) : React
47
59
  var delegate: TrueSheetContentViewDelegate? = null
48
60
  var stateWrapper: StateWrapper? = null
49
61
 
50
- private var lastWidth = 0
51
- private var lastHeight = 0
52
-
53
- private var pinnedScrollView: ViewGroup? = null
54
- private var observedScrollChild: View? = null
62
+ private var detectedScrollView: ViewGroup? = null
55
63
  private var originalScrollViewPaddingBottom: Int = 0
56
- private var bottomInset: Int = 0
57
64
  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
65
 
69
66
  /**
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.
67
+ * Content height measured unconstrained by the shadow node the height the
68
+ * content wants regardless of container bounds (see
69
+ * TrueSheetContentViewShadowNode). Falls back to the view height before the
70
+ * first state update.
73
71
  */
74
72
  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
- }
73
+ get() = if (lastNaturalHeight > 0) lastNaturalHeight else height
74
+
75
+ private var lastNaturalHeight = 0
76
+
77
+ fun updateNaturalHeight(heightDp: Double) {
78
+ val heightPx = heightDp.toFloat().dpToPx().toInt()
79
+ if (heightPx == lastNaturalHeight) return
80
+ lastNaturalHeight = heightPx
81
+ delegate?.contentViewDidChangeSize(width, heightPx)
82
+ }
84
83
 
85
84
  private var keyboardScrollOffset: Float = 0f
85
+
86
+ /**
87
+ * Adjustment added to the keyboard bottom inset applied to the detected
88
+ * scrollable — negative values reduce the inset (e.g. to cancel out safe-area
89
+ * padding already baked into the content's paddingBottom).
90
+ */
91
+ private var keyboardOffset: Float = 0f
92
+
93
+ /**
94
+ * How much of keyboardOffset actually landed in the applied padding — the
95
+ * caret reveal compensates by this so it still targets the real keyboard edge.
96
+ */
97
+ private var appliedKeyboardOffset = 0
98
+
86
99
  private var keyboardObserver: TrueSheetKeyboardObserver? = null
87
100
 
88
101
  private var watchedEditText: EditText? = null
@@ -92,18 +105,52 @@ class TrueSheetContentView(private val reactContext: ThemedReactContext) : React
92
105
  set(value) {
93
106
  field = value
94
107
  keyboardScrollOffset = value?.keyboardScrollOffset?.dpToPx() ?: 0f
108
+ keyboardOffset = value?.keyboardOffset?.dpToPx() ?: 0f
109
+ updateContentInset()
110
+ }
111
+
112
+ /**
113
+ * Bottom safe-area inset applied to the scrollable's content padding while
114
+ * the content can scroll — Android counterpart of iOS's
115
+ * `contentInsetAdjustmentBehavior="automatic"`. Already zero when the
116
+ * sheet's `insetAdjustment` is `'never'`.
117
+ */
118
+ var bottomInset: Int = 0
119
+ set(value) {
120
+ if (field == value) return
121
+ field = value
122
+ updateContentInset()
123
+ }
124
+
125
+ private var baseBottomInset = 0
126
+ private var keyboardBottomInset = 0
127
+
128
+ private val scrollableLayoutListener =
129
+ View.OnLayoutChangeListener { _, _, top, _, bottom, _, oldTop, _, oldBottom ->
130
+ if (bottom - top != oldBottom - oldTop) updateContentInset()
131
+ }
132
+
133
+ /**
134
+ * React tag of the user-provided scrollable (see the `scrollableRef` prop).
135
+ * Setting a new handle clears the currently resolved ScrollView.
136
+ */
137
+ var scrollableHandle: Int = -1
138
+ set(value) {
139
+ if (field == value) return
140
+ field = value
141
+ clearScrollable()
95
142
  }
96
143
 
97
144
  /**
98
145
  * 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
146
+ * scroll content is circular with natural layout, so the detected ScrollView's
100
147
  * viewport is force-bounded to the container only in this case.
101
148
  */
102
149
  var hasAutoDetent = false
103
150
  set(value) {
104
151
  if (field == value) return
105
152
  field = value
106
- if (pinnedScrollView != null) {
153
+ if (detectedScrollView != null) {
107
154
  setScrollableBounded(value)
108
155
  }
109
156
  }
@@ -119,47 +166,14 @@ class TrueSheetContentView(private val reactContext: ThemedReactContext) : React
119
166
  }
120
167
 
121
168
  private fun checkScrollViewChanged() {
122
- if (pinnedScrollView == null || pinnedScrollView?.isDescendantOf(this) == false) {
169
+ if (detectedScrollView == null || detectedScrollView?.isDescendantOf(this) == false) {
123
170
  delegate?.contentViewScrollViewDidChange()
124
171
  }
125
172
  }
126
173
 
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
174
  /**
161
175
  * 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
176
+ * detected ScrollView's viewport is bounded to the visible space. Only applied
163
177
  * for auto detents — otherwise content lays out naturally like a regular view.
164
178
  */
165
179
  private fun setScrollableBounded(bounded: Boolean) {
@@ -173,44 +187,37 @@ class TrueSheetContentView(private val reactContext: ThemedReactContext) : React
173
187
  }
174
188
  }
175
189
 
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) {
190
+ fun setupScrollable() {
191
+ // Check if the detected scroll view is still valid (still in view hierarchy)
192
+ if (detectedScrollView != null && detectedScrollView?.isDescendantOf(this) == false) {
179
193
  clearScrollable()
180
194
  }
181
195
 
182
- // Already set up with same inset and valid scroll view
183
- if (pinnedScrollView != null && this.bottomInset == bottomInset) {
196
+ if (detectedScrollView != null) {
184
197
  return
185
198
  }
186
199
 
187
- val scrollView = findScrollView(this) ?: return
188
-
189
- // Only capture originals on first pin
190
- if (pinnedScrollView == null) {
191
- originalScrollViewPaddingBottom = scrollView.paddingBottom
192
- pinnedScrollView = scrollView
200
+ val scrollView = findScrollView() ?: return
193
201
 
194
- scrollView.isNestedScrollingEnabled = true
195
- (scrollView.parent as? SwipeRefreshLayout)?.isNestedScrollingEnabled = false
202
+ originalScrollViewPaddingBottom = scrollView.paddingBottom
203
+ detectedScrollView = scrollView
196
204
 
197
- scrollView.setOnScrollChangeListener { _, _, scrollY, _, oldScrollY ->
198
- if (scrollY != oldScrollY) {
199
- delegate?.contentViewDidScroll()
200
- }
201
- }
205
+ scrollView.isNestedScrollingEnabled = true
206
+ (scrollView.parent as? SwipeRefreshLayout)?.isNestedScrollingEnabled = false
202
207
 
203
- scrollView.getChildAt(0)?.let { child ->
204
- observedScrollChild = child
205
- child.addOnLayoutChangeListener(scrollChildLayoutListener)
208
+ scrollView.setOnScrollChangeListener { _, _, scrollY, _, oldScrollY ->
209
+ if (scrollY != oldScrollY) {
210
+ delegate?.contentViewDidScroll()
206
211
  }
207
- setScrollableBounded(hasAutoDetent)
208
- reportSizeIfChanged()
209
212
  }
210
213
 
211
- this.bottomInset = bottomInset
214
+ // Track viewport and content size changes — the content inset only applies
215
+ // while the content can scroll (mirrors iOS's automatic behavior)
216
+ scrollView.addOnLayoutChangeListener(scrollableLayoutListener)
217
+ scrollView.getChildAt(0)?.addOnLayoutChangeListener(scrollableLayoutListener)
212
218
 
213
- setScrollViewPaddingBottom(originalScrollViewPaddingBottom + bottomInset)
219
+ setScrollableBounded(hasAutoDetent)
220
+ updateContentInset()
214
221
 
215
222
  // If keyboard is currently showing, re-apply the keyboard inset to the new ScrollView
216
223
  val keyboardHeight = keyboardObserver?.currentHeight ?: 0
@@ -219,8 +226,38 @@ class TrueSheetContentView(private val reactContext: ThemedReactContext) : React
219
226
  }
220
227
  }
221
228
 
229
+ /**
230
+ * Re-applies the base content inset — the bottom safe-area inset while the
231
+ * content can scroll, no relative footer absorbs it, and
232
+ * `contentInsetAdjustmentBehavior` isn't disabled.
233
+ */
234
+ fun updateContentInset() {
235
+ val scrollView = detectedScrollView ?: return
236
+
237
+ val enabled = scrollableOptions?.contentInsetAdjustment ?: true
238
+ baseBottomInset = if (enabled && delegate?.hasRelativeFooter != true && canScrollContent(scrollView)) {
239
+ bottomInset
240
+ } else {
241
+ 0
242
+ }
243
+
244
+ applyBottomPadding()
245
+ }
246
+
247
+ // Measured against the original padding so the applied inset doesn't feed
248
+ // back into the check
249
+ private fun canScrollContent(scrollView: ViewGroup): Boolean {
250
+ val child = scrollView.getChildAt(0) ?: return false
251
+ return child.height > scrollView.height - scrollView.paddingTop - originalScrollViewPaddingBottom
252
+ }
253
+
254
+ // The keyboard covers the safe-area region, so the larger inset wins over a sum
255
+ private fun applyBottomPadding() {
256
+ setScrollViewPaddingBottom(originalScrollViewPaddingBottom + maxOf(baseBottomInset, keyboardBottomInset))
257
+ }
258
+
222
259
  private fun setScrollViewPaddingBottom(paddingBottom: Int) {
223
- val scrollView = pinnedScrollView ?: return
260
+ val scrollView = detectedScrollView ?: return
224
261
  scrollView.clipToPadding = false
225
262
  scrollView.setPadding(
226
263
  scrollView.paddingLeft,
@@ -231,39 +268,28 @@ class TrueSheetContentView(private val reactContext: ThemedReactContext) : React
231
268
  }
232
269
 
233
270
  fun clearScrollable() {
234
- pinnedScrollView?.setOnScrollChangeListener(null as View.OnScrollChangeListener?)
235
- pinnedScrollView?.isNestedScrollingEnabled = false
236
- (pinnedScrollView?.parent as? SwipeRefreshLayout)?.isNestedScrollingEnabled = true
271
+ detectedScrollView?.setOnScrollChangeListener(null as View.OnScrollChangeListener?)
272
+ detectedScrollView?.removeOnLayoutChangeListener(scrollableLayoutListener)
273
+ detectedScrollView?.getChildAt(0)?.removeOnLayoutChangeListener(scrollableLayoutListener)
274
+ detectedScrollView?.isNestedScrollingEnabled = false
275
+ (detectedScrollView?.parent as? SwipeRefreshLayout)?.isNestedScrollingEnabled = true
237
276
  setScrollViewPaddingBottom(originalScrollViewPaddingBottom)
238
- observedScrollChild?.removeOnLayoutChangeListener(scrollChildLayoutListener)
239
- observedScrollChild = null
240
277
  setScrollableBounded(false)
241
- pinnedScrollView = null
278
+ detectedScrollView = null
242
279
  originalScrollViewPaddingBottom = 0
243
- bottomInset = 0
244
- reportSizeIfChanged()
280
+ baseBottomInset = 0
281
+ keyboardBottomInset = 0
282
+ appliedKeyboardOffset = 0
245
283
  }
246
284
 
285
+ /**
286
+ * Resolves the user-provided `scrollableHandle` within the content subtree —
287
+ * React tags are view ids on Android.
288
+ */
247
289
  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
290
+ if (detectedScrollView != null) return detectedScrollView
291
+ if (scrollableHandle <= 0) return null
292
+ return findViewById<View>(scrollableHandle) as? ViewGroup
267
293
  }
268
294
 
269
295
  // ==================== Keyboard Handling ====================
@@ -273,8 +299,19 @@ class TrueSheetContentView(private val reactContext: ThemedReactContext) : React
273
299
 
274
300
  keyboardObserver = TrueSheetKeyboardObserver(this, reactContext).apply {
275
301
  delegate = object : TrueSheetKeyboardObserverDelegate {
276
- override fun keyboardWillShow(height: Int) {
302
+ // Drive the inset per keyboard animation frame so the content tracks
303
+ // the keyboard edge instead of jumping to the final inset up front.
304
+ // On hide, the shrinking inset gradually clamps the scroll position
305
+ // back in sync with the keyboard.
306
+ override fun keyboardDidChangeHeight(height: Int) {
277
307
  updateScrollViewInsetForKeyboard(height)
308
+
309
+ // Follow the caret frame-by-frame — only API 30+ delivers
310
+ // incremental heights; legacy fires once after the keyboard is
311
+ // already up, where keyboardDidShow's smooth reveal takes over
312
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && !isHiding) {
313
+ scrollToFocusedInput(smooth = false)
314
+ }
278
315
  }
279
316
 
280
317
  override fun keyboardDidShow(height: Int) {
@@ -284,7 +321,6 @@ class TrueSheetContentView(private val reactContext: ThemedReactContext) : React
284
321
 
285
322
  override fun keyboardWillHide() {
286
323
  detachCaretListener()
287
- updateScrollViewInsetForKeyboard(0)
288
324
  }
289
325
 
290
326
  override fun focusDidChange(newFocus: View) {
@@ -329,30 +365,39 @@ class TrueSheetContentView(private val reactContext: ThemedReactContext) : React
329
365
  }
330
366
 
331
367
  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))
368
+ val scrollView = detectedScrollView ?: return
369
+
370
+ // A relative footer stays behind the keyboard below the content, so its
371
+ // height shields that much of the keyboard's overlap. An absolute footer
372
+ // floats within the viewport its clearance is the content padding's job
373
+ // (the caret reveal accounts for it, see scrollToFocusedInput).
374
+ keyboardBottomInset = if (keyboardHeight > 0) {
375
+ val baseInset = maxOf(0, keyboardHeight + minOf(0, delegate?.footerKeyboardOcclusion ?: 0))
376
+ val adjustedInset = maxOf(0, baseInset + keyboardOffset.toInt())
377
+ appliedKeyboardOffset = adjustedInset - baseInset
378
+ adjustedInset
340
379
  } else {
341
- bottomInset
380
+ appliedKeyboardOffset = 0
381
+ 0
342
382
  }
343
- setScrollViewPaddingBottom(originalScrollViewPaddingBottom + totalBottomInset)
344
-
345
- scrollView.post { nudgeScrollView() }
383
+ applyBottomPadding()
384
+ clampScrollPosition()
346
385
  }
347
386
 
348
- private fun nudgeScrollView() {
349
- val scrollView = pinnedScrollView ?: return
350
- scrollView.smoothScrollBy(0, 1)
351
- scrollView.smoothScrollBy(0, -1)
387
+ // Padding changes don't re-clamp the scroll position until the next layout
388
+ // pass clamp synchronously so the content follows the shrinking inset on
389
+ // every keyboard frame instead of snapping afterwards
390
+ private fun clampScrollPosition() {
391
+ val scrollView = detectedScrollView ?: return
392
+ val child = scrollView.getChildAt(0) ?: return
393
+ val maxScrollY = maxOf(0, child.height - (scrollView.height - scrollView.paddingTop - scrollView.paddingBottom))
394
+ if (scrollView.scrollY > maxScrollY) {
395
+ scrollView.scrollTo(scrollView.scrollX, maxScrollY)
396
+ }
352
397
  }
353
398
 
354
- private fun scrollToFocusedInput() {
355
- val scrollView = pinnedScrollView ?: findScrollView() ?: return
399
+ private fun scrollToFocusedInput(smooth: Boolean = true) {
400
+ val scrollView = detectedScrollView ?: findScrollView() ?: return
356
401
  val focusedView = findFocus() ?: return
357
402
 
358
403
  val focusedLocation = IntArray(2)
@@ -379,17 +424,29 @@ class TrueSheetContentView(private val reactContext: ThemedReactContext) : React
379
424
  caretBottom = viewTop + focusedView.height
380
425
  }
381
426
 
427
+ // An absolute footer floats over the viewport's bottom edge — extend the
428
+ // caret target so it clears the footer, not just the keyboard.
429
+ val footerOcclusion = maxOf(0, delegate?.footerKeyboardOcclusion ?: 0)
430
+
382
431
  val offset = keyboardScrollOffset.toInt()
383
- val visibleHeight = scrollView.height - scrollView.paddingBottom
432
+ val visibleHeight = scrollView.height - scrollView.paddingBottom + appliedKeyboardOffset
384
433
  val visibleTop = scrollView.scrollY
385
434
  val visibleBottom = scrollView.scrollY + visibleHeight
386
435
 
387
- when {
388
- caretBottom + offset > visibleBottom ->
389
- scrollView.smoothScrollTo(0, caretBottom + offset - visibleHeight)
436
+ val targetY = when {
437
+ caretBottom + offset + footerOcclusion > visibleBottom ->
438
+ caretBottom + offset + footerOcclusion - visibleHeight
390
439
 
391
440
  caretTop - offset < visibleTop ->
392
- scrollView.smoothScrollTo(0, (caretTop - offset).coerceAtLeast(0))
441
+ (caretTop - offset).coerceAtLeast(0)
442
+
443
+ else -> return
444
+ }
445
+
446
+ if (smooth) {
447
+ scrollView.smoothScrollTo(0, targetY)
448
+ } else {
449
+ scrollView.scrollTo(0, targetY)
393
450
  }
394
451
  }
395
452
 
@@ -21,6 +21,11 @@ class TrueSheetContentViewManager : ViewGroupManager<TrueSheetContentView>() {
21
21
 
22
22
  override fun updateState(view: TrueSheetContentView, props: ReactStylesDiffMap?, stateWrapper: StateWrapper?): Any? {
23
23
  view.stateWrapper = stateWrapper
24
+ stateWrapper?.stateData?.let { data ->
25
+ if (data.hasKey("naturalHeight")) {
26
+ view.updateNaturalHeight(data.getDouble("naturalHeight"))
27
+ }
28
+ }
24
29
  return null
25
30
  }
26
31
 
@@ -45,25 +45,17 @@ class TrueSheetFooterView(private val reactContext: ThemedReactContext) :
45
45
 
46
46
  private var lastWidth = 0
47
47
  private var lastHeight = 0
48
- private var bottomInset = 0
49
48
 
50
- /**
51
- * Skips the inset while the keyboard is open — an absolute footer rises
52
- * above the keyboard, so the inset would leave a gap.
53
- */
54
- var keyboardVisible = false
55
- set(value) {
56
- if (field == value) return
57
- field = value
58
- pushBottomInsetState()
59
- }
49
+ var bottomInset = 0
50
+ private set
60
51
 
61
52
  /**
62
53
  * Height the footer occupies above the keyboard — its layout height minus
63
- * the safe-area inset it drops while the keyboard is open.
54
+ * the safe-area inset, which rides below the keyboard's top edge while the
55
+ * keyboard is open (see positionFooter).
64
56
  */
65
57
  val keyboardOcclusionHeight: Int
66
- get() = maxOf(0, height - if (keyboardVisible) 0 else bottomInset)
58
+ get() = maxOf(0, height - bottomInset)
67
59
 
68
60
  /**
69
61
  * Tells the shadow node to pad the footer's bottom edge with the sheet's
@@ -73,12 +65,9 @@ class TrueSheetFooterView(private val reactContext: ThemedReactContext) :
73
65
  fun setBottomInset(inset: Int) {
74
66
  if (bottomInset == inset) return
75
67
  bottomInset = inset
76
- pushBottomInsetState()
77
- }
78
68
 
79
- private fun pushBottomInsetState() {
80
69
  val sw = stateWrapper ?: return
81
- val insetDp = (if (keyboardVisible) 0 else bottomInset).toFloat().pxToDp()
70
+ val insetDp = bottomInset.toFloat().pxToDp()
82
71
 
83
72
  // Synchronous update — the footer must be padded before detents are
84
73
  // configured, otherwise the auto detent is set up an inset short