@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
@@ -218,13 +218,10 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
218
218
  override var sheetBackgroundColor: Int? = null
219
219
  var insetAdjustment: TrueSheetInsetAdjustment = TrueSheetInsetAdjustment.AUTOMATIC
220
220
 
221
- var scrollable: Boolean = false
222
-
223
221
  var scrollableOptions: ScrollableOptions? = null
224
222
  set(value) {
225
223
  field = value
226
224
  behavior?.scrollingExpandsSheet = value?.scrollingExpandsSheet ?: true
227
- if (isPresented) sheetView?.let { updateScrollExpansionPadding(it.top) }
228
225
  }
229
226
 
230
227
  override var sheetCornerRadius: Float = DEFAULT_CORNER_RADIUS.dpToPx()
@@ -287,9 +284,13 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
287
284
  override val headerHeight: Int
288
285
  get() = containerView?.headerHeight ?: cachedHeaderHeight
289
286
 
287
+ override var absoluteHeader: Boolean = false
288
+
290
289
  override val footerHeight: Int
291
290
  get() = containerView?.footerHeight ?: cachedFooterHeight
292
291
 
292
+ override var absoluteFooter: Boolean = false
293
+
293
294
  override val peekContentHeight: Int
294
295
  get() = containerView?.peekContentHeight ?: cachedPeekContentHeight
295
296
 
@@ -564,29 +565,20 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
564
565
  else -> { }
565
566
  }
566
567
 
567
- updateScrollExpansionPadding(sheetView.top)
568
568
  emitChangePositionDelegate(sheetView.top)
569
569
 
570
- // On older APIs, use onSlide for footer positioning during keyboard transitions
571
- val useLegacyKeyboardHandling = Build.VERSION.SDK_INT < Build.VERSION_CODES.R
572
- if (!isKeyboardTransitioning || useLegacyKeyboardHandling) {
573
- positionFooter(slideOffset)
574
- }
570
+ // Position on every slide frame during keyboard transitions the IME
571
+ // animation callbacks are sparser than the sheet's settle frames, and the
572
+ // footer rides the sheet between them, jittering against the keyboard.
573
+ // positionFooter derives from current state, so double-driving it with
574
+ // keyboardDidChangeHeight is harmless.
575
+ positionFooter(slideOffset)
575
576
 
576
577
  if (!isKeyboardTransitioning) {
577
578
  updateDimAmount(sheetView.top)
578
579
  }
579
580
  }
580
581
 
581
- private fun updateScrollExpansionPadding(sheetTop: Int) {
582
- if (!scrollable) {
583
- containerView?.contentView?.updateScrollExpansionPadding(0)
584
- return
585
- }
586
- val expandedOffset = behavior?.expandedOffset ?: return
587
- containerView?.contentView?.updateScrollExpansionPadding(maxOf(0, sheetTop - expandedOffset))
588
- }
589
-
590
582
  private fun handleStateSettled(sheetView: View, newState: Int) {
591
583
  if (interactionState is InteractionState.Reconfiguring) return
592
584
 
@@ -634,6 +626,9 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
634
626
  }
635
627
  }
636
628
 
629
+ // Settled — resize the container to the settled detent (applies deferred shrinks)
630
+ updateStateDimensions()
631
+
637
632
  if (!isKeyboardTransitioning) {
638
633
  updateDimAmount(animated = true)
639
634
  }
@@ -743,6 +738,7 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
743
738
  }
744
739
 
745
740
  pendingDetentIndex = detentIndex
741
+ updateStateDimensions(deferShrink = true)
746
742
  setupDimmedBackground()
747
743
  setStateForDetentIndex(detentIndex)
748
744
  resizePromise?.invoke()
@@ -893,7 +889,7 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
893
889
  animate = isPresented
894
890
  )
895
891
 
896
- updateStateDimensions(expandedOffset)
892
+ updateStateDimensions()
897
893
 
898
894
  if (isPresented && applyState) {
899
895
  // Prefer the pending target while a resize animation is in flight so a
@@ -923,6 +919,11 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
923
919
  }
924
920
 
925
921
  fun setupSheetDetentsForSizeChange() {
922
+ // Skip while dragging — the size change is driven by the drag itself (the
923
+ // container tracks the sheet's visible height), and reconfiguring resets
924
+ // behavior state, killing the gesture.
925
+ if (interactionState is InteractionState.Dragging) return
926
+
926
927
  setupSheetDetents()
927
928
  positionFooter()
928
929
  }
@@ -1007,15 +1008,28 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
1007
1008
  val footerView = containerView?.footerView ?: return
1008
1009
  val sheet = sheetView ?: return
1009
1010
 
1011
+ val keyboardShift = if (currentKeyboardInset > 0) maxOf(0, currentKeyboardInset + footerKeyboardOffset) else 0
1012
+
1013
+ // A relative footer is laid out by Yoga (pinned to the container's bottom
1014
+ // edge) and stays in the layout flow behind the keyboard — only an
1015
+ // absolute footer floats above it
1016
+ if (!absoluteFooter) {
1017
+ footerView.translationY = 0f
1018
+ return
1019
+ }
1020
+
1010
1021
  val footerHeight = footerView.height
1011
1022
  val sheetHeight = sheet.height
1012
1023
  val sheetTop = sheet.top
1013
1024
 
1014
- val keyboardShift = if (currentKeyboardInset > 0) maxOf(0, currentKeyboardInset + footerKeyboardOffset) else 0
1015
1025
  var footerY = (sheetHeight - sheetTop - footerHeight - keyboardShift).toFloat()
1016
1026
 
1017
- // Adjust during dismiss animation when slideOffset is negative
1018
- if (slideOffset != null && slideOffset < 0) {
1027
+ // Adjust during dismiss animation when slideOffset is negative. Skipped
1028
+ // while the keyboard is open the detents collapse to the expanded
1029
+ // position, so any downward drag reads as a negative slideOffset and the
1030
+ // adjustment would sink the footer behind the keyboard, snapping back up
1031
+ // when the keyboard detents reset on release.
1032
+ if (slideOffset != null && slideOffset < 0 && keyboardShift == 0) {
1019
1033
  footerY -= (footerHeight * slideOffset)
1020
1034
  }
1021
1035
 
@@ -1062,17 +1076,22 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
1062
1076
  delegate = object : TrueSheetKeyboardObserverDelegate {
1063
1077
  override fun keyboardWillShow(height: Int) {
1064
1078
  if (!shouldHandleKeyboard()) return
1079
+ // An absolute footer rises above the keyboard, so it drops the bottom
1080
+ // inset padding — before detents are configured against its height
1081
+ if (absoluteFooter) containerView?.footerView?.keyboardVisible = true
1065
1082
  // If a resize is in flight, restore to its target — not the stale current
1066
1083
  detentIndexBeforeKeyboard = if (pendingDetentIndex >= 0) pendingDetentIndex else currentDetentIndex
1067
1084
  pendingDetentIndex = -1
1068
- setupSheetDetents()
1085
+ // Commit the target index first so the container grows before the sheet expands
1069
1086
  currentDetentIndex = detents.size - 1
1070
- setStateForDetentIndex(currentDetentIndex)
1087
+ setupSheetDetents()
1088
+ positionFooter()
1071
1089
  updateDimAmount(animated = true)
1072
1090
  }
1073
1091
 
1074
1092
  override fun keyboardWillHide() {
1075
1093
  if (!shouldHandleKeyboard(checkFocus = false)) return
1094
+ containerView?.footerView?.keyboardVisible = false
1076
1095
  val restoring = !isBeingDismissed && detentIndexBeforeKeyboard >= 0
1077
1096
 
1078
1097
  // Skip reconfigure during interactive keyboard dismiss (e.g. keyboardDismissMode="on-drag")
@@ -1085,6 +1104,10 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
1085
1104
  }
1086
1105
  }
1087
1106
 
1107
+ // Same-frame reposition — the reconfigure above shrinks the container
1108
+ // to the target detent, which yanks the bottom-pinned footer up until
1109
+ // the next keyboard animation frame repositions it.
1110
+ positionFooter()
1088
1111
  updateDimAmount(
1089
1112
  sheetTop = detentCalculator.getSheetTopForDetentIndex(currentDetentIndex),
1090
1113
  animated = true
@@ -1093,6 +1116,7 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
1093
1116
 
1094
1117
  override fun keyboardDidHide() {
1095
1118
  if (!shouldHandleKeyboard(checkFocus = false)) return
1119
+ containerView?.footerView?.keyboardVisible = false
1096
1120
  detentIndexBeforeKeyboard = -1
1097
1121
  isKeyboardDismissProgrammatic = false
1098
1122
  setupSheetDetents(applyState = false)
@@ -1114,9 +1138,11 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
1114
1138
  // Handle case where keyboard is already visible and focus moves into the sheet
1115
1139
  if (!shouldHandleKeyboard()) return
1116
1140
  if (detentIndexBeforeKeyboard < 0 && (keyboardObserver?.currentHeight ?: 0) > 0) {
1141
+ if (absoluteFooter) containerView?.footerView?.keyboardVisible = true
1117
1142
  detentIndexBeforeKeyboard = currentDetentIndex
1118
1143
  currentDetentIndex = detents.size - 1
1119
1144
  setupSheetDetents()
1145
+ positionFooter()
1120
1146
  }
1121
1147
  }
1122
1148
  }
@@ -1185,6 +1211,8 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
1185
1211
  private fun handleDragChange(sheetView: View) {
1186
1212
  if (interactionState !is InteractionState.Dragging) return
1187
1213
 
1214
+ updateStateDimensionsForDrag(sheetView.top)
1215
+
1188
1216
  val position = getPositionDpForView(sheetView)
1189
1217
  val detent = detentCalculator.getDetentValueForIndex(currentDetentIndex)
1190
1218
  delegate?.viewControllerDidDragChange(currentDetentIndex, position, detent)
@@ -1253,19 +1281,56 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
1253
1281
  behavior.maxWidth = newMaxWidth
1254
1282
  }
1255
1283
 
1256
- private fun updateStateDimensions(expandedOffset: Int? = null) {
1257
- val offset = expandedOffset ?: (realScreenHeight - detentCalculator.getDetentHeight(detents.last()))
1258
- val topOffset = if (offset == 0) topInset else 0
1259
- val newHeight = realScreenHeight - offset - topOffset
1284
+ /**
1285
+ * Updates the Fabric state with the current/target detent size so Yoga sizes
1286
+ * the container to the sheet's visible height.
1287
+ *
1288
+ * Unlike iOS, resize animations run over multiple frames (ViewDragHelper), so
1289
+ * shrinking is deferred to settle ([deferShrink]) when a resize animation is
1290
+ * about to run — growing applies immediately so content is laid out before
1291
+ * the sheet reveals it.
1292
+ */
1293
+ private fun updateStateDimensions(deferShrink: Boolean = false) {
1294
+ if (detents.isEmpty()) return
1295
+
1296
+ val targetIndex = (if (pendingDetentIndex >= 0) pendingDetentIndex else currentDetentIndex)
1297
+ .coerceIn(0, detents.size - 1)
1298
+ val maxAvailableHeight = realScreenHeight - topInset
1299
+ val newHeight = minOf(detentCalculator.getDetentHeight(detents[targetIndex]), maxAvailableHeight)
1300
+ val newWidth = getStateWidth()
1301
+
1302
+ if (deferShrink && newWidth == lastStateWidth && newHeight < lastStateHeight) return
1303
+
1304
+ setStateDimensions(newWidth, newHeight)
1305
+ }
1306
+
1307
+ /**
1308
+ * Tracks the sheet's visible height during drag (and the post-release settle
1309
+ * animation) so the container resizes with the finger, like iOS. Clamped to the
1310
+ * smallest detent — dragging below it slides the sheet out without resizing.
1311
+ */
1312
+ private fun updateStateDimensionsForDrag(sheetTop: Int) {
1313
+ if (detents.isEmpty()) return
1314
+
1315
+ val maxAvailableHeight = realScreenHeight - topInset
1316
+ val minHeight = minOf(detentCalculator.getDetentHeight(detents.first()), maxAvailableHeight)
1317
+ val newHeight = detentCalculator.getVisibleSheetHeight(sheetTop).coerceIn(minHeight, maxAvailableHeight)
1318
+
1319
+ setStateDimensions(getStateWidth(), newHeight)
1320
+ }
1321
+
1322
+ private fun getStateWidth(): Int {
1260
1323
  val applyMaxWidth = maxContentWidth != null && !ScreenUtils.isPortraitPhone(reactContext)
1261
1324
  val effectiveMaxWidth = if (applyMaxWidth) maxContentWidth!! else DEFAULT_MAX_WIDTH.dpToPx().toInt()
1262
- val newWidth = minOf(screenWidth, effectiveMaxWidth)
1325
+ return minOf(screenWidth, effectiveMaxWidth)
1326
+ }
1263
1327
 
1264
- if (lastStateWidth != newWidth || lastStateHeight != newHeight) {
1265
- lastStateWidth = newWidth
1266
- lastStateHeight = newHeight
1267
- delegate?.viewControllerDidChangeSize(newWidth, newHeight)
1268
- }
1328
+ private fun setStateDimensions(width: Int, height: Int) {
1329
+ if (lastStateWidth == width && lastStateHeight == height) return
1330
+
1331
+ lastStateWidth = width
1332
+ lastStateHeight = height
1333
+ delegate?.viewControllerDidChangeSize(width, height)
1269
1334
  }
1270
1335
 
1271
1336
  fun translateSheet(translationY: Int, onEnd: (() -> Unit)? = null) {
@@ -221,11 +221,6 @@ class TrueSheetViewManager :
221
221
  view.setInsetAdjustment(insetAdjustment ?: "automatic")
222
222
  }
223
223
 
224
- @ReactProp(name = "scrollable", defaultBoolean = false)
225
- override fun setScrollable(view: TrueSheetView, value: Boolean) {
226
- view.setScrollable(value)
227
- }
228
-
229
224
  @ReactProp(name = "presentation")
230
225
  override fun setPresentation(view: TrueSheetView, value: String?) {
231
226
  // iOS/web-specific prop - no-op on Android
@@ -250,8 +245,17 @@ class TrueSheetViewManager :
250
245
  view.setScrollableOptions(scrollableOptions)
251
246
  }
252
247
 
248
+ @ReactProp(name = "headerOptions")
249
+ override fun setHeaderOptions(view: TrueSheetView, options: ReadableMap?) {
250
+ val position = if (options != null && options.hasKey("position")) options.getString("position") else null
251
+ view.setAbsoluteHeader(position == "absolute")
252
+ }
253
+
253
254
  @ReactProp(name = "footerOptions")
254
255
  override fun setFooterOptions(view: TrueSheetView, options: ReadableMap?) {
256
+ val position = if (options != null && options.hasKey("footerPosition")) options.getString("footerPosition") else null
257
+ view.setAbsoluteFooter(position == "absolute")
258
+
255
259
  val keyboardOffset =
256
260
  if (options != null && options.hasKey("keyboardOffset")) options.getDouble("keyboardOffset").toFloat() else 0f
257
261
  view.setFooterKeyboardOffset(keyboardOffset)
@@ -118,8 +118,9 @@ class TrueSheetCoordinatorLayout(context: Context) :
118
118
  override fun requestDisallowInterceptTouchEvent(disallowIntercept: Boolean) {
119
119
  // RN's ReactEditText fires this(true) on ACTION_DOWN, killing sheet drag over an input.
120
120
  // Swallow only that case — nothing between the touched EditText and the sheet scrolls
121
- // (an overflowing multiline EditText or an input inside a scrollable still scrolls).
122
- // Other children (maps, sliders, scrollables) keep the standard disallow contract.
121
+ // (an overflowing multiline EditText or an input inside a scrollable that can actually
122
+ // scroll still scrolls). Other children (maps, sliders, scrollables) keep the standard
123
+ // disallow contract.
123
124
  if (disallowIntercept && streamDraggableEditText) return
124
125
  super.requestDisallowInterceptTouchEvent(disallowIntercept)
125
126
  }
@@ -138,8 +139,16 @@ class TrueSheetCoordinatorLayout(context: Context) :
138
139
  .firstNotNullOfOrNull { it.getView() } ?: return
139
140
  if (target !== sheet && !target.isDescendantOf(sheet)) return
140
141
 
142
+ // A pinned scrollable that can scroll (or hosts pull-to-refresh) owns its
143
+ // touches — nested scrolling drags the sheet. When it can't scroll (e.g.
144
+ // the sheet is fully expanded and the content fits) it eats the gesture
145
+ // instead, so fall through and let the sheet drag from it — an EditText
146
+ // target needs the disallow-intercept swallow or its stream dies.
141
147
  val scrollView = delegate?.findScrollView()
142
- if (scrollView != null && (target === scrollView || target.isDescendantOf(scrollView))) return
148
+ if (scrollView != null && (target === scrollView || target.isDescendantOf(scrollView))) {
149
+ val hasRefreshControl = scrollView.parent is SwipeRefreshLayout
150
+ if (hasRefreshControl || scrollView.scrollY > 0 || scrollView.canScrollVertically(1)) return
151
+ }
143
152
 
144
153
  var view: View? = target
145
154
  while (view != null && view !== this) {
@@ -15,7 +15,9 @@ interface TrueSheetDetentCalculatorDelegate {
15
15
  val detents: MutableList<Double>
16
16
  val contentHeight: Int
17
17
  val headerHeight: Int
18
+ val absoluteHeader: Boolean
18
19
  val footerHeight: Int
20
+ val absoluteFooter: Boolean
19
21
  val peekContentHeight: Int
20
22
  val contentBottomInset: Int
21
23
  val maxContentHeight: Int?
@@ -34,6 +36,7 @@ class TrueSheetDetentCalculator(private val reactContext: ThemedReactContext) {
34
36
  private val realScreenHeight: Int get() = delegate?.realScreenHeight ?: 0
35
37
  private val detents: List<Double> get() = delegate?.detents ?: emptyList()
36
38
  private val contentHeight: Int get() = delegate?.contentHeight ?: 0
39
+
37
40
  private val headerHeight: Int get() = delegate?.headerHeight ?: 0
38
41
  private val footerHeight: Int get() = delegate?.footerHeight ?: 0
39
42
  private val peekContentHeight: Int get() = delegate?.peekContentHeight ?: 0
@@ -42,13 +45,39 @@ class TrueSheetDetentCalculator(private val reactContext: ThemedReactContext) {
42
45
  private val keyboardInset: Int get() = delegate?.keyboardInset ?: 0
43
46
  private val topInset: Int get() = delegate?.topInset ?: 0
44
47
 
48
+ /**
49
+ * Height for auto (-1.0) detents: content + header + footer height.
50
+ * An absolute (floating) header or footer overlaps the content, so it contributes no height.
51
+ */
52
+ private val autoDetentHeight: Int
53
+ get() = contentHeight +
54
+ (if (delegate?.absoluteHeader == true) 0 else headerHeight) +
55
+ (if (delegate?.absoluteFooter == true) 0 else footerHeight)
56
+
57
+ /**
58
+ * Bottom inset for auto detents. A relative footer owns the sheet's bottom
59
+ * edge, so it absorbs the inset instead of adding it to the auto height.
60
+ */
61
+ private val autoDetentBottomInset: Int
62
+ get() = if (footerHeight > 0 && delegate?.absoluteFooter == false) 0 else contentBottomInset
63
+
64
+ /**
65
+ * Bottom inset for peek detents. An absolute footer counts toward the peek
66
+ * height and absorbs the inset, so it isn't added again.
67
+ */
68
+ private val peekDetentBottomInset: Int
69
+ get() = if (footerHeight > 0 && delegate?.absoluteFooter == true) 0 else contentBottomInset
70
+
45
71
  /**
46
72
  * Height for peek (-2.0) detents: header + footer + peek content height.
73
+ * A relative footer is pushed off-screen at peek, so it contributes no height.
47
74
  * Falls back to 150dp when none is present.
48
75
  */
49
76
  private val peekDetentHeight: Int
50
77
  get() {
51
- val height = headerHeight + footerHeight + peekContentHeight
78
+ val height = headerHeight +
79
+ (if (delegate?.absoluteFooter == true) footerHeight else 0) +
80
+ peekContentHeight
52
81
  return if (height > 0) height else DEFAULT_PEEK_HEIGHT.dpToPx().toInt()
53
82
  }
54
83
 
@@ -58,9 +87,9 @@ class TrueSheetDetentCalculator(private val reactContext: ThemedReactContext) {
58
87
  */
59
88
  fun getDetentHeight(detent: Double, includeKeyboard: Boolean = true): Int {
60
89
  val baseHeight = if (detent == -1.0) {
61
- contentHeight + headerHeight + contentBottomInset
90
+ autoDetentHeight + autoDetentBottomInset
62
91
  } else if (detent == -2.0) {
63
- peekDetentHeight + contentBottomInset
92
+ peekDetentHeight + peekDetentBottomInset
64
93
  } else {
65
94
  if (detent <= 0.0 || detent > 1.0) {
66
95
  throw IllegalArgumentException("TrueSheet: detent fraction ($detent) must be between 0 and 1")
@@ -106,7 +135,7 @@ class TrueSheetDetentCalculator(private val reactContext: ThemedReactContext) {
106
135
  if (index < 0 || index >= detents.size) return 0f
107
136
  val value = detents[index]
108
137
  return if (value == -1.0) {
109
- (contentHeight + headerHeight).toFloat() / screenHeight.toFloat()
138
+ autoDetentHeight.toFloat() / screenHeight.toFloat()
110
139
  } else if (value == -2.0) {
111
140
  peekDetentHeight.toFloat() / screenHeight.toFloat()
112
141
  } else {
@@ -3,6 +3,8 @@
3
3
  #include <ReactCommon/JavaTurboModule.h>
4
4
  #include <ReactCommon/TurboModule.h>
5
5
  #include <jsi/jsi.h>
6
+ #include <react/renderer/components/TrueSheetSpec/TrueSheetContentViewComponentDescriptor.h>
7
+ #include <react/renderer/components/TrueSheetSpec/TrueSheetFooterViewComponentDescriptor.h>
6
8
  #include <react/renderer/components/TrueSheetSpec/TrueSheetViewComponentDescriptor.h>
7
9
 
8
10
  namespace facebook {
@@ -0,0 +1,96 @@
1
+ //
2
+ // Created by Jovanni Lo (@lodev09)
3
+ // Copyright (c) 2024-present. All rights reserved.
4
+ //
5
+ // This source code is licensed under the MIT license found in the
6
+ // LICENSE file in the root directory of this source tree.
7
+ //
8
+
9
+ #include <fbjni/fbjni.h>
10
+ #include <react/fabric/StateWrapperImpl.h>
11
+ #include <react/renderer/components/TrueSheetSpec/TrueSheetFooterViewState.h>
12
+ #include <react/renderer/components/TrueSheetSpec/TrueSheetInsets.h>
13
+ #include <react/renderer/components/TrueSheetSpec/TrueSheetViewState.h>
14
+ #include <react/renderer/core/ConcreteState.h>
15
+
16
+ namespace facebook::react {
17
+
18
+ static std::shared_ptr<const State> getStateFromWrapper(jni::alias_ref<jobject> stateWrapper) {
19
+ static const auto stateWrapperImplClass =
20
+ jni::findClassStatic(StateWrapperImpl::StateWrapperImplJavaDescriptor);
21
+ if (!stateWrapper->isInstanceOf(stateWrapperImplClass)) {
22
+ return nullptr;
23
+ }
24
+
25
+ auto impl = jni::static_ref_cast<StateWrapperImpl::jhybridobject>(stateWrapper);
26
+ return impl->cthis()->getState();
27
+ }
28
+
29
+ // Kotlin's StateWrapper.updateState is hardcoded async. This bridges to
30
+ // ConcreteState::updateState with unstable_Immediate so the commit — and, when
31
+ // called from the UI thread, the mount — run synchronously, letting Yoga resize
32
+ // the container in the same frame as the sheet (parity with iOS).
33
+ static jboolean updateStateImmediate(
34
+ jni::alias_ref<jclass> /*clazz*/,
35
+ jni::alias_ref<jobject> stateWrapper,
36
+ jfloat containerWidth,
37
+ jfloat containerHeight) {
38
+ auto state = getStateFromWrapper(stateWrapper);
39
+ if (!state) {
40
+ return JNI_FALSE;
41
+ }
42
+
43
+ auto concreteState = std::static_pointer_cast<const ConcreteState<TrueSheetViewState>>(state);
44
+
45
+ // Copy the latest data so fields this bridge doesn't set survive the update
46
+ TrueSheetViewState newState = concreteState->getData();
47
+ newState.containerWidth = containerWidth;
48
+ newState.containerHeight = containerHeight;
49
+ concreteState->updateState(std::move(newState), EventQueue::UpdateMode::unstable_Immediate);
50
+
51
+ return JNI_TRUE;
52
+ }
53
+
54
+ // Same synchronous bridge for the footer's bottom inset — the footer must be
55
+ // padded (and resized) before detents are configured, otherwise the behavior
56
+ // is set up against the unpadded footer height and the auto detent lands an
57
+ // inset short.
58
+ static jboolean updateFooterStateImmediate(
59
+ jni::alias_ref<jclass> /*clazz*/,
60
+ jni::alias_ref<jobject> stateWrapper,
61
+ jfloat bottomInset) {
62
+ auto state = getStateFromWrapper(stateWrapper);
63
+ if (!state) {
64
+ return JNI_FALSE;
65
+ }
66
+
67
+ auto concreteState =
68
+ std::static_pointer_cast<const ConcreteState<TrueSheetFooterViewState>>(state);
69
+
70
+ // Copy the latest data so fields this bridge doesn't set survive the update
71
+ TrueSheetFooterViewState newState = concreteState->getData();
72
+ newState.bottomInset = bottomInset;
73
+ newState.initialized = true;
74
+ concreteState->updateState(std::move(newState), EventQueue::UpdateMode::unstable_Immediate);
75
+
76
+ return JNI_TRUE;
77
+ }
78
+
79
+ // Publishes the precalculated bottom safe-area inset so a late-mounted
80
+ // footer's first layout is already padded (see TrueSheetFooterViewShadowNode).
81
+ static void setBottomSafeArea(jni::alias_ref<jclass> /*clazz*/, jfloat insetDp) {
82
+ TrueSheetInsets::setBottomSafeArea(insetDp);
83
+ }
84
+
85
+ } // namespace facebook::react
86
+
87
+ JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM *vm, void * /*reserved*/) {
88
+ return facebook::jni::initialize(vm, [] {
89
+ facebook::jni::findClassStatic("com/lodev09/truesheet/TrueSheetStateUpdater")
90
+ ->registerNatives({
91
+ makeNativeMethod("nativeUpdateState", facebook::react::updateStateImmediate),
92
+ makeNativeMethod("nativeUpdateFooterState", facebook::react::updateFooterStateImmediate),
93
+ makeNativeMethod("nativeSetBottomSafeArea", facebook::react::setBottomSafeArea),
94
+ });
95
+ });
96
+ }
@@ -0,0 +1,24 @@
1
+ #pragma once
2
+
3
+ #include <react/renderer/components/TrueSheetSpec/TrueSheetContentViewShadowNode.h>
4
+ #include <react/renderer/core/ConcreteComponentDescriptor.h>
5
+
6
+ namespace facebook::react {
7
+
8
+ /*
9
+ * Descriptor for <TrueSheetContentView> component.
10
+ */
11
+ class TrueSheetContentViewComponentDescriptor final
12
+ : public ConcreteComponentDescriptor<TrueSheetContentViewShadowNode> {
13
+ using ConcreteComponentDescriptor::ConcreteComponentDescriptor;
14
+
15
+ void adopt(ShadowNode &shadowNode) const override {
16
+ auto &concreteShadowNode =
17
+ static_cast<TrueSheetContentViewShadowNode &>(shadowNode);
18
+ concreteShadowNode.adjustLayoutWithState();
19
+
20
+ ConcreteComponentDescriptor::adopt(shadowNode);
21
+ }
22
+ };
23
+
24
+ } // namespace facebook::react
@@ -0,0 +1,43 @@
1
+ #include "TrueSheetContentViewShadowNode.h"
2
+
3
+ #include <react/renderer/components/view/conversions.h>
4
+
5
+ namespace facebook::react {
6
+
7
+ using namespace yoga;
8
+
9
+ extern const char TrueSheetContentViewComponentName[] = "TrueSheetContentView";
10
+
11
+ void TrueSheetContentViewShadowNode::adjustLayoutWithState() {
12
+ ensureUnsealed();
13
+
14
+ auto state = std::static_pointer_cast<
15
+ const TrueSheetContentViewShadowNode::ConcreteState>(getState());
16
+ auto stateData = state->getData();
17
+
18
+ auto &props = getConcreteProps();
19
+
20
+ // When a ScrollView is pinned under an auto detent, fill the container so
21
+ // descendant flex layouts (flex:1 wrappers, the ScrollView itself) resolve
22
+ // against a definite height and the viewport is bounded — natural layout is
23
+ // circular there since the sheet height derives from the ScrollView's content
24
+ // size instead (see ContentView's naturalHeight on each platform). For fixed
25
+ // detents, content lays out naturally like a regular view.
26
+ auto flexGrow = stateData.scrollableBounded
27
+ ? FloatOptional{1.0f}
28
+ : props.yogaStyle.flexGrow();
29
+ auto flexShrink = stateData.scrollableBounded
30
+ ? FloatOptional{1.0f}
31
+ : props.yogaStyle.flexShrink();
32
+
33
+ if (yogaNode_.style().flexGrow() != flexGrow ||
34
+ yogaNode_.style().flexShrink() != flexShrink) {
35
+ yoga::Style adjustedStyle = props.yogaStyle;
36
+ adjustedStyle.setFlexGrow(flexGrow);
37
+ adjustedStyle.setFlexShrink(flexShrink);
38
+ yogaNode_.setStyle(adjustedStyle);
39
+ yogaNode_.setDirty(true);
40
+ }
41
+ }
42
+
43
+ } // namespace facebook::react
@@ -0,0 +1,28 @@
1
+ #pragma once
2
+
3
+ #include <jsi/jsi.h>
4
+ #include <react/renderer/components/TrueSheetSpec/EventEmitters.h>
5
+ #include <react/renderer/components/TrueSheetSpec/Props.h>
6
+ #include <react/renderer/components/TrueSheetSpec/TrueSheetContentViewState.h>
7
+ #include <react/renderer/components/view/ConcreteViewShadowNode.h>
8
+
9
+ namespace facebook::react {
10
+
11
+ JSI_EXPORT extern const char TrueSheetContentViewComponentName[];
12
+
13
+ /*
14
+ * `ShadowNode` for <TrueSheetContentView> component.
15
+ */
16
+ class JSI_EXPORT TrueSheetContentViewShadowNode final
17
+ : public ConcreteViewShadowNode<
18
+ TrueSheetContentViewComponentName,
19
+ TrueSheetContentViewProps,
20
+ TrueSheetContentViewEventEmitter,
21
+ TrueSheetContentViewState> {
22
+ using ConcreteViewShadowNode::ConcreteViewShadowNode;
23
+
24
+ public:
25
+ void adjustLayoutWithState();
26
+ };
27
+
28
+ } // namespace facebook::react
@@ -0,0 +1,11 @@
1
+ #include "TrueSheetContentViewState.h"
2
+
3
+ namespace facebook::react {
4
+
5
+ #ifdef ANDROID
6
+ folly::dynamic TrueSheetContentViewState::getDynamic() const {
7
+ return folly::dynamic::object("scrollableBounded", scrollableBounded);
8
+ }
9
+ #endif
10
+
11
+ } // namespace facebook::react
@@ -0,0 +1,37 @@
1
+ #pragma once
2
+
3
+ #ifdef ANDROID
4
+ #include <folly/dynamic.h>
5
+ #include <react/renderer/mapbuffer/MapBuffer.h>
6
+ #include <react/renderer/mapbuffer/MapBufferBuilder.h>
7
+ #endif
8
+
9
+ namespace facebook::react {
10
+
11
+ /*
12
+ * State for <TrueSheetContentView> component.
13
+ * Set from native when a ScrollView is pinned so the shadow node bounds the
14
+ * content to the container via flexShrink (see TrueSheetContentViewShadowNode).
15
+ */
16
+ class TrueSheetContentViewState final {
17
+ public:
18
+ TrueSheetContentViewState() = default;
19
+
20
+ #ifdef ANDROID
21
+ TrueSheetContentViewState(
22
+ TrueSheetContentViewState const &previousState,
23
+ folly::dynamic data)
24
+ : scrollableBounded(data["scrollableBounded"].getBool()) {}
25
+ #endif
26
+
27
+ bool scrollableBounded{false};
28
+
29
+ #ifdef ANDROID
30
+ folly::dynamic getDynamic() const;
31
+ MapBuffer getMapBuffer() const {
32
+ return MapBufferBuilder::EMPTY();
33
+ }
34
+ #endif
35
+ };
36
+
37
+ } // namespace facebook::react