@lodev09/react-native-true-sheet 4.0.0-beta.25 → 4.0.0-beta.27

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 (39) hide show
  1. package/README.md +1 -0
  2. package/android/src/main/java/com/lodev09/truesheet/TrueSheetFooterView.kt +19 -1
  3. package/android/src/main/java/com/lodev09/truesheet/TrueSheetOverlayView.kt +1 -1
  4. package/android/src/main/java/com/lodev09/truesheet/TrueSheetStateUpdater.kt +11 -3
  5. package/android/src/main/java/com/lodev09/truesheet/TrueSheetView.kt +31 -10
  6. package/android/src/main/java/com/lodev09/truesheet/TrueSheetViewController.kt +23 -3
  7. package/android/src/main/java/com/lodev09/truesheet/TrueSheetViewManager.kt +5 -0
  8. package/android/src/main/java/com/lodev09/truesheet/core/RNScreensEventObserver.kt +8 -16
  9. package/android/src/main/java/com/lodev09/truesheet/core/TrueSheetBottomSheetBehavior.kt +9 -0
  10. package/android/src/main/java/com/lodev09/truesheet/core/TrueSheetBottomSheetView.kt +0 -8
  11. package/android/src/main/java/com/lodev09/truesheet/core/TrueSheetOverlayRootView.kt +8 -1
  12. package/android/src/main/java/com/lodev09/truesheet/core/TrueSheetStackManager.kt +29 -38
  13. package/android/src/main/jni/TrueSheetStateUpdater.cpp +37 -6
  14. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetContentViewShadowNode.cpp +6 -2
  15. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetFooterViewShadowNode.cpp +12 -0
  16. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetFooterViewShadowNode.h +2 -0
  17. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetFooterViewState.h +8 -2
  18. package/ios/TrueSheetContainerView.h +1 -0
  19. package/ios/TrueSheetContainerView.mm +6 -0
  20. package/ios/TrueSheetContentView.h +1 -0
  21. package/ios/TrueSheetContentView.mm +2 -0
  22. package/ios/TrueSheetFooterView.mm +32 -12
  23. package/ios/TrueSheetView.mm +9 -2
  24. package/ios/TrueSheetViewController.h +12 -0
  25. package/ios/TrueSheetViewController.mm +332 -29
  26. package/ios/core/TrueSheetDetentCalculator.h +11 -0
  27. package/ios/core/TrueSheetDetentCalculator.mm +17 -5
  28. package/ios/core/TrueSheetKeyboardObserver.mm +89 -9
  29. package/lib/module/fabric/TrueSheetViewNativeComponent.ts +6 -0
  30. package/lib/typescript/src/TrueSheet.types.d.ts +31 -0
  31. package/lib/typescript/src/TrueSheet.types.d.ts.map +1 -1
  32. package/lib/typescript/src/fabric/TrueSheetViewNativeComponent.d.ts +5 -0
  33. package/lib/typescript/src/fabric/TrueSheetViewNativeComponent.d.ts.map +1 -1
  34. package/lib/typescript/src/navigation/types.d.ts +1 -1
  35. package/lib/typescript/src/navigation/types.d.ts.map +1 -1
  36. package/package.json +1 -1
  37. package/src/TrueSheet.types.ts +34 -0
  38. package/src/fabric/TrueSheetViewNativeComponent.ts +6 -0
  39. package/src/navigation/types.ts +1 -0
package/README.md CHANGED
@@ -35,6 +35,7 @@ The true native bottom sheet experience for your React Native Apps. 💩
35
35
  * 🍞 **`TrueSheetOverlay`** - Render toasts, dialogs, and other [overlays above your sheets](https://sheet.lodev09.com/guides/overlays) — no more `FullWindowOverlay`/`Modal` workarounds
36
36
  * 🔍 **Element inspector** - React Native's dev menu inspector now works inside a presented sheet
37
37
  * 🎨 **Dim color and opacity** - [Customize the dim](https://sheet.lodev09.com/guides/dimming#dim-color-and-opacity) behind the sheet with `dimColor` and `dimOpacity`
38
+ * 📱 **Foldables** - [Rest the sheet on the hinge](https://sheet.lodev09.com/guides/foldables) of a half-folded iPhone with `foldOptions` (iOS 27.1+)
38
39
 
39
40
  ```sh
40
41
  npx expo install @lodev09/react-native-true-sheet@beta
@@ -32,10 +32,24 @@ class TrueSheetFooterView(reactContext: ThemedReactContext) : ReactViewGroup(rea
32
32
 
33
33
  private var lastWidth = 0
34
34
  private var lastHeight = 0
35
+ private var stateTranslationY = 0f
35
36
 
36
37
  var bottomInset = 0
37
38
  private set
38
39
 
40
+ /**
41
+ * positionFooter slides the footer off its Yoga position (above the
42
+ * keyboard, or riding the sheet). Mirror where it rests into the shadow
43
+ * node so measure() — and Pressability's press region — match where it's
44
+ * drawn. Called once the footer settles: every push is a commit.
45
+ */
46
+ fun syncTranslationState() {
47
+ if (translationY == stateTranslationY) return
48
+ val sw = stateWrapper ?: return
49
+ stateTranslationY = translationY
50
+ TrueSheetStateUpdater.updateFooterTranslation(sw, translationY.pxToDp())
51
+ }
52
+
39
53
  /**
40
54
  * Height the footer occupies above the keyboard — its layout height minus
41
55
  * the safe-area inset, which rides below the keyboard's top edge while the
@@ -55,14 +69,18 @@ class TrueSheetFooterView(reactContext: ThemedReactContext) : ReactViewGroup(rea
55
69
 
56
70
  val sw = stateWrapper ?: return
57
71
  val insetDp = bottomInset.toFloat().pxToDp()
72
+ // Carried along — this update replaces a still-queued translation update
73
+ stateTranslationY = translationY
74
+ val translateYDp = translationY.pxToDp()
58
75
 
59
76
  // Synchronous update — the footer must be padded before detents are
60
77
  // configured, otherwise the auto detent is set up an inset short
61
- if (TrueSheetStateUpdater.updateFooterState(sw, insetDp)) return
78
+ if (TrueSheetStateUpdater.updateFooterState(sw, insetDp, translateYDp)) return
62
79
 
63
80
  // Fallback: async state update
64
81
  val newState = WritableNativeMap()
65
82
  newState.putDouble("bottomInset", insetDp.toDouble())
83
+ newState.putDouble("translateY", translateYDp.toDouble())
66
84
  sw.updateState(newState)
67
85
  }
68
86
 
@@ -67,7 +67,7 @@ class TrueSheetOverlayView(private val reactContext: ThemedReactContext) :
67
67
 
68
68
  override fun onDetachedFromWindow() {
69
69
  viewTreeObserver.removeOnWindowAttachListener(windowAttachListener)
70
- detachRootView()
70
+ rootContainerView?.post { if (!isAttachedToWindow) detachRootView() }
71
71
  super.onDetachedFromWindow()
72
72
  }
73
73
 
@@ -41,8 +41,13 @@ object TrueSheetStateUpdater {
41
41
  }
42
42
 
43
43
  /** Returns false when unavailable — caller should fall back to async updateState. */
44
- fun updateFooterState(stateWrapper: StateWrapper, bottomInsetDp: Float): Boolean =
45
- isAvailable && nativeUpdateFooterState(stateWrapper, bottomInsetDp)
44
+ fun updateFooterState(stateWrapper: StateWrapper, bottomInsetDp: Float, translateYDp: Float): Boolean =
45
+ isAvailable && nativeUpdateFooterState(stateWrapper, bottomInsetDp, translateYDp)
46
+
47
+ /** Mirrors the footer's native translation so measure() matches where it's drawn. */
48
+ fun updateFooterTranslation(stateWrapper: StateWrapper, translateYDp: Float) {
49
+ if (isAvailable) nativeUpdateFooterTranslation(stateWrapper, translateYDp)
50
+ }
46
51
 
47
52
  /**
48
53
  * Publishes the precalculated bottom safe-area inset so a late-mounted
@@ -56,7 +61,10 @@ object TrueSheetStateUpdater {
56
61
  private external fun nativeUpdateState(stateWrapper: Any, width: Float, height: Float): Boolean
57
62
 
58
63
  @JvmStatic
59
- private external fun nativeUpdateFooterState(stateWrapper: Any, bottomInset: Float): Boolean
64
+ private external fun nativeUpdateFooterState(stateWrapper: Any, bottomInset: Float, translateY: Float): Boolean
65
+
66
+ @JvmStatic
67
+ private external fun nativeUpdateFooterTranslation(stateWrapper: Any, translateY: Float)
60
68
 
61
69
  @JvmStatic
62
70
  private external fun nativeSetBottomSafeArea(insetDp: Float)
@@ -72,6 +72,9 @@ class TrueSheetView(private val reactContext: ThemedReactContext) :
72
72
  // Debounce flag to coalesce rapid layout changes into a single sheet update
73
73
  private var isSheetUpdatePending: Boolean = false
74
74
 
75
+ // didFocus waiting for the slide-back after a child dismissed
76
+ private var isDidFocusPending: Boolean = false
77
+
75
78
  // Root container for the coordinator layout (activity or Modal dialog content view)
76
79
  internal var rootContainerView: ViewGroup? = null
77
80
 
@@ -610,7 +613,7 @@ class TrueSheetView(private val reactContext: ThemedReactContext) :
610
613
 
611
614
  // Propagate any additional translation up the stack
612
615
  if (additionalTranslation > 0) {
613
- TrueSheetStackManager.getParentSheet(this)?.addTranslation(additionalTranslation)
616
+ viewController.parentSheetView?.addTranslation(additionalTranslation)
614
617
  }
615
618
  }
616
619
 
@@ -642,7 +645,7 @@ class TrueSheetView(private val reactContext: ThemedReactContext) :
642
645
 
643
646
  val additionalTranslation = newTranslation - currentTranslation
644
647
  if (additionalTranslation > 0) {
645
- TrueSheetStackManager.getParentSheet(this)?.addTranslation(additionalTranslation, animated = false)
648
+ viewController.parentSheetView?.addTranslation(additionalTranslation, animated = false)
646
649
  }
647
650
  }
648
651
 
@@ -653,7 +656,7 @@ class TrueSheetView(private val reactContext: ThemedReactContext) :
653
656
  if (viewController.isExpanded) return
654
657
 
655
658
  viewController.translateSheet(viewController.currentTranslationY + amount, animated)
656
- TrueSheetStackManager.getParentSheet(this)?.addTranslation(amount, animated)
659
+ viewController.parentSheetView?.addTranslation(amount, animated)
657
660
  }
658
661
 
659
662
  /**
@@ -666,7 +669,25 @@ class TrueSheetView(private val reactContext: ThemedReactContext) :
666
669
 
667
670
  // Parent should recalculate its translation based on this sheet's position
668
671
  val mySheetTop = viewController.detentCalculator.getSheetTopForDetentIndex(viewController.currentDetentIndex)
669
- TrueSheetStackManager.getParentSheet(this)?.updateTranslationForChild(mySheetTop)
672
+ viewController.parentSheetView?.updateTranslationForChild(mySheetTop)
673
+ }
674
+
675
+ /**
676
+ * Resets translation after a child dismissed and emits didFocus once the sheet settles.
677
+ * A sheet presented on top before then cancels the slide-back, so the next blur flushes it.
678
+ */
679
+ private fun focusAfterResetTranslation() {
680
+ isDidFocusPending = true
681
+ resetTranslation { emitPendingDidFocus() }
682
+ }
683
+
684
+ private fun emitPendingDidFocus() {
685
+ if (!isDidFocusPending) return
686
+ isDidFocusPending = false
687
+
688
+ if (viewController.isPresented && !viewController.isBeingDismissed) {
689
+ viewControllerDidFocus()
690
+ }
670
691
  }
671
692
 
672
693
  // ==================== TrueSheetViewControllerDelegate ====================
@@ -701,6 +722,9 @@ class TrueSheetView(private val reactContext: ThemedReactContext) :
701
722
  }
702
723
 
703
724
  override fun viewControllerDidDismiss(parent: TrueSheetView?) {
725
+ // A slide-back cut short by this sheet's own dismissal never emits; don't carry it over
726
+ isDidFocusPending = false
727
+
704
728
  // Detach coordinator from the root container view
705
729
  viewController.coordinatorLayout?.let { rootContainerView?.removeView(it) }
706
730
  rootContainerView = null
@@ -715,12 +739,7 @@ class TrueSheetView(private val reactContext: ThemedReactContext) :
715
739
  // The sheets below stop blending this sheet's dim
716
740
  parent?.viewController?.takeIf { it.isPresented }?.updateDimAmount()
717
741
 
718
- parent?.resetTranslation {
719
- val parentController = parent.viewController
720
- if (parentController.isPresented && !parentController.isBeingDismissed) {
721
- parent.viewControllerDidFocus()
722
- }
723
- }
742
+ parent?.focusAfterResetTranslation()
724
743
  }
725
744
 
726
745
  override fun viewControllerDidChangeDetent(index: Int, position: Float, detent: Float) {
@@ -763,6 +782,8 @@ class TrueSheetView(private val reactContext: ThemedReactContext) :
763
782
  }
764
783
 
765
784
  override fun viewControllerWillBlur() {
785
+ emitPendingDidFocus()
786
+
766
787
  val surfaceId = UIManagerHelper.getSurfaceId(this)
767
788
  eventDispatcher?.dispatchEvent(WillBlurEvent(surfaceId, id))
768
789
  }
@@ -374,6 +374,13 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
374
374
  private val isKeyboardTransitioning: Boolean
375
375
  get() = keyboardObserver?.isTransitioning ?: false
376
376
 
377
+ // The footer slides every frame until the sheet and keyboard come to rest
378
+ private val isSheetMoving: Boolean
379
+ get() = isPresentAnimating ||
380
+ isKeyboardTransitioning ||
381
+ behavior?.state == BottomSheetBehavior.STATE_DRAGGING ||
382
+ behavior?.state == BottomSheetBehavior.STATE_SETTLING
383
+
377
384
  // Keyboard is up or animating — detents are (or are about to be) keyboard-shifted
378
385
  val isKeyboardActive: Boolean
379
386
  get() = keyboardInset > 0 || isKeyboardTransitioning
@@ -724,6 +731,9 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
724
731
  }
725
732
 
726
733
  private fun handleStateSettled(sheetView: View, newState: Int) {
734
+ // The settle's last slide frame has positioned the footer
735
+ containerView?.footerView?.syncTranslationState()
736
+
727
737
  if (interactionState is InteractionState.Reconfiguring) return
728
738
 
729
739
  // A release already at a stop settles without a slide frame
@@ -886,6 +896,8 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
886
896
  setupSheetDetents()
887
897
  setupDimmedBackground()
888
898
  setupKeyboardObserver()
899
+ // Before the slide, so an input focused in onWillPresent insets the scrollable
900
+ containerView?.setupKeyboardHandler()
889
901
 
890
902
  displayedBackgroundIndex = detentIndex
891
903
  setupBackground(animated = false)
@@ -1008,11 +1020,9 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
1008
1020
  // Restore isHideable to actual value after present animation
1009
1021
  behavior?.isHideable = dismissible
1010
1022
 
1011
- containerView?.setupKeyboardHandler()
1012
-
1013
1023
  val (index, position, detent) = getDetentInfoWithValue(currentDetentIndex)
1014
- delegate?.viewControllerDidPresent(index, position, detent)
1015
1024
  parentSheetView?.viewControllerDidBlur()
1025
+ delegate?.viewControllerDidPresent(index, position, detent)
1016
1026
  delegate?.viewControllerDidFocus()
1017
1027
  sheetView?.updateGrabberAccessibilityValue(index, detents.size)
1018
1028
 
@@ -1288,6 +1298,7 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
1288
1298
  // absolute footer floats above it
1289
1299
  if (!absoluteFooter) {
1290
1300
  footerView.translationY = 0f
1301
+ footerView.syncTranslationState()
1291
1302
  return
1292
1303
  }
1293
1304
 
@@ -1313,6 +1324,9 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
1313
1324
  // Clamp to prevent footer going above safe area
1314
1325
  val maxAllowedY = (sheetHeight - topInset - footerHeight).toFloat()
1315
1326
  footerView.y = minOf(footerY, maxAllowedY)
1327
+ // Mid-motion slides are synced once the sheet settles or the keyboard
1328
+ // lands (handleStateSettled, keyboardDidShow/keyboardDidHide)
1329
+ if (!isSheetMoving) footerView.syncTranslationState()
1316
1330
  if (!footerAvoidsKeyboard) {
1317
1331
  containerView?.contentView?.updateContentInset()
1318
1332
  }
@@ -1403,7 +1417,13 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
1403
1417
  )
1404
1418
  }
1405
1419
 
1420
+ override fun keyboardDidShow(height: Int) {
1421
+ // The keyboard's last frame has positioned the footer
1422
+ containerView?.footerView?.syncTranslationState()
1423
+ }
1424
+
1406
1425
  override fun keyboardDidHide() {
1426
+ containerView?.footerView?.syncTranslationState()
1407
1427
  if (!shouldHandleKeyboard(checkFocus = false)) return
1408
1428
  isKeyboardDismissProgrammatic = false
1409
1429
  // Keep detentIndexBeforeKeyboard for the settle's detent-change check
@@ -253,6 +253,11 @@ class TrueSheetViewManager :
253
253
  // iOS-specific prop - no-op on Android
254
254
  }
255
255
 
256
+ @ReactProp(name = "foldOptions")
257
+ override fun setFoldOptions(view: TrueSheetView, options: ReadableMap?) {
258
+ // iOS-specific prop - no-op on Android
259
+ }
260
+
256
261
  @ReactProp(name = "insetAdjustment")
257
262
  override fun setInsetAdjustment(view: TrueSheetView, insetAdjustment: String?) {
258
263
  view.setInsetAdjustment(insetAdjustment ?: "automatic")
@@ -44,22 +44,9 @@ class RNScreensEventObserver : EventDispatcherListener {
44
44
  }
45
45
 
46
46
  fun capturePresenterScreenFromView(view: View?) {
47
- presenterScreenTag = 0
48
- val ancestors = mutableSetOf<Int>()
49
-
50
- var current: View? = view
51
- while (current != null) {
52
- if (isScreenView(current)) {
53
- if (presenterScreenTag == 0) {
54
- presenterScreenTag = current.id
55
- } else {
56
- ancestors.add(current.id)
57
- }
58
- }
59
- current = (current.parent as? View)
60
- }
61
-
62
- ancestorScreenTags = ancestors
47
+ val tags = screenTags(view).toList()
48
+ presenterScreenTag = tags.firstOrNull() ?: 0
49
+ ancestorScreenTags = tags.drop(1).toSet()
63
50
  }
64
51
 
65
52
  override fun onEventDispatch(event: Event<*>) {
@@ -84,5 +71,10 @@ class RNScreensEventObserver : EventDispatcherListener {
84
71
 
85
72
  companion object {
86
73
  private fun isScreenView(view: View): Boolean = view.javaClass.name == RN_SCREENS_VIEW_CLASS
74
+
75
+ // Tags of the screens enclosing the view, innermost first
76
+ private fun screenTags(view: View?): Sequence<Int> = generateSequence(view) { it.parent as? View }.filter(::isScreenView).map { it.id }
77
+
78
+ fun findPresenterScreenTag(view: View?): Int = screenTags(view).firstOrNull() ?: 0
87
79
  }
88
80
  }
@@ -40,6 +40,15 @@ class TrueSheetBottomSheetBehavior<V : View> : BottomSheetBehavior<V>() {
40
40
  hideFriction = HIDE_FRICTION
41
41
  }
42
42
 
43
+ // Material's first layout registers an IME callback that rides the sheet's translationY
44
+ // with the keyboard. TrueSheet positions sheets for the keyboard itself, and the callback
45
+ // overwrites a parent's stack translation when the keyboard rises mid-translation.
46
+ override fun onLayoutChild(parent: CoordinatorLayout, child: V, layoutDirection: Int): Boolean {
47
+ val handled = super.onLayoutChild(parent, child, layoutDirection)
48
+ ViewCompat.setWindowInsetsAnimationCallback(child, null)
49
+ return handled
50
+ }
51
+
43
52
  // With the short threshold, settle at or below the lowest detent like
44
53
  // Compose Material3's sheet instead of Material's half-height rule.
45
54
  private fun releaseState(child: V, dragged: Boolean, velocityY: Float): Int? {
@@ -81,14 +81,6 @@ class TrueSheetBottomSheetView(private val reactContext: ThemedReactContext) : F
81
81
  clipToPadding = false
82
82
  }
83
83
 
84
- override fun setTranslationY(translationY: Float) {
85
- // This prevents keyboard inset animations from resetting parent sheet translation
86
- if (translationY == 0f && this.translationY != 0f) {
87
- return
88
- }
89
- super.setTranslationY(translationY)
90
- }
91
-
92
84
  // =============================================================================
93
85
  // MARK: - Layout
94
86
  // =============================================================================
@@ -48,7 +48,14 @@ class TrueSheetOverlayRootView(private val reactContext: ThemedReactContext) :
48
48
  translationZ = OVERLAY_Z
49
49
  }
50
50
 
51
- // Children are positioned by Fabric — a FrameLayout pass would stretch them to match_parent
51
+ // Children are measured and positioned by Fabric — a FrameLayout pass would stretch them to match_parent
52
+ override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
53
+ setMeasuredDimension(
54
+ MeasureSpec.getSize(widthMeasureSpec),
55
+ MeasureSpec.getSize(heightMeasureSpec)
56
+ )
57
+ }
58
+
52
59
  override fun onLayout(
53
60
  changed: Boolean,
54
61
  left: Int,
@@ -12,16 +12,6 @@ object TrueSheetStackManager {
12
12
 
13
13
  private val presentedSheetStack = mutableListOf<TrueSheetView>()
14
14
 
15
- /**
16
- * Gets the parent sheet at the given index, if any.
17
- * Only returns a parent if it's in the same container.
18
- * Must be called within synchronized block.
19
- */
20
- private fun getParentSheetAt(index: Int, rootContainer: ViewGroup?): TrueSheetView? {
21
- if (index <= 0) return null
22
- return presentedSheetStack[index - 1].takeIf { it.rootContainerView == rootContainer }
23
- }
24
-
25
15
  /**
26
16
  * Returns the topmost presented and visible sheet, skipping sheets that are dismissing.
27
17
  * A dismissing sheet can't parent a new one: its dim would suppress the child's own dim
@@ -33,6 +23,22 @@ object TrueSheetStackManager {
33
23
  it.viewController.run { isPresented && isSheetVisible && !isBeingDismissed }
34
24
  }
35
25
 
26
+ /**
27
+ * Returns the topmost sheet hidden by screen navigation that was presented from the same
28
+ * screen as the given sheet. Sheets presented on the covering screen must not parent to it,
29
+ * or the hidden sheet would receive focus and blur events while it can't be seen.
30
+ * Must be called within synchronized block.
31
+ */
32
+ private fun findScreenHiddenSheet(sheetView: TrueSheetView): TrueSheetView? {
33
+ val screenTag = RNScreensEventObserver.findPresenterScreenTag(sheetView)
34
+ if (screenTag == 0) return null
35
+
36
+ return presentedSheetStack.lastOrNull {
37
+ it.viewController.run { isPresented && wasHiddenByScreen && !isBeingDismissed } &&
38
+ it.screensEventObserver?.presenterScreenTag == screenTag
39
+ }
40
+ }
41
+
36
42
  /**
37
43
  * Registers a sheet in the stack and returns its parent sheet if any.
38
44
  * Only returns a parent if it's in the same container (e.g., same Screen).
@@ -40,7 +46,8 @@ object TrueSheetStackManager {
40
46
  @JvmStatic
41
47
  fun registerSheet(sheetView: TrueSheetView): TrueSheetView? {
42
48
  synchronized(presentedSheetStack) {
43
- val parentSheet = findTopmostSheet()?.takeIf { it.rootContainerView == sheetView.rootContainerView }
49
+ val parentSheet = (findTopmostSheet() ?: findScreenHiddenSheet(sheetView))
50
+ ?.takeIf { it.rootContainerView == sheetView.rootContainerView }
44
51
 
45
52
  if (!presentedSheetStack.contains(sheetView)) {
46
53
  presentedSheetStack.add(sheetView)
@@ -62,24 +69,20 @@ object TrueSheetStackManager {
62
69
 
63
70
  /**
64
71
  * Updates parent sheet translation based on the child sheet's position.
65
- * Only affects parent sheets in the same container.
66
72
  */
67
73
  @JvmStatic
68
74
  fun updateParentTranslation(sheetView: TrueSheetView) {
69
- synchronized(presentedSheetStack) {
70
- val index = presentedSheetStack.indexOf(sheetView)
71
- val parentSheet = getParentSheetAt(index, sheetView.rootContainerView) ?: return
72
-
73
- // Post to ensure layout is complete before reading position
74
- sheetView.viewController.post {
75
- val childMinSheetTop = sheetView.viewController.detentCalculator.getSheetTopForDetentIndex(0)
76
- val childCurrentSheetTop = sheetView.viewController.detentCalculator.getSheetTopForDetentIndex(
77
- sheetView.viewController.currentDetentIndex
78
- )
79
- // Cap to minimum detent position
80
- val childSheetTop = maxOf(childMinSheetTop, childCurrentSheetTop)
81
- parentSheet.updateTranslationForChild(childSheetTop)
82
- }
75
+ val parentSheet = sheetView.viewController.parentSheetView ?: return
76
+
77
+ // Post to ensure layout is complete before reading position
78
+ sheetView.viewController.post {
79
+ val childMinSheetTop = sheetView.viewController.detentCalculator.getSheetTopForDetentIndex(0)
80
+ val childCurrentSheetTop = sheetView.viewController.detentCalculator.getSheetTopForDetentIndex(
81
+ sheetView.viewController.currentDetentIndex
82
+ )
83
+ // Cap to minimum detent position
84
+ val childSheetTop = maxOf(childMinSheetTop, childCurrentSheetTop)
85
+ parentSheet.updateTranslationForChild(childSheetTop)
83
86
  }
84
87
  }
85
88
 
@@ -113,18 +116,6 @@ object TrueSheetStackManager {
113
116
  }
114
117
  }
115
118
 
116
- /**
117
- * Gets the parent sheet of the given sheet, if any.
118
- * Only returns a parent if it's in the same container.
119
- */
120
- @JvmStatic
121
- fun getParentSheet(sheetView: TrueSheetView): TrueSheetView? {
122
- synchronized(presentedSheetStack) {
123
- val index = presentedSheetStack.indexOf(sheetView)
124
- return getParentSheetAt(index, sheetView.rootContainerView)
125
- }
126
- }
127
-
128
119
  /**
129
120
  * Returns true if the given sheet is the topmost presented sheet in its container.
130
121
  */
@@ -58,7 +58,8 @@ static jboolean updateStateImmediate(
58
58
  static jboolean updateFooterStateImmediate(
59
59
  jni::alias_ref<jclass> /*clazz*/,
60
60
  jni::alias_ref<jobject> stateWrapper,
61
- jfloat bottomInset) {
61
+ jfloat bottomInset,
62
+ jfloat translateY) {
62
63
  auto state = getStateFromWrapper(stateWrapper);
63
64
  if (!state) {
64
65
  return JNI_FALSE;
@@ -67,15 +68,44 @@ static jboolean updateFooterStateImmediate(
67
68
  auto concreteState =
68
69
  std::static_pointer_cast<const ConcreteState<TrueSheetFooterViewState>>(state);
69
70
 
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);
71
+ // Carries the translation too — RN's EventQueue drops a still-queued update
72
+ // for the same view (see updateFooterTranslation) when this one is enqueued
73
+ concreteState->updateState(
74
+ [bottomInset, translateY](const TrueSheetFooterViewState &oldData) -> StateData::Shared {
75
+ auto newData = oldData;
76
+ newData.bottomInset = bottomInset;
77
+ newData.translateY = translateY;
78
+ newData.initialized = true;
79
+ return std::make_shared<const TrueSheetFooterViewState>(newData);
80
+ },
81
+ EventQueue::UpdateMode::unstable_Immediate);
75
82
 
76
83
  return JNI_TRUE;
77
84
  }
78
85
 
86
+ // Mirrors the footer's resting translation into its state so measure()
87
+ // matches where it's drawn. Async — it only feeds measure().
88
+ static void updateFooterTranslation(
89
+ jni::alias_ref<jclass> /*clazz*/,
90
+ jni::alias_ref<jobject> stateWrapper,
91
+ jfloat translateY) {
92
+ auto state = getStateFromWrapper(stateWrapper);
93
+ if (!state) {
94
+ return;
95
+ }
96
+
97
+ auto concreteState =
98
+ std::static_pointer_cast<const ConcreteState<TrueSheetFooterViewState>>(state);
99
+
100
+ concreteState->updateState(
101
+ [translateY](const TrueSheetFooterViewState &oldData) -> StateData::Shared {
102
+ auto newData = oldData;
103
+ newData.translateY = translateY;
104
+ return std::make_shared<const TrueSheetFooterViewState>(newData);
105
+ },
106
+ EventQueue::UpdateMode::Asynchronous);
107
+ }
108
+
79
109
  // Publishes the precalculated bottom safe-area inset so a late-mounted
80
110
  // footer's first layout is already padded (see TrueSheetFooterViewShadowNode).
81
111
  static void setBottomSafeArea(jni::alias_ref<jclass> /*clazz*/, jfloat insetDp) {
@@ -90,6 +120,7 @@ JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM *vm, void * /*reserved*/) {
90
120
  ->registerNatives({
91
121
  makeNativeMethod("nativeUpdateState", facebook::react::updateStateImmediate),
92
122
  makeNativeMethod("nativeUpdateFooterState", facebook::react::updateFooterStateImmediate),
123
+ makeNativeMethod("nativeUpdateFooterTranslation", facebook::react::updateFooterTranslation),
93
124
  makeNativeMethod("nativeSetBottomSafeArea", facebook::react::setBottomSafeArea),
94
125
  });
95
126
  });
@@ -1,6 +1,5 @@
1
1
  #include "TrueSheetContentViewShadowNode.h"
2
2
 
3
- #include <folly/ScopeGuard.h>
4
3
  #include <react/renderer/components/view/conversions.h>
5
4
  #include <react/renderer/core/ComponentDescriptor.h>
6
5
  #include <react/renderer/core/LayoutConstraints.h>
@@ -95,7 +94,12 @@ void TrueSheetContentViewShadowNode::updateNaturalHeightIfNeeded(const LayoutCon
95
94
  if (contentChanged_ || !measurement_ || !(measurement_->constraints == constraints) ||
96
95
  !(measurement_->context == measurementContext) || measurement_->errata != errata) {
97
96
  gIsMeasuringNaturalHeight = true;
98
- auto restoreMeasurement = folly::makeGuard([] { gIsMeasuringNaturalHeight = false; });
97
+ // Not folly::makeGuard: its terminate() isn't linked into RN < 0.86 on Android.
98
+ struct RestoreMeasurement {
99
+ ~RestoreMeasurement() {
100
+ gIsMeasuringNaturalHeight = false;
101
+ }
102
+ } restoreMeasurement;
99
103
  auto measurementNode =
100
104
  measurement_ ? cloneForMeasurement(*this, *measurementSourceChildren_, measurement_->root->getChildren())
101
105
  : cloneForMeasurement(*this, {}, {});
@@ -59,4 +59,16 @@ void TrueSheetFooterViewShadowNode::adjustLayoutWithState() {
59
59
  }
60
60
  }
61
61
 
62
+ // Native moves the footer off its Yoga position without a layout pass. Folding
63
+ // that slide into the transform keeps measure() — and Pressability's press
64
+ // region — on the drawn footer.
65
+ Transform TrueSheetFooterViewShadowNode::getTransform() const {
66
+ auto transform = ConcreteViewShadowNode::getTransform();
67
+ auto translateY = getStateData().translateY;
68
+ if (translateY == 0) {
69
+ return transform;
70
+ }
71
+ return transform * Transform::Translate(0, translateY, 0);
72
+ }
73
+
62
74
  } // namespace facebook::react
@@ -20,6 +20,8 @@ class JSI_EXPORT TrueSheetFooterViewShadowNode final
20
20
 
21
21
  public:
22
22
  void adjustLayoutWithState();
23
+
24
+ Transform getTransform() const override;
23
25
  };
24
26
 
25
27
  } // namespace facebook::react
@@ -11,7 +11,8 @@ namespace facebook::react {
11
11
  /*
12
12
  * State for <TrueSheetFooterView> component.
13
13
  * Set from native with the sheet's bottom safe-area inset so the shadow node
14
- * pads the footer's bottom edge (see TrueSheetFooterViewShadowNode).
14
+ * pads the footer's bottom edge, and with the footer's native translation so
15
+ * measure() matches where it's drawn (see TrueSheetFooterViewShadowNode).
15
16
  */
16
17
  class TrueSheetFooterViewState final {
17
18
  public:
@@ -19,11 +20,16 @@ class TrueSheetFooterViewState final {
19
20
 
20
21
  #ifdef ANDROID
21
22
  TrueSheetFooterViewState(TrueSheetFooterViewState const &previousState, folly::dynamic data)
22
- : bottomInset(static_cast<float>(data["bottomInset"].getDouble())), initialized(true) {
23
+ : bottomInset(static_cast<float>(data["bottomInset"].getDouble())),
24
+ translateY(static_cast<float>(data["translateY"].getDouble())),
25
+ initialized(true) {
23
26
  }
24
27
  #endif
25
28
 
26
29
  float bottomInset{0};
30
+ // Native slide off the Yoga position — above the keyboard, or riding the
31
+ // sheet below its lowest detent (Android)
32
+ float translateY{0};
27
33
  // True once native has pushed a real inset — until then the shadow node
28
34
  // seeds the layout from TrueSheetInsets (see TrueSheetFooterViewShadowNode)
29
35
  bool initialized{false};
@@ -37,6 +37,7 @@ NS_ASSUME_NONNULL_BEGIN
37
37
 
38
38
  - (void)containerViewContentDidChangeSize:(CGSize)newSize;
39
39
  - (void)containerViewScrollViewDidChange;
40
+ - (void)containerViewDidMountTransaction;
40
41
 
41
42
  @optional
42
43
 
@@ -287,6 +287,12 @@ using namespace facebook::react;
287
287
  [self.delegate containerViewScrollViewDidChange];
288
288
  }
289
289
 
290
+ // Footer controls can mount, unmount, or hide deep in its subtree without
291
+ // passing through its own mount hooks or changing its size.
292
+ - (void)contentViewDidMountTransaction {
293
+ [self.delegate containerViewDidMountTransaction];
294
+ }
295
+
290
296
  #pragma mark - TrueSheetHeaderViewDelegate
291
297
 
292
298
  - (void)headerViewDidChangeSize:(CGSize)newSize {
@@ -26,6 +26,7 @@ NS_ASSUME_NONNULL_BEGIN
26
26
 
27
27
  - (void)contentViewDidChangeSize:(CGSize)newSize;
28
28
  - (void)contentViewScrollViewDidChange;
29
+ - (void)contentViewDidMountTransaction;
29
30
 
30
31
  @end
31
32
 
@@ -181,6 +181,8 @@ using namespace facebook::react;
181
181
  // Layout has settled for the whole tree — the footer overlap (sheet height,
182
182
  // scroll view frame) and the keyboard inset are current here
183
183
  [self updateBottomInset];
184
+
185
+ [self.delegate contentViewDidMountTransaction];
184
186
  }
185
187
 
186
188
  #pragma mark - Scrollable