@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
@@ -101,13 +101,30 @@ class TrueSheetView(private val reactContext: ThemedReactContext) :
101
101
 
102
102
  override fun onAttachedToWindow() {
103
103
  super.onAttachedToWindow()
104
+ presentInitialIfNeeded()
105
+ }
104
106
 
105
- if (initialDetentIndex >= 0 && !didInitiallyPresent) {
106
- didInitiallyPresent = true
107
- if (initialDetentAnimated) {
108
- present(initialDetentIndex, true) { }
109
- } else {
110
- post { present(initialDetentIndex, false) { } }
107
+ /**
108
+ * JS holds initialDetentIndex back one commit so effect-driven content (e.g. a
109
+ * navigation footer set via setOptions) commits together with it. The prop then
110
+ * arrives after attach, so the ViewManager triggers this on prop update too.
111
+ * Posted so sibling mounts from the current transaction land before measuring.
112
+ */
113
+ fun presentInitialIfNeeded() {
114
+ if (initialDetentIndex < 0 || didInitiallyPresent || !isAttachedToWindow) return
115
+
116
+ didInitiallyPresent = true
117
+ post {
118
+ // The view may have been dropped (flag reset) or detached before this
119
+ // ran — roll back so a later attach retries instead of presenting a
120
+ // sheet with no live host.
121
+ if (!didInitiallyPresent) return@post
122
+ if (!isAttachedToWindow) {
123
+ didInitiallyPresent = false
124
+ return@post
125
+ }
126
+ if (!viewController.isPresented) {
127
+ present(initialDetentIndex, initialDetentAnimated) { }
111
128
  }
112
129
  }
113
130
  }
@@ -287,6 +304,12 @@ class TrueSheetView(private val reactContext: ThemedReactContext) :
287
304
  setupScrollable()
288
305
  }
289
306
 
307
+ fun setScrollableHandle(handle: Int) {
308
+ if (viewController.scrollableHandle == handle) return
309
+ viewController.scrollableHandle = handle
310
+ setupScrollable()
311
+ }
312
+
290
313
  fun setAbsoluteHeader(absolute: Boolean) {
291
314
  viewController.absoluteHeader = absolute
292
315
  }
@@ -302,9 +325,9 @@ class TrueSheetView(private val reactContext: ThemedReactContext) :
302
325
 
303
326
  private fun setupScrollable() {
304
327
  viewController.containerView?.let {
305
- it.insetAdjustment = viewController.insetAdjustment
306
- it.scrollViewBottomInset = viewController.contentBottomInset
307
328
  it.scrollableOptions = viewController.scrollableOptions
329
+ it.scrollableHandle = viewController.scrollableHandle
330
+ it.scrollableBottomInset = viewController.contentBottomInset
308
331
  it.hasAutoDetent = viewController.detents.contains(-1.0)
309
332
  it.footerBottomInset = viewController.contentBottomInset
310
333
  it.absoluteFooter = viewController.absoluteFooter
@@ -469,7 +492,12 @@ class TrueSheetView(private val reactContext: ThemedReactContext) :
469
492
  if (viewController.containerView == null) return@post
470
493
 
471
494
  viewController.setupSheetDetentsForSizeChange()
472
- TrueSheetStackManager.updateParentTranslation(this)
495
+ // While settling, onSlide drives the parent translation in realtime —
496
+ // an animated update here would race it frame-by-frame, staggering the
497
+ // emitted position
498
+ if (!viewController.isSettling) {
499
+ TrueSheetStackManager.updateParentTranslation(this)
500
+ }
473
501
  }
474
502
  }
475
503
 
@@ -497,14 +525,46 @@ class TrueSheetView(private val reactContext: ThemedReactContext) :
497
525
  }
498
526
  }
499
527
 
528
+ /**
529
+ * Realtime counterpart of updateTranslationForChild, driven by the child's
530
+ * onSlide frames so stacked sheets move in lockstep instead of on
531
+ * independently-timed animations.
532
+ */
533
+ fun syncTranslationForChild(childSheetTop: Int, childMinSheetTop: Int) {
534
+ if (!viewController.isSheetVisible || viewController.isExpanded) return
535
+
536
+ val mySheetTop = viewController.detentCalculator.getSheetTopForDetentIndex(viewController.currentDetentIndex)
537
+ val targetTranslation = maxOf(0, childMinSheetTop - mySheetTop)
538
+ val actualTranslation = maxOf(0, childSheetTop - mySheetTop)
539
+ val currentTranslation = viewController.currentTranslationY
540
+
541
+ // Ride the child's actual position, but only between the current
542
+ // translation and the settled target — tracks the child frame-by-frame
543
+ // when the alignment changes (auto detent resize) while staying put when
544
+ // the child moves between detents above its minimum. Returning early on
545
+ // no-ops avoids cancelling an in-flight realign animation.
546
+ val newTranslation = actualTranslation.coerceIn(
547
+ minOf(currentTranslation, targetTranslation),
548
+ maxOf(currentTranslation, targetTranslation)
549
+ )
550
+ if (newTranslation == currentTranslation) return
551
+
552
+ viewController.translateSheet(newTranslation, animated = false)
553
+
554
+ val additionalTranslation = newTranslation - currentTranslation
555
+ if (additionalTranslation > 0) {
556
+ TrueSheetStackManager.getParentSheet(this)?.addTranslation(additionalTranslation, animated = false)
557
+ }
558
+ }
559
+
500
560
  /**
501
561
  * Recursively adds translation to this sheet and all parent sheets.
502
562
  */
503
- private fun addTranslation(amount: Int) {
563
+ private fun addTranslation(amount: Int, animated: Boolean = true) {
504
564
  if (viewController.isExpanded) return
505
565
 
506
- viewController.translateSheet(viewController.currentTranslationY + amount)
507
- TrueSheetStackManager.getParentSheet(this)?.addTranslation(amount)
566
+ viewController.translateSheet(viewController.currentTranslationY + amount, animated)
567
+ TrueSheetStackManager.getParentSheet(this)?.addTranslation(amount, animated)
508
568
  }
509
569
 
510
570
  /**
@@ -626,7 +686,7 @@ class TrueSheetView(private val reactContext: ThemedReactContext) :
626
686
  viewController.commitKeyboardDetent()
627
687
  }
628
688
 
629
- // When the ScrollView changes (e.g. conditional remount), re-pin the new ScrollView
689
+ // When the ScrollView changes (e.g. conditional remount), re-detect the new ScrollView
630
690
  // and request layout so BottomSheetBehavior re-discovers the nested scrolling child.
631
691
  override fun containerViewScrollViewDidChange() {
632
692
  setupScrollable()
@@ -37,6 +37,7 @@ import com.lodev09.truesheet.core.TrueSheetDimViewDelegate
37
37
  import com.lodev09.truesheet.core.TrueSheetKeyboardObserver
38
38
  import com.lodev09.truesheet.core.TrueSheetKeyboardObserverDelegate
39
39
  import com.lodev09.truesheet.core.TrueSheetStackManager
40
+ import com.lodev09.truesheet.utils.Insets
40
41
  import com.lodev09.truesheet.utils.KeyboardUtils
41
42
  import com.lodev09.truesheet.utils.ScreenUtils
42
43
  import com.lodev09.truesheet.utils.TouchEventDeduper
@@ -162,6 +163,10 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
162
163
  private var pendingDetentIndex: Int = -1
163
164
 
164
165
  private var interactionState: InteractionState = InteractionState.Idle
166
+
167
+ // A content/header/footer size change arrived mid-drag — reconfiguring then
168
+ // would reset behavior state and kill the gesture, so it's applied on settle.
169
+ private var hasPendingSizeChange = false
165
170
  internal var isBeingDismissed = false
166
171
  private set
167
172
  var wasHiddenByScreen = false
@@ -227,6 +232,9 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
227
232
  behavior?.scrollingExpandsSheet = value?.scrollingExpandsSheet ?: true
228
233
  }
229
234
 
235
+ // React tag of the user-provided scrollable within the content (see the scrollableRef prop)
236
+ var scrollableHandle: Int = -1
237
+
230
238
  override var sheetCornerRadius: Float = DEFAULT_CORNER_RADIUS.dpToPx()
231
239
  set(value) {
232
240
  field = if (value < 0) DEFAULT_CORNER_RADIUS.dpToPx() else value
@@ -270,9 +278,29 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
270
278
  val screenWidth: Int
271
279
  get() = ScreenUtils.getScreenWidth(reactContext)
272
280
 
281
+ // Cached per layout/configuration pass — the WindowManager and
282
+ // rootWindowInsets queries behind these allocate, and they're read many
283
+ // times per animation frame (position emission, detent interpolation,
284
+ // stacked sheet sync)
285
+ private var cachedRealScreenHeight: Int = 0
286
+ private var cachedInsets: Insets? = null
287
+
288
+ private fun invalidateScreenMetrics() {
289
+ cachedRealScreenHeight = 0
290
+ cachedInsets = null
291
+ }
292
+
273
293
  // Includes system bars for accurate positioning
274
294
  override val realScreenHeight: Int
275
- get() = ScreenUtils.getRealScreenHeight(reactContext)
295
+ get() {
296
+ if (cachedRealScreenHeight == 0) {
297
+ cachedRealScreenHeight = ScreenUtils.getRealScreenHeight(reactContext)
298
+ }
299
+ return cachedRealScreenHeight
300
+ }
301
+
302
+ private val insets: Insets
303
+ get() = cachedInsets ?: ScreenUtils.getInsets(reactContext).also { cachedInsets = it }
276
304
 
277
305
  // Content Measurements
278
306
  // Cached values used during dismiss when container is unmounted
@@ -315,10 +343,10 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
315
343
  }
316
344
 
317
345
  val bottomInset: Int
318
- get() = if (edgeToEdgeEnabled) ScreenUtils.getInsets(reactContext).bottom else 0
346
+ get() = if (edgeToEdgeEnabled) insets.bottom else 0
319
347
 
320
348
  override val topInset: Int
321
- get() = if (edgeToEdgeEnabled) ScreenUtils.getInsets(reactContext).top else 0
349
+ get() = if (edgeToEdgeEnabled) insets.top else 0
322
350
 
323
351
  override val contentBottomInset: Int
324
352
  get() = if (insetAdjustment == TrueSheetInsetAdjustment.AUTOMATIC) bottomInset else 0
@@ -331,6 +359,9 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
331
359
  }
332
360
 
333
361
  // Sheet State
362
+ val isSettling: Boolean
363
+ get() = behavior?.state == BottomSheetBehavior.STATE_SETTLING
364
+
334
365
  val isExpanded: Boolean
335
366
  get() {
336
367
  val sheetTop = sheetView?.top ?: return false
@@ -392,6 +423,7 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
392
423
  sheetView = null
393
424
 
394
425
  interactionState = InteractionState.Idle
426
+ hasPendingSizeChange = false
395
427
  isBeingDismissed = false
396
428
  isPresented = false
397
429
  isSheetVisible = false
@@ -440,6 +472,8 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
440
472
  // =============================================================================
441
473
 
442
474
  override fun coordinatorLayoutDidLayout(changed: Boolean) {
475
+ if (changed) invalidateScreenMetrics()
476
+
443
477
  // Reposition footer when layout changes
444
478
  if (isPresented && changed) {
445
479
  positionFooter()
@@ -447,6 +481,7 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
447
481
  }
448
482
 
449
483
  override fun coordinatorLayoutDidChangeConfiguration() {
484
+ invalidateScreenMetrics()
450
485
  if (!isPresented) return
451
486
 
452
487
  sheetView?.updateGravity()
@@ -570,6 +605,14 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
570
605
 
571
606
  emitChangePositionDelegate(sheetView.top)
572
607
 
608
+ // Drive the parent's stack translation from our actual frame position —
609
+ // an independently-timed animation lags behind (e.g. auto detent shrink)
610
+ // and reveals the parent above this sheet's top edge. Skipped while
611
+ // dragging so the parent stays put during drag-to-dismiss.
612
+ if (!isPresentAnimating && interactionState !is InteractionState.Dragging) {
613
+ syncParentTranslation(sheetView.top)
614
+ }
615
+
573
616
  // Position on every slide frame — during keyboard transitions the IME
574
617
  // animation callbacks are sparser than the sheet's settle frames, and the
575
618
  // footer rides the sheet between them, jittering against the keyboard.
@@ -589,7 +632,15 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
589
632
  // whether settled at the target, interrupted by drag, or superseded.
590
633
  pendingDetentIndex = -1
591
634
 
592
- val index = detentCalculator.getDetentIndexForState(newState) ?: return
635
+ val index = detentCalculator.getDetentIndexForState(newState) ?: run {
636
+ // Unmapped settle — end any drag anyway so deferred size changes aren't
637
+ // dropped forever (a leaked Dragging state skips every reconfigure)
638
+ if (interactionState is InteractionState.Dragging) {
639
+ interactionState = InteractionState.Idle
640
+ }
641
+ flushPendingSizeChange()
642
+ return
643
+ }
593
644
  val position = getPositionDpForView(sheetView)
594
645
  val detentInfo = DetentInfo(index, position)
595
646
 
@@ -637,6 +688,14 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
637
688
  }
638
689
 
639
690
  TrueSheetStackManager.updateBackgroundAccessibility()
691
+
692
+ flushPendingSizeChange()
693
+ }
694
+
695
+ private fun flushPendingSizeChange() {
696
+ if (!hasPendingSizeChange) return
697
+ hasPendingSizeChange = false
698
+ setupSheetDetentsForSizeChange()
640
699
  }
641
700
 
642
701
  // =============================================================================
@@ -698,6 +757,7 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
698
757
  val sheet = this.sheetView ?: return
699
758
  if (isPresented) return
700
759
 
760
+ invalidateScreenMetrics()
701
761
  shouldAnimatePresent = animated
702
762
  currentDetentIndex = detentIndex
703
763
  interactionState = InteractionState.Idle
@@ -922,10 +982,15 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
922
982
  }
923
983
 
924
984
  fun setupSheetDetentsForSizeChange() {
925
- // Skip while dragging — the size change is driven by the drag itself (the
985
+ // Defer while dragging — the size change is driven by the drag itself (the
926
986
  // container tracks the sheet's visible height), and reconfiguring resets
927
- // behavior state, killing the gesture.
928
- if (interactionState is InteractionState.Dragging) return
987
+ // behavior state, killing the gesture. Applied on settle so a real content
988
+ // change mid-drag (e.g. items removed from a list) isn't lost.
989
+ if (interactionState is InteractionState.Dragging) {
990
+ hasPendingSizeChange = true
991
+ return
992
+ }
993
+ hasPendingSizeChange = false
929
994
 
930
995
  setupSheetDetents()
931
996
  positionFooter()
@@ -1011,8 +1076,6 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
1011
1076
  val footerView = containerView?.footerView ?: return
1012
1077
  val sheet = sheetView ?: return
1013
1078
 
1014
- val keyboardShift = if (currentKeyboardInset > 0) maxOf(0, currentKeyboardInset + footerKeyboardOffset) else 0
1015
-
1016
1079
  // A relative footer is laid out by Yoga (pinned to the container's bottom
1017
1080
  // edge) and stays in the layout flow behind the keyboard — only an
1018
1081
  // absolute footer floats above it
@@ -1021,6 +1084,15 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
1021
1084
  return
1022
1085
  }
1023
1086
 
1087
+ // The footer's absorbed safe-area inset rides below the keyboard's top
1088
+ // edge — its content stays flush with the keyboard without dropping the
1089
+ // inset padding from the layout, which would jump at the transition edges
1090
+ val keyboardShift = if (currentKeyboardInset > 0) {
1091
+ maxOf(0, currentKeyboardInset + footerKeyboardOffset - footerView.bottomInset)
1092
+ } else {
1093
+ 0
1094
+ }
1095
+
1024
1096
  val footerHeight = footerView.height
1025
1097
  val sheetHeight = sheet.height
1026
1098
  val sheetTop = sheet.top
@@ -1079,9 +1151,6 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
1079
1151
  delegate = object : TrueSheetKeyboardObserverDelegate {
1080
1152
  override fun keyboardWillShow(height: Int) {
1081
1153
  if (!shouldHandleKeyboard()) return
1082
- // An absolute footer rises above the keyboard, so it drops the bottom
1083
- // inset padding — before detents are configured against its height
1084
- if (absoluteFooter) containerView?.footerView?.keyboardVisible = true
1085
1154
  // If a resize is in flight, restore to its target — not the stale current
1086
1155
  detentIndexBeforeKeyboard = if (pendingDetentIndex >= 0) pendingDetentIndex else currentDetentIndex
1087
1156
  pendingDetentIndex = -1
@@ -1094,7 +1163,6 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
1094
1163
 
1095
1164
  override fun keyboardWillHide() {
1096
1165
  if (!shouldHandleKeyboard(checkFocus = false)) return
1097
- containerView?.footerView?.keyboardVisible = false
1098
1166
  val restoring = !isBeingDismissed && detentIndexBeforeKeyboard >= 0
1099
1167
 
1100
1168
  // Skip reconfigure during interactive keyboard dismiss (e.g. keyboardDismissMode="on-drag")
@@ -1119,7 +1187,6 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
1119
1187
 
1120
1188
  override fun keyboardDidHide() {
1121
1189
  if (!shouldHandleKeyboard(checkFocus = false)) return
1122
- containerView?.footerView?.keyboardVisible = false
1123
1190
  detentIndexBeforeKeyboard = -1
1124
1191
  isKeyboardDismissProgrammatic = false
1125
1192
  setupSheetDetents(applyState = false)
@@ -1141,7 +1208,6 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
1141
1208
  // Handle case where keyboard is already visible and focus moves into the sheet
1142
1209
  if (!shouldHandleKeyboard()) return
1143
1210
  if (detentIndexBeforeKeyboard < 0 && (keyboardObserver?.currentHeight ?: 0) > 0) {
1144
- if (absoluteFooter) containerView?.footerView?.keyboardVisible = true
1145
1211
  detentIndexBeforeKeyboard = currentDetentIndex
1146
1212
  currentDetentIndex = detents.size - 1
1147
1213
  setupSheetDetents()
@@ -1256,8 +1322,7 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
1256
1322
  lastEmittedPositionPx = currentTop
1257
1323
  val visibleHeight = realScreenHeight - currentTop
1258
1324
  val position = detentCalculator.getPositionDp(visibleHeight)
1259
- val interpolatedIndex = detentCalculator.getInterpolatedIndexForPosition(currentTop)
1260
- val detent = detentCalculator.getInterpolatedDetentForPosition(currentTop)
1325
+ val (interpolatedIndex, detent) = detentCalculator.getInterpolatedPositionInfo(currentTop)
1261
1326
  delegate?.viewControllerDidChangePosition(interpolatedIndex, position, detent, realtime)
1262
1327
  }
1263
1328
 
@@ -1336,9 +1401,27 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
1336
1401
  delegate?.viewControllerDidChangeSize(width, height)
1337
1402
  }
1338
1403
 
1339
- fun translateSheet(translationY: Int, onEnd: (() -> Unit)? = null) {
1404
+ /**
1405
+ * Realtime (non-animated) counterpart of TrueSheetStackManager.updateParentTranslation,
1406
+ * driven by onSlide so the parent tracks this sheet's actual position.
1407
+ */
1408
+ private fun syncParentTranslation(sheetTop: Int) {
1409
+ val parent = parentSheetView ?: return
1410
+ val minSheetTop = detentCalculator.getSheetTopForDetentIndex(0)
1411
+ parent.syncTranslationForChild(sheetTop, minSheetTop)
1412
+ }
1413
+
1414
+ fun translateSheet(translationY: Int, animated: Boolean = true, onEnd: (() -> Unit)? = null) {
1340
1415
  val sheet = sheetView ?: return
1341
1416
 
1417
+ if (!animated) {
1418
+ sheet.animate().cancel()
1419
+ sheet.translationY = translationY.toFloat()
1420
+ emitChangePositionDelegate(sheet.top + translationY)
1421
+ onEnd?.invoke()
1422
+ return
1423
+ }
1424
+
1342
1425
  sheet.animate()
1343
1426
  .translationY(translationY.toFloat())
1344
1427
  .setDuration(TRANSLATE_ANIMATION_DURATION)
@@ -5,6 +5,7 @@ import com.facebook.react.bridge.ReadableArray
5
5
  import com.facebook.react.bridge.ReadableMap
6
6
  import com.facebook.react.module.annotations.ReactModule
7
7
  import com.facebook.react.uimanager.PixelUtil.dpToPx
8
+ import com.facebook.react.uimanager.PointerEvents
8
9
  import com.facebook.react.uimanager.ReactStylesDiffMap
9
10
  import com.facebook.react.uimanager.StateWrapper
10
11
  import com.facebook.react.uimanager.ThemedReactContext
@@ -55,6 +56,20 @@ class TrueSheetViewManager :
55
56
 
56
57
  override fun getDelegate(): ViewManagerDelegate<TrueSheetView> = delegate
57
58
 
59
+ // The codegen delegate drops props outside the spec (pointerEvents comes from the
60
+ // host's style), so apply it here after the delegate pass.
61
+ override fun updateProperties(viewToUpdate: TrueSheetView, props: ReactStylesDiffMap) {
62
+ super.updateProperties(viewToUpdate, props)
63
+ if (props.hasKey("pointerEvents")) {
64
+ setPointerEvents(viewToUpdate, props.getString("pointerEvents"))
65
+ }
66
+ }
67
+
68
+ @ReactProp(name = "pointerEvents")
69
+ fun setPointerEvents(view: TrueSheetView, pointerEventsStr: String?) {
70
+ view.pointerEvents = PointerEvents.parsePointerEvents(pointerEventsStr)
71
+ }
72
+
58
73
  /**
59
74
  * Export custom direct event types for Fabric
60
75
  * Uses Kotlin native collections with decoupled event classes
@@ -179,6 +194,7 @@ class TrueSheetViewManager :
179
194
  @ReactProp(name = "initialDetentIndex", defaultInt = -1)
180
195
  override fun setInitialDetentIndex(view: TrueSheetView, index: Int) {
181
196
  view.initialDetentIndex = index
197
+ view.presentInitialIfNeeded()
182
198
  }
183
199
 
184
200
  @ReactProp(name = "initialDetentAnimated", defaultBoolean = true)
@@ -231,6 +247,11 @@ class TrueSheetViewManager :
231
247
  view.setSheetElevation(elevation.toFloat())
232
248
  }
233
249
 
250
+ @ReactProp(name = "scrollableHandle", defaultInt = -1)
251
+ override fun setScrollableHandle(view: TrueSheetView, handle: Int) {
252
+ view.setScrollableHandle(handle)
253
+ }
254
+
234
255
  @ReactProp(name = "scrollableOptions")
235
256
  override fun setScrollableOptions(view: TrueSheetView, options: ReadableMap?) {
236
257
  if (options == null) {
@@ -240,7 +261,10 @@ class TrueSheetViewManager :
240
261
 
241
262
  val scrollableOptions = ScrollableOptions(
242
263
  keyboardScrollOffset = if (options.hasKey("keyboardScrollOffset")) options.getDouble("keyboardScrollOffset").toFloat() else 0f,
243
- scrollingExpandsSheet = if (options.hasKey("scrollingExpandsSheet")) options.getBoolean("scrollingExpandsSheet") else true
264
+ keyboardOffset = if (options.hasKey("keyboardOffset")) options.getDouble("keyboardOffset").toFloat() else 0f,
265
+ scrollingExpandsSheet = if (options.hasKey("scrollingExpandsSheet")) options.getBoolean("scrollingExpandsSheet") else true,
266
+ contentInsetAdjustment = !options.hasKey("contentInsetAdjustmentBehavior") ||
267
+ options.getBoolean("contentInsetAdjustmentBehavior")
244
268
  )
245
269
  view.setScrollableOptions(scrollableOptions)
246
270
  }
@@ -57,7 +57,7 @@ class TrueSheetCoordinatorLayout(context: Context) :
57
57
  private var streamGestureClaimed = false
58
58
 
59
59
  // Whether the sheet owns vertical drags for this stream: the touch target is inside
60
- // the sheet but outside the pinned scrollable (e.g. a header overlaying it), with
60
+ // the sheet but outside the detected scrollable (e.g. a header overlaying it), with
61
61
  // nothing vertically scrollable along its chain. Such targets consume the stream
62
62
  // natively (no nested scroll), and BottomSheetBehavior won't intercept because the
63
63
  // scrollable's bounds still contain the point (touchingScrollingChild) — intercept
@@ -127,7 +127,7 @@ class TrueSheetCoordinatorLayout(context: Context) :
127
127
 
128
128
  /**
129
129
  * Resolves the stream's touch target (respecting zIndex and pointerEvents) and
130
- * decides whether the sheet may drag from it — inside the sheet, outside the pinned
130
+ * decides whether the sheet may drag from it — inside the sheet, outside the detected
131
131
  * scrollable, and nothing along the target's chain scrolls vertically.
132
132
  */
133
133
  private fun updateStreamTargetFlags(ev: MotionEvent) {
@@ -139,7 +139,7 @@ class TrueSheetCoordinatorLayout(context: Context) :
139
139
  .firstNotNullOfOrNull { it.getView() } ?: return
140
140
  if (target !== sheet && !target.isDescendantOf(sheet)) return
141
141
 
142
- // A pinned scrollable that can scroll (or hosts pull-to-refresh) owns its
142
+ // A detected scrollable that can scroll (or hosts pull-to-refresh) owns its
143
143
  // touches — nested scrolling drags the sheet. When it can't scroll (e.g.
144
144
  // the sheet is fully expanded and the content fits) it eats the gesture
145
145
  // instead, so fall through and let the sheet drag from it — an EditText
@@ -169,8 +169,12 @@ class TrueSheetDetentCalculator(private val reactContext: ThemedReactContext) {
169
169
  */
170
170
  private fun getDetentStateMap(): Map<Int, Int>? =
171
171
  when (detents.size) {
172
+ // With one detent halfExpandedRatio matches the peek height, so the
173
+ // behavior can settle HALF_EXPANDED at the same position (e.g. an upward
174
+ // drag release) — map it too, or the settle is unmappable
172
175
  1 -> mapOf(
173
176
  BottomSheetBehavior.STATE_COLLAPSED to 0,
177
+ BottomSheetBehavior.STATE_HALF_EXPANDED to 0,
174
178
  BottomSheetBehavior.STATE_EXPANDED to 0
175
179
  )
176
180
 
@@ -197,7 +201,7 @@ class TrueSheetDetentCalculator(private val reactContext: ThemedReactContext) {
197
201
  * Find which segment the position falls into for interpolation.
198
202
  * @return Triple(fromIndex, toIndex, progress) where progress is 0-1, or null if no detents
199
203
  */
200
- fun findSegmentForPosition(positionPx: Int): Triple<Int, Int, Float>? {
204
+ private fun findSegmentForPosition(positionPx: Int): Triple<Int, Int, Float>? {
201
205
  val count = detents.size
202
206
  if (count == 0) return null
203
207
 
@@ -238,37 +242,23 @@ class TrueSheetDetentCalculator(private val reactContext: ThemedReactContext) {
238
242
  }
239
243
 
240
244
  /**
241
- * Returns continuous index (e.g., 0.5 = halfway between detent 0 and 1).
245
+ * Returns interpolated continuous index (e.g., 0.5 = halfway between detent
246
+ * 0 and 1) and screen fraction for a position, sharing a single segment
247
+ * lookup — this runs on every position frame.
242
248
  */
243
- fun getInterpolatedIndexForPosition(positionPx: Int): Float {
244
- val count = detents.size
245
- if (count == 0) return -1f
246
-
247
- val segment = findSegmentForPosition(positionPx) ?: return 0f
248
- val (fromIndex, _, progress) = segment
249
-
250
- if (fromIndex == -1) return -progress
251
- return fromIndex + progress
252
- }
253
-
254
- /**
255
- * Returns interpolated screen fraction for position.
256
- */
257
- fun getInterpolatedDetentForPosition(positionPx: Int): Float {
258
- val count = detents.size
259
- if (count == 0) return 0f
249
+ fun getInterpolatedPositionInfo(positionPx: Int): Pair<Float, Float> {
250
+ if (detents.isEmpty()) return Pair(-1f, 0f)
260
251
 
261
- val segment = findSegmentForPosition(positionPx) ?: return getDetentValueForIndex(0)
262
- val (fromIndex, toIndex, progress) = segment
252
+ val (fromIndex, toIndex, progress) = findSegmentForPosition(positionPx)
253
+ ?: return Pair(0f, getDetentValueForIndex(0))
263
254
 
264
255
  if (fromIndex == -1) {
265
- val firstDetent = getDetentValueForIndex(0)
266
- return maxOf(0f, firstDetent * (1 - progress))
256
+ return Pair(-progress, maxOf(0f, getDetentValueForIndex(0) * (1 - progress)))
267
257
  }
268
258
 
269
259
  val fromDetent = getDetentValueForIndex(fromIndex)
270
260
  val toDetent = getDetentValueForIndex(toIndex)
271
- return fromDetent + progress * (toDetent - fromDetent)
261
+ return Pair(fromIndex + progress, fromDetent + progress * (toDetent - fromDetent))
272
262
  }
273
263
 
274
264
  companion object {
@@ -15,13 +15,6 @@ fun View.isDescendantOf(ancestor: View): Boolean {
15
15
  return false
16
16
  }
17
17
 
18
- fun ViewGroup.smoothScrollBy(dx: Int, dy: Int) {
19
- when (this) {
20
- is ScrollView -> smoothScrollBy(dx, dy)
21
- is NestedScrollView -> smoothScrollBy(dx, dy)
22
- }
23
- }
24
-
25
18
  fun ViewGroup.smoothScrollTo(x: Int, y: Int) {
26
19
  when (this) {
27
20
  is ScrollView -> smoothScrollTo(x, y)
@@ -1,6 +1,10 @@
1
1
  #include "TrueSheetContentViewShadowNode.h"
2
2
 
3
+ #include <limits>
4
+
3
5
  #include <react/renderer/components/view/conversions.h>
6
+ #include <react/renderer/core/LayoutConstraints.h>
7
+ #include <react/renderer/core/LayoutContext.h>
4
8
 
5
9
  namespace facebook::react {
6
10
 
@@ -8,6 +12,10 @@ using namespace yoga;
8
12
 
9
13
  extern const char TrueSheetContentViewComponentName[] = "TrueSheetContentView";
10
14
 
15
+ // measure() lays out a clone of this node, which re-enters layout() on the
16
+ // clone — guard so the measurement pass doesn't measure again recursively.
17
+ static thread_local bool gIsMeasuringNaturalHeight = false;
18
+
11
19
  void TrueSheetContentViewShadowNode::adjustLayoutWithState() {
12
20
  ensureUnsealed();
13
21
 
@@ -17,12 +25,12 @@ void TrueSheetContentViewShadowNode::adjustLayoutWithState() {
17
25
 
18
26
  auto &props = getConcreteProps();
19
27
 
20
- // When a ScrollView is pinned under an auto detent, fill the container so
28
+ // When a ScrollView is detected under an auto detent, fill the container so
21
29
  // descendant flex layouts (flex:1 wrappers, the ScrollView itself) resolve
22
30
  // 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.
31
+ // circular there since the sheet height derives from the content's natural
32
+ // height instead (see updateNaturalHeightIfNeeded). For fixed detents,
33
+ // content lays out naturally like a regular view.
26
34
  auto flexGrow = stateData.scrollableBounded
27
35
  ? FloatOptional{1.0f}
28
36
  : props.yogaStyle.flexGrow();
@@ -40,4 +48,42 @@ void TrueSheetContentViewShadowNode::adjustLayoutWithState() {
40
48
  }
41
49
  }
42
50
 
51
+ void TrueSheetContentViewShadowNode::layout(LayoutContext layoutContext) {
52
+ ConcreteViewShadowNode::layout(layoutContext);
53
+ updateNaturalHeightIfNeeded(layoutContext);
54
+ }
55
+
56
+ // The natural height is the height the content wants when unbounded — the
57
+ // source for the auto detent. Once the container bounds the content
58
+ // (scrollableBounded), the committed layout no longer reflects it, so lay out
59
+ // a clone of the subtree with an unconstrained height. Yoga respects the
60
+ // user's styles: an explicit-height ScrollView keeps its height while a
61
+ // flexible one expands to its content — no ScrollView discovery involved.
62
+ void TrueSheetContentViewShadowNode::updateNaturalHeightIfNeeded(
63
+ const LayoutContext &layoutContext) {
64
+ if (gIsMeasuringNaturalHeight) {
65
+ return;
66
+ }
67
+
68
+ auto stateData = getStateData();
69
+ auto size = getLayoutMetrics().frame.size;
70
+
71
+ Float naturalHeight = size.height;
72
+ if (stateData.scrollableBounded) {
73
+ auto constraints = LayoutConstraints{};
74
+ constraints.minimumSize = {size.width, 0};
75
+ constraints.maximumSize = {size.width, std::numeric_limits<Float>::infinity()};
76
+ constraints.layoutDirection = getLayoutMetrics().layoutDirection;
77
+
78
+ gIsMeasuringNaturalHeight = true;
79
+ naturalHeight = measure(layoutContext, constraints).height;
80
+ gIsMeasuringNaturalHeight = false;
81
+ }
82
+
83
+ if (stateData.naturalHeight != naturalHeight) {
84
+ stateData.naturalHeight = naturalHeight;
85
+ setStateData(std::move(stateData));
86
+ }
87
+ }
88
+
43
89
  } // namespace facebook::react
@@ -23,6 +23,13 @@ class JSI_EXPORT TrueSheetContentViewShadowNode final
23
23
 
24
24
  public:
25
25
  void adjustLayoutWithState();
26
+
27
+ #pragma mark - LayoutableShadowNode
28
+
29
+ void layout(LayoutContext layoutContext) override;
30
+
31
+ private:
32
+ void updateNaturalHeightIfNeeded(const LayoutContext &layoutContext);
26
33
  };
27
34
 
28
35
  } // namespace facebook::react