@lodev09/react-native-true-sheet 3.11.10 → 4.0.0-beta.1

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 (73) hide show
  1. package/README.md +6 -5
  2. package/android/src/main/java/com/lodev09/truesheet/TrueSheetContainerView.kt +23 -2
  3. package/android/src/main/java/com/lodev09/truesheet/TrueSheetContentView.kt +116 -26
  4. package/android/src/main/java/com/lodev09/truesheet/TrueSheetContentViewManager.kt +7 -0
  5. package/android/src/main/java/com/lodev09/truesheet/TrueSheetFooterView.kt +48 -0
  6. package/android/src/main/java/com/lodev09/truesheet/TrueSheetFooterViewManager.kt +7 -0
  7. package/android/src/main/java/com/lodev09/truesheet/TrueSheetStateUpdater.kt +45 -0
  8. package/android/src/main/java/com/lodev09/truesheet/TrueSheetView.kt +28 -12
  9. package/android/src/main/java/com/lodev09/truesheet/TrueSheetViewController.kt +99 -34
  10. package/android/src/main/java/com/lodev09/truesheet/TrueSheetViewManager.kt +9 -5
  11. package/android/src/main/java/com/lodev09/truesheet/core/TrueSheetCoordinatorLayout.kt +12 -3
  12. package/android/src/main/java/com/lodev09/truesheet/core/TrueSheetDetentCalculator.kt +33 -4
  13. package/android/src/main/jni/TrueSheetSpec.h +2 -0
  14. package/android/src/main/jni/TrueSheetStateUpdater.cpp +96 -0
  15. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetContentViewComponentDescriptor.h +24 -0
  16. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetContentViewShadowNode.cpp +43 -0
  17. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetContentViewShadowNode.h +28 -0
  18. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetContentViewState.cpp +11 -0
  19. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetContentViewState.h +37 -0
  20. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetFooterViewComponentDescriptor.h +24 -0
  21. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetFooterViewShadowNode.cpp +56 -0
  22. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetFooterViewShadowNode.h +28 -0
  23. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetFooterViewState.cpp +11 -0
  24. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetFooterViewState.h +41 -0
  25. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetInsets.cpp +18 -0
  26. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetInsets.h +16 -0
  27. package/ios/TrueSheetContainerView.h +12 -10
  28. package/ios/TrueSheetContainerView.mm +11 -29
  29. package/ios/TrueSheetContentView.h +22 -7
  30. package/ios/TrueSheetContentView.mm +143 -41
  31. package/ios/TrueSheetFooterView.h +12 -2
  32. package/ios/TrueSheetFooterView.mm +74 -71
  33. package/ios/TrueSheetHeaderView.mm +0 -1
  34. package/ios/TrueSheetView.mm +60 -27
  35. package/ios/TrueSheetViewController.h +9 -0
  36. package/ios/TrueSheetViewController.mm +288 -122
  37. package/ios/core/TrueSheetDetentCalculator.h +9 -0
  38. package/ios/core/TrueSheetDetentCalculator.mm +13 -4
  39. package/ios/tvos/TrueSheetStubs.mm +2 -0
  40. package/lib/module/TrueSheet.js +32 -13
  41. package/lib/module/TrueSheet.js.map +1 -1
  42. package/lib/module/TrueSheet.web.js +262 -38
  43. package/lib/module/TrueSheet.web.js.map +1 -1
  44. package/lib/module/fabric/TrueSheetContentViewNativeComponent.ts +5 -1
  45. package/lib/module/fabric/TrueSheetFooterViewNativeComponent.ts +10 -2
  46. package/lib/module/fabric/TrueSheetViewNativeComponent.ts +8 -1
  47. package/lib/module/web/vaul/index.js +17 -6
  48. package/lib/module/web/vaul/index.js.map +1 -1
  49. package/lib/typescript/src/TrueSheet.d.ts.map +1 -1
  50. package/lib/typescript/src/TrueSheet.types.d.ts +35 -11
  51. package/lib/typescript/src/TrueSheet.types.d.ts.map +1 -1
  52. package/lib/typescript/src/TrueSheet.web.d.ts.map +1 -1
  53. package/lib/typescript/src/fabric/TrueSheetContentViewNativeComponent.d.ts.map +1 -1
  54. package/lib/typescript/src/fabric/TrueSheetFooterViewNativeComponent.d.ts +2 -0
  55. package/lib/typescript/src/fabric/TrueSheetFooterViewNativeComponent.d.ts.map +1 -1
  56. package/lib/typescript/src/fabric/TrueSheetViewNativeComponent.d.ts +5 -1
  57. package/lib/typescript/src/fabric/TrueSheetViewNativeComponent.d.ts.map +1 -1
  58. package/lib/typescript/src/navigation/types.d.ts +1 -1
  59. package/lib/typescript/src/navigation/types.d.ts.map +1 -1
  60. package/lib/typescript/src/web/vaul/index.d.ts +8 -1
  61. package/lib/typescript/src/web/vaul/index.d.ts.map +1 -1
  62. package/package.json +10 -10
  63. package/react-native.config.js +5 -1
  64. package/src/TrueSheet.tsx +45 -14
  65. package/src/TrueSheet.types.ts +38 -12
  66. package/src/TrueSheet.web.tsx +314 -44
  67. package/src/fabric/TrueSheetContentViewNativeComponent.ts +5 -1
  68. package/src/fabric/TrueSheetFooterViewNativeComponent.ts +10 -2
  69. package/src/fabric/TrueSheetViewNativeComponent.ts +8 -1
  70. package/src/navigation/types.ts +3 -1
  71. package/src/web/vaul/index.tsx +23 -5
  72. package/ios/utils/LayoutUtil.h +0 -77
  73. package/ios/utils/LayoutUtil.mm +0 -106
package/README.md CHANGED
@@ -30,16 +30,17 @@ The true native bottom sheet experience for your React Native Apps. 💩
30
30
 
31
31
  ### Prerequisites
32
32
 
33
- - React Native 0.81+
33
+ - React Native 0.82+
34
34
  - New Architecture enabled
35
35
  - Xcode 26.1+
36
36
 
37
37
  ### Compatibility
38
38
 
39
- | TrueSheet | React Native | Expo SDK |
40
- |-----------|--------------|----------|
41
- | 3.7+ | 0.81+ | 54+ |
42
- | 3.6 | 0.80 | 52-53 |
39
+ | TrueSheet | React Native | Expo SDK |
40
+ |------------|--------------|----------|
41
+ | 4.0+ | 0.82+ | 55+ |
42
+ | 3.7 - 3.11 | 0.81+ | 54+ |
43
+ | 3.6 | 0.80 | 52-53 |
43
44
 
44
45
  ### Expo
45
46
 
@@ -63,13 +63,29 @@ class TrueSheetContainerView(reactContext: ThemedReactContext) :
63
63
 
64
64
  var insetAdjustment: TrueSheetInsetAdjustment = TrueSheetInsetAdjustment.AUTOMATIC
65
65
  var scrollViewBottomInset: Int = 0
66
- var scrollableEnabled: Boolean = false
66
+ var absoluteFooter: Boolean = false
67
+
68
+ /**
69
+ * Bottom safe-area inset the footer absorbs as padding — the footer
70
+ * owns the sheet's bottom edge, so its background fills the inset.
71
+ */
72
+ var footerBottomInset: Int = 0
73
+ set(value) {
74
+ field = value
75
+ footerView?.setBottomInset(value)
76
+ }
67
77
  var scrollableOptions: ScrollableOptions? = null
68
78
  set(value) {
69
79
  field = value
70
80
  contentView?.scrollableOptions = value
71
81
  }
72
82
 
83
+ var hasAutoDetent = false
84
+ set(value) {
85
+ field = value
86
+ contentView?.hasAutoDetent = value
87
+ }
88
+
73
89
  override val eventDispatcher: EventDispatcher?
74
90
  get() = delegate?.eventDispatcher
75
91
 
@@ -81,7 +97,7 @@ class TrueSheetContainerView(reactContext: ThemedReactContext) :
81
97
 
82
98
  fun setupScrollable() {
83
99
  val bottomInset = if (insetAdjustment == TrueSheetInsetAdjustment.AUTOMATIC) scrollViewBottomInset else 0
84
- contentView?.setupScrollable(scrollableEnabled, bottomInset)
100
+ contentView?.setupScrollable(bottomInset)
85
101
  }
86
102
 
87
103
  fun setupKeyboardHandler() {
@@ -99,6 +115,7 @@ class TrueSheetContainerView(reactContext: ThemedReactContext) :
99
115
  is TrueSheetContentView -> {
100
116
  child.delegate = this
101
117
  child.scrollableOptions = scrollableOptions
118
+ child.hasAutoDetent = hasAutoDetent
102
119
  contentView = child
103
120
 
104
121
  // Children mount bottom-up, so the content subtree is complete here.
@@ -113,6 +130,7 @@ class TrueSheetContainerView(reactContext: ThemedReactContext) :
113
130
 
114
131
  is TrueSheetFooterView -> {
115
132
  child.delegate = this
133
+ child.setBottomInset(footerBottomInset)
116
134
  footerView = child
117
135
  }
118
136
  }
@@ -184,6 +202,9 @@ class TrueSheetContainerView(reactContext: ThemedReactContext) :
184
202
  delegate?.containerViewContentDidChangeSize(width, height)
185
203
  }
186
204
 
205
+ override val footerKeyboardOcclusion: Int
206
+ get() = if (absoluteFooter) footerView?.keyboardOcclusionHeight ?: 0 else -(footerView?.height ?: 0)
207
+
187
208
  override fun contentViewDidScroll() {
188
209
  delegate?.containerViewContentDidScroll()
189
210
  }
@@ -9,7 +9,9 @@ import android.widget.EditText
9
9
  import android.widget.ScrollView
10
10
  import androidx.core.widget.NestedScrollView
11
11
  import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
12
+ import com.facebook.react.bridge.WritableNativeMap
12
13
  import com.facebook.react.uimanager.PixelUtil.dpToPx
14
+ import com.facebook.react.uimanager.StateWrapper
13
15
  import com.facebook.react.uimanager.ThemedReactContext
14
16
  import com.facebook.react.views.view.ReactViewGroup
15
17
  import com.lodev09.truesheet.core.TrueSheetKeyboardObserver
@@ -27,6 +29,13 @@ interface TrueSheetContentViewDelegate {
27
29
  fun contentViewDidChangeSize(width: Int, height: Int)
28
30
  fun contentViewDidScroll()
29
31
  fun contentViewScrollViewDidChange()
32
+
33
+ /**
34
+ * 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.
37
+ */
38
+ val footerKeyboardOcclusion: Int
30
39
  }
31
40
 
32
41
  /**
@@ -36,14 +45,42 @@ interface TrueSheetContentViewDelegate {
36
45
  @SuppressLint("ViewConstructor")
37
46
  class TrueSheetContentView(private val reactContext: ThemedReactContext) : ReactViewGroup(reactContext) {
38
47
  var delegate: TrueSheetContentViewDelegate? = null
48
+ var stateWrapper: StateWrapper? = null
39
49
 
40
50
  private var lastWidth = 0
41
51
  private var lastHeight = 0
42
52
 
43
53
  private var pinnedScrollView: ViewGroup? = null
54
+ private var observedScrollChild: View? = null
44
55
  private var originalScrollViewPaddingBottom: Int = 0
45
56
  private var bottomInset: Int = 0
46
- private var scrollExpansionPadding: 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
+
69
+ /**
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.
73
+ */
74
+ 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
+ }
47
84
 
48
85
  private var keyboardScrollOffset: Float = 0f
49
86
  private var keyboardObserver: TrueSheetKeyboardObserver? = null
@@ -57,6 +94,20 @@ class TrueSheetContentView(private val reactContext: ThemedReactContext) : React
57
94
  keyboardScrollOffset = value?.keyboardScrollOffset?.dpToPx() ?: 0f
58
95
  }
59
96
 
97
+ /**
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.
101
+ */
102
+ var hasAutoDetent = false
103
+ set(value) {
104
+ if (field == value) return
105
+ field = value
106
+ if (pinnedScrollView != null) {
107
+ setScrollableBounded(value)
108
+ }
109
+ }
110
+
60
111
  override fun addView(child: View?, index: Int) {
61
112
  super.addView(child, index)
62
113
  checkScrollViewChanged()
@@ -75,20 +126,54 @@ class TrueSheetContentView(private val reactContext: ThemedReactContext) : React
75
126
 
76
127
  override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
77
128
  super.onSizeChanged(w, h, oldw, oldh)
129
+ reportSizeIfChanged()
130
+ }
78
131
 
79
- if (w != lastWidth || h != lastHeight) {
80
- lastWidth = w
81
- lastHeight = h
82
- delegate?.contentViewDidChangeSize(w, h)
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
83
146
  }
147
+
148
+ reportSizeNow()
84
149
  }
85
150
 
86
- fun setupScrollable(enabled: Boolean, bottomInset: Int) {
87
- if (!enabled) {
88
- clearScrollable()
89
- return
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)
90
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
+ }
91
175
 
176
+ fun setupScrollable(bottomInset: Int) {
92
177
  // Check if pinned scroll view is still valid (still in view hierarchy)
93
178
  if (pinnedScrollView != null && pinnedScrollView?.isDescendantOf(this) == false) {
94
179
  clearScrollable()
@@ -114,6 +199,13 @@ class TrueSheetContentView(private val reactContext: ThemedReactContext) : React
114
199
  delegate?.contentViewDidScroll()
115
200
  }
116
201
  }
202
+
203
+ scrollView.getChildAt(0)?.let { child ->
204
+ observedScrollChild = child
205
+ child.addOnLayoutChangeListener(scrollChildLayoutListener)
206
+ }
207
+ setScrollableBounded(hasAutoDetent)
208
+ reportSizeIfChanged()
117
209
  }
118
210
 
119
211
  this.bottomInset = bottomInset
@@ -123,23 +215,10 @@ class TrueSheetContentView(private val reactContext: ThemedReactContext) : React
123
215
  // If keyboard is currently showing, re-apply the keyboard inset to the new ScrollView
124
216
  val keyboardHeight = keyboardObserver?.currentHeight ?: 0
125
217
  if (keyboardHeight > 0) {
126
- setScrollViewPaddingBottom(originalScrollViewPaddingBottom + keyboardHeight)
218
+ updateScrollViewInsetForKeyboard(keyboardHeight)
127
219
  }
128
220
  }
129
221
 
130
- // TODO: Replace this workaround with synchronous state layout updates on every sheet resize.
131
- // The container is currently sized to the largest detent, so at smaller detents the ScrollView
132
- // viewport extends beyond the visible area, reducing the effective scroll range. This padding
133
- // compensates for that difference until we can resize the container per-detent synchronously.
134
- fun updateScrollExpansionPadding(padding: Int) {
135
- if (scrollExpansionPadding == padding) return
136
- scrollExpansionPadding = padding
137
- val keyboardHeight = keyboardObserver?.currentHeight ?: 0
138
- val basePadding = if (keyboardHeight > 0) keyboardHeight else bottomInset
139
- setScrollViewPaddingBottom(originalScrollViewPaddingBottom + basePadding)
140
- nudgeScrollView()
141
- }
142
-
143
222
  private fun setScrollViewPaddingBottom(paddingBottom: Int) {
144
223
  val scrollView = pinnedScrollView ?: return
145
224
  scrollView.clipToPadding = false
@@ -147,7 +226,7 @@ class TrueSheetContentView(private val reactContext: ThemedReactContext) : React
147
226
  scrollView.paddingLeft,
148
227
  scrollView.paddingTop,
149
228
  scrollView.paddingRight,
150
- paddingBottom + scrollExpansionPadding
229
+ paddingBottom
151
230
  )
152
231
  }
153
232
 
@@ -155,11 +234,14 @@ class TrueSheetContentView(private val reactContext: ThemedReactContext) : React
155
234
  pinnedScrollView?.setOnScrollChangeListener(null as View.OnScrollChangeListener?)
156
235
  pinnedScrollView?.isNestedScrollingEnabled = false
157
236
  (pinnedScrollView?.parent as? SwipeRefreshLayout)?.isNestedScrollingEnabled = true
158
- scrollExpansionPadding = 0
159
237
  setScrollViewPaddingBottom(originalScrollViewPaddingBottom)
238
+ observedScrollChild?.removeOnLayoutChangeListener(scrollChildLayoutListener)
239
+ observedScrollChild = null
240
+ setScrollableBounded(false)
160
241
  pinnedScrollView = null
161
242
  originalScrollViewPaddingBottom = 0
162
243
  bottomInset = 0
244
+ reportSizeIfChanged()
163
245
  }
164
246
 
165
247
  fun findScrollView(): ViewGroup? {
@@ -249,7 +331,15 @@ class TrueSheetContentView(private val reactContext: ThemedReactContext) : React
249
331
  private fun updateScrollViewInsetForKeyboard(keyboardHeight: Int) {
250
332
  val scrollView = pinnedScrollView ?: return
251
333
 
252
- val totalBottomInset = if (keyboardHeight > 0) keyboardHeight else bottomInset
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))
340
+ } else {
341
+ bottomInset
342
+ }
253
343
  setScrollViewPaddingBottom(originalScrollViewPaddingBottom + totalBottomInset)
254
344
 
255
345
  scrollView.post { nudgeScrollView() }
@@ -2,6 +2,8 @@ package com.lodev09.truesheet
2
2
 
3
3
  import com.facebook.react.module.annotations.ReactModule
4
4
  import com.facebook.react.uimanager.PointerEvents
5
+ import com.facebook.react.uimanager.ReactStylesDiffMap
6
+ import com.facebook.react.uimanager.StateWrapper
5
7
  import com.facebook.react.uimanager.ThemedReactContext
6
8
  import com.facebook.react.uimanager.ViewGroupManager
7
9
  import com.facebook.react.uimanager.annotations.ReactProp
@@ -17,6 +19,11 @@ class TrueSheetContentViewManager : ViewGroupManager<TrueSheetContentView>() {
17
19
 
18
20
  override fun createViewInstance(reactContext: ThemedReactContext): TrueSheetContentView = TrueSheetContentView(reactContext)
19
21
 
22
+ override fun updateState(view: TrueSheetContentView, props: ReactStylesDiffMap?, stateWrapper: StateWrapper?): Any? {
23
+ view.stateWrapper = stateWrapper
24
+ return null
25
+ }
26
+
20
27
  @ReactProp(name = "pointerEvents")
21
28
  fun setPointerEvents(view: TrueSheetContentView, pointerEventsStr: String?) {
22
29
  view.pointerEvents = PointerEvents.parsePointerEvents(pointerEventsStr)
@@ -4,10 +4,13 @@ import android.annotation.SuppressLint
4
4
  import android.view.MotionEvent
5
5
  import android.view.View
6
6
  import android.view.ViewParent
7
+ import com.facebook.react.bridge.WritableNativeMap
7
8
  import com.facebook.react.uimanager.JSPointerDispatcher
8
9
  import com.facebook.react.uimanager.JSTouchDispatcher
10
+ import com.facebook.react.uimanager.PixelUtil.pxToDp
9
11
  import com.facebook.react.uimanager.PointerEvents
10
12
  import com.facebook.react.uimanager.RootView
13
+ import com.facebook.react.uimanager.StateWrapper
11
14
  import com.facebook.react.uimanager.ThemedReactContext
12
15
  import com.facebook.react.uimanager.events.EventDispatcher
13
16
  import com.facebook.react.views.view.ReactViewGroup
@@ -34,12 +37,57 @@ class TrueSheetFooterView(private val reactContext: ThemedReactContext) :
34
37
  RootView {
35
38
 
36
39
  var delegate: TrueSheetFooterViewDelegate? = null
40
+ var stateWrapper: StateWrapper? = null
37
41
 
38
42
  private val eventDispatcher: EventDispatcher?
39
43
  get() = delegate?.eventDispatcher
40
44
 
41
45
  private var lastWidth = 0
42
46
  private var lastHeight = 0
47
+ private var bottomInset = 0
48
+
49
+ /**
50
+ * Skips the inset while the keyboard is open — an absolute footer rises
51
+ * above the keyboard, so the inset would leave a gap.
52
+ */
53
+ var keyboardVisible = false
54
+ set(value) {
55
+ if (field == value) return
56
+ field = value
57
+ pushBottomInsetState()
58
+ }
59
+
60
+ /**
61
+ * Height the footer occupies above the keyboard — its layout height minus
62
+ * the safe-area inset it drops while the keyboard is open.
63
+ */
64
+ val keyboardOcclusionHeight: Int
65
+ get() = maxOf(0, height - if (keyboardVisible) 0 else bottomInset)
66
+
67
+ /**
68
+ * Tells the shadow node to pad the footer's bottom edge with the sheet's
69
+ * bottom safe-area inset — the footer owns the sheet's bottom edge, so it
70
+ * absorbs the inset and its background fills it.
71
+ */
72
+ fun setBottomInset(inset: Int) {
73
+ if (bottomInset == inset) return
74
+ bottomInset = inset
75
+ pushBottomInsetState()
76
+ }
77
+
78
+ private fun pushBottomInsetState() {
79
+ val sw = stateWrapper ?: return
80
+ val insetDp = (if (keyboardVisible) 0 else bottomInset).toFloat().pxToDp()
81
+
82
+ // Synchronous update — the footer must be padded before detents are
83
+ // configured, otherwise the auto detent is set up an inset short
84
+ if (TrueSheetStateUpdater.updateFooterState(sw, insetDp)) return
85
+
86
+ // Fallback: async state update
87
+ val newState = WritableNativeMap()
88
+ newState.putDouble("bottomInset", insetDp.toDouble())
89
+ sw.updateState(newState)
90
+ }
43
91
 
44
92
  private val jsTouchDispatcher = JSTouchDispatcher(this)
45
93
  private var jsPointerDispatcher: JSPointerDispatcher? = null
@@ -2,6 +2,8 @@ package com.lodev09.truesheet
2
2
 
3
3
  import com.facebook.react.module.annotations.ReactModule
4
4
  import com.facebook.react.uimanager.PointerEvents
5
+ import com.facebook.react.uimanager.ReactStylesDiffMap
6
+ import com.facebook.react.uimanager.StateWrapper
5
7
  import com.facebook.react.uimanager.ThemedReactContext
6
8
  import com.facebook.react.uimanager.ViewGroupManager
7
9
  import com.facebook.react.uimanager.annotations.ReactProp
@@ -17,6 +19,11 @@ class TrueSheetFooterViewManager : ViewGroupManager<TrueSheetFooterView>() {
17
19
 
18
20
  override fun createViewInstance(reactContext: ThemedReactContext): TrueSheetFooterView = TrueSheetFooterView(reactContext)
19
21
 
22
+ override fun updateState(view: TrueSheetFooterView, props: ReactStylesDiffMap?, stateWrapper: StateWrapper?): Any? {
23
+ view.stateWrapper = stateWrapper
24
+ return null
25
+ }
26
+
20
27
  @ReactProp(name = "pointerEvents")
21
28
  fun setPointerEvents(view: TrueSheetFooterView, pointerEventsStr: String?) {
22
29
  view.pointerEvents = PointerEvents.parsePointerEvents(pointerEventsStr)
@@ -0,0 +1,45 @@
1
+ package com.lodev09.truesheet
2
+
3
+ import com.facebook.react.uimanager.StateWrapper
4
+
5
+ /**
6
+ * Synchronous Fabric state updates via JNI (see TrueSheetStateUpdater.cpp).
7
+ *
8
+ * StateWrapper.updateState commits asynchronously, so the container's Yoga
9
+ * layout lags the sheet by a frame or two during drags. The native bridge
10
+ * commits with `unstable_Immediate`, which mounts synchronously when called
11
+ * from the UI thread — same-frame layout, like iOS.
12
+ */
13
+ object TrueSheetStateUpdater {
14
+ private val isAvailable: Boolean = try {
15
+ System.loadLibrary("react_codegen_TrueSheetSpec")
16
+ true
17
+ } catch (e: UnsatisfiedLinkError) {
18
+ false
19
+ }
20
+
21
+ /** Returns false when unavailable — caller should fall back to async updateState. */
22
+ fun updateState(stateWrapper: StateWrapper, widthDp: Float, heightDp: Float): Boolean =
23
+ isAvailable && nativeUpdateState(stateWrapper, widthDp, heightDp)
24
+
25
+ /** Returns false when unavailable — caller should fall back to async updateState. */
26
+ fun updateFooterState(stateWrapper: StateWrapper, bottomInsetDp: Float): Boolean =
27
+ isAvailable && nativeUpdateFooterState(stateWrapper, bottomInsetDp)
28
+
29
+ /**
30
+ * Publishes the precalculated bottom safe-area inset so a late-mounted
31
+ * footer's first layout is already padded (see TrueSheetFooterViewShadowNode).
32
+ */
33
+ fun setBottomSafeArea(insetDp: Float) {
34
+ if (isAvailable) nativeSetBottomSafeArea(insetDp)
35
+ }
36
+
37
+ @JvmStatic
38
+ private external fun nativeUpdateState(stateWrapper: Any, width: Float, height: Float): Boolean
39
+
40
+ @JvmStatic
41
+ private external fun nativeUpdateFooterState(stateWrapper: Any, bottomInset: Float): Boolean
42
+
43
+ @JvmStatic
44
+ private external fun nativeSetBottomSafeArea(insetDp: Float)
45
+ }
@@ -274,6 +274,7 @@ class TrueSheetView(private val reactContext: ThemedReactContext) :
274
274
 
275
275
  fun setDetents(newDetents: MutableList<Double>) {
276
276
  viewController.detents = newDetents
277
+ setupScrollable()
277
278
  }
278
279
 
279
280
  fun setInsetAdjustment(insetAdjustment: String) {
@@ -281,16 +282,19 @@ class TrueSheetView(private val reactContext: ThemedReactContext) :
281
282
  setupScrollable()
282
283
  }
283
284
 
284
- fun setScrollable(scrollable: Boolean) {
285
- viewController.scrollable = scrollable
286
- setupScrollable()
287
- }
288
-
289
285
  fun setScrollableOptions(options: ScrollableOptions?) {
290
286
  viewController.scrollableOptions = options
291
287
  setupScrollable()
292
288
  }
293
289
 
290
+ fun setAbsoluteHeader(absolute: Boolean) {
291
+ viewController.absoluteHeader = absolute
292
+ }
293
+
294
+ fun setAbsoluteFooter(absolute: Boolean) {
295
+ viewController.absoluteFooter = absolute
296
+ }
297
+
294
298
  fun setFooterKeyboardOffset(offset: Float) {
295
299
  viewController.footerKeyboardOffset = offset.dpToPx().toInt()
296
300
  viewController.positionFooter()
@@ -299,11 +303,19 @@ class TrueSheetView(private val reactContext: ThemedReactContext) :
299
303
  private fun setupScrollable() {
300
304
  viewController.containerView?.let {
301
305
  it.insetAdjustment = viewController.insetAdjustment
302
- it.scrollableEnabled = viewController.scrollable
303
306
  it.scrollViewBottomInset = viewController.contentBottomInset
304
307
  it.scrollableOptions = viewController.scrollableOptions
308
+ it.hasAutoDetent = viewController.detents.contains(-1.0)
309
+ it.footerBottomInset = viewController.contentBottomInset
310
+ it.absoluteFooter = viewController.absoluteFooter
305
311
  it.setupScrollable()
306
312
  }
313
+
314
+ // Publish the inset so a footer set later (e.g. navigation setOptions) is
315
+ // padded on its very first layout instead of resizing the sheet twice.
316
+ if (viewController.insetAdjustment == TrueSheetInsetAdjustment.AUTOMATIC) {
317
+ TrueSheetStateUpdater.setBottomSafeArea(viewController.contentBottomInset.toFloat().pxToDp())
318
+ }
307
319
  }
308
320
 
309
321
  // ==================== Screen Event Observer ====================
@@ -343,9 +355,16 @@ class TrueSheetView(private val reactContext: ThemedReactContext) :
343
355
  lastContainerHeight = height
344
356
 
345
357
  val sw = stateWrapper ?: return
358
+ val widthDp = width.toFloat().pxToDp()
359
+ val heightDp = height.toFloat().pxToDp()
360
+
361
+ // Synchronous update — commits and mounts within the same UI-thread frame
362
+ if (TrueSheetStateUpdater.updateState(sw, widthDp, heightDp)) return
363
+
364
+ // Fallback: async state update
346
365
  val newStateData = WritableNativeMap()
347
- newStateData.putDouble("containerWidth", width.toFloat().pxToDp().toDouble())
348
- newStateData.putDouble("containerHeight", height.toFloat().pxToDp().toDouble())
366
+ newStateData.putDouble("containerWidth", widthDp.toDouble())
367
+ newStateData.putDouble("containerHeight", heightDp.toDouble())
349
368
  sw.updateState(newStateData)
350
369
  }
351
370
 
@@ -569,10 +588,7 @@ class TrueSheetView(private val reactContext: ThemedReactContext) :
569
588
  }
570
589
 
571
590
  override fun viewControllerDidChangeSize(width: Int, height: Int) {
572
- // On android scrollable, we need the actual sheet height to get proper ScrollView height.
573
- // Unlike IOS where ScrollView is pinned to the container.
574
- val effectiveHeight = if (viewController.scrollable) height else viewController.screenHeight
575
- updateState(width, effectiveHeight)
591
+ updateState(width, height)
576
592
  }
577
593
 
578
594
  override fun viewControllerWillFocus() {