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

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 (118) hide show
  1. package/android/src/main/java/com/lodev09/truesheet/TrueSheetFooterView.kt +8 -2
  2. package/android/src/main/java/com/lodev09/truesheet/TrueSheetView.kt +23 -6
  3. package/android/src/main/java/com/lodev09/truesheet/TrueSheetViewController.kt +9 -2
  4. package/android/src/main/java/com/lodev09/truesheet/TrueSheetViewManager.kt +1 -0
  5. package/android/src/main/java/com/lodev09/truesheet/utils/TouchEventDeduper.kt +31 -0
  6. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetFooterViewShadowNode.cpp +7 -0
  7. package/ios/TrueSheetContainerView.h +5 -0
  8. package/ios/TrueSheetContainerView.mm +4 -0
  9. package/ios/TrueSheetFooterView.h +6 -0
  10. package/ios/TrueSheetFooterView.mm +31 -2
  11. package/ios/TrueSheetView.mm +82 -17
  12. package/ios/TrueSheetViewController.h +9 -0
  13. package/ios/TrueSheetViewController.mm +109 -29
  14. package/lib/module/TrueSheet.js +53 -9
  15. package/lib/module/TrueSheet.js.map +1 -1
  16. package/lib/module/TrueSheet.web.js +11 -9
  17. package/lib/module/TrueSheet.web.js.map +1 -1
  18. package/lib/module/TrueSheetPeek.web.js +3 -3
  19. package/lib/module/TrueSheetPeek.web.js.map +1 -1
  20. package/lib/module/mocks/navigation.js +31 -21
  21. package/lib/module/mocks/navigation.js.map +1 -1
  22. package/lib/module/mocks/navigationExpoRouter.js +21 -0
  23. package/lib/module/mocks/navigationExpoRouter.js.map +1 -0
  24. package/lib/module/mocks/reanimated.js +1 -1
  25. package/lib/module/mocks/reanimated.js.map +1 -1
  26. package/lib/module/navigation/{TrueSheetView.js → TrueSheetNavigatorContent.js} +14 -12
  27. package/lib/module/navigation/TrueSheetNavigatorContent.js.map +1 -0
  28. package/lib/module/navigation/actions.js +50 -0
  29. package/lib/module/navigation/actions.js.map +1 -0
  30. package/lib/module/navigation/{TrueSheetRouter.js → createTrueSheetRouter.js} +20 -24
  31. package/lib/module/navigation/createTrueSheetRouter.js.map +1 -0
  32. package/lib/module/navigation/expo-router/index.js +48 -0
  33. package/lib/module/navigation/expo-router/index.js.map +1 -0
  34. package/lib/module/navigation/index.js +43 -2
  35. package/lib/module/navigation/index.js.map +1 -1
  36. package/lib/module/navigation/navigator.js +10 -0
  37. package/lib/module/navigation/navigator.js.map +1 -0
  38. package/lib/module/navigation/screen/ReanimatedTrueSheetScreen.js +2 -2
  39. package/lib/module/navigation/screen/ReanimatedTrueSheetScreen.js.map +1 -1
  40. package/lib/module/navigation/screen/TrueSheetScreen.js +2 -2
  41. package/lib/module/navigation/screen/TrueSheetScreen.js.map +1 -1
  42. package/lib/module/navigation/screen/useSheetScreenState.js +4 -4
  43. package/lib/module/navigation/screen/useSheetScreenState.js.map +1 -1
  44. package/lib/module/navigation/useTrueSheetNavigation.js +1 -1
  45. package/lib/module/navigation/useTrueSheetNavigation.js.map +1 -1
  46. package/lib/module/web/dom.js +10 -0
  47. package/lib/module/web/dom.js.map +1 -0
  48. package/lib/module/web/vaul/helpers.js +10 -4
  49. package/lib/module/web/vaul/helpers.js.map +1 -1
  50. package/lib/typescript/src/TrueSheet.d.ts +2 -0
  51. package/lib/typescript/src/TrueSheet.d.ts.map +1 -1
  52. package/lib/typescript/src/TrueSheet.web.d.ts.map +1 -1
  53. package/lib/typescript/src/TrueSheetPeek.web.d.ts.map +1 -1
  54. package/lib/typescript/src/mocks/navigation.d.ts +5 -1
  55. package/lib/typescript/src/mocks/navigation.d.ts.map +1 -1
  56. package/lib/typescript/src/mocks/navigationExpoRouter.d.ts +22 -0
  57. package/lib/typescript/src/mocks/navigationExpoRouter.d.ts.map +1 -0
  58. package/lib/typescript/src/mocks/reanimated.d.ts.map +1 -1
  59. package/lib/typescript/src/navigation/TrueSheetNavigatorContent.d.ts +5 -0
  60. package/lib/typescript/src/navigation/TrueSheetNavigatorContent.d.ts.map +1 -0
  61. package/lib/typescript/src/navigation/actions.d.ts +71 -0
  62. package/lib/typescript/src/navigation/actions.d.ts.map +1 -0
  63. package/lib/typescript/src/navigation/createTrueSheetRouter.d.ts +14 -0
  64. package/lib/typescript/src/navigation/createTrueSheetRouter.d.ts.map +1 -0
  65. package/lib/typescript/src/navigation/expo-router/index.d.ts +37 -0
  66. package/lib/typescript/src/navigation/expo-router/index.d.ts.map +1 -0
  67. package/lib/typescript/src/navigation/index.d.ts +42 -2
  68. package/lib/typescript/src/navigation/index.d.ts.map +1 -1
  69. package/lib/typescript/src/navigation/navigator.d.ts +7 -0
  70. package/lib/typescript/src/navigation/navigator.d.ts.map +1 -0
  71. package/lib/typescript/src/navigation/screen/ReanimatedTrueSheetScreen.d.ts +1 -1
  72. package/lib/typescript/src/navigation/screen/ReanimatedTrueSheetScreen.d.ts.map +1 -1
  73. package/lib/typescript/src/navigation/screen/TrueSheetScreen.d.ts +1 -1
  74. package/lib/typescript/src/navigation/screen/TrueSheetScreen.d.ts.map +1 -1
  75. package/lib/typescript/src/navigation/screen/types.d.ts +3 -4
  76. package/lib/typescript/src/navigation/screen/types.d.ts.map +1 -1
  77. package/lib/typescript/src/navigation/screen/useSheetScreenState.d.ts +3 -5
  78. package/lib/typescript/src/navigation/screen/useSheetScreenState.d.ts.map +1 -1
  79. package/lib/typescript/src/navigation/types.d.ts +24 -0
  80. package/lib/typescript/src/navigation/types.d.ts.map +1 -1
  81. package/lib/typescript/src/navigation/useTrueSheetNavigation.d.ts +1 -1
  82. package/lib/typescript/src/navigation/useTrueSheetNavigation.d.ts.map +1 -1
  83. package/lib/typescript/src/web/dom.d.ts +7 -0
  84. package/lib/typescript/src/web/dom.d.ts.map +1 -0
  85. package/lib/typescript/src/web/vaul/helpers.d.ts.map +1 -1
  86. package/package.json +23 -8
  87. package/src/TrueSheet.tsx +55 -9
  88. package/src/TrueSheet.web.tsx +14 -18
  89. package/src/TrueSheetPeek.web.tsx +4 -3
  90. package/src/mocks/navigation.ts +32 -25
  91. package/src/mocks/navigationExpoRouter.ts +40 -0
  92. package/src/mocks/reanimated.ts +8 -9
  93. package/src/navigation/{TrueSheetView.tsx → TrueSheetNavigatorContent.tsx} +24 -17
  94. package/src/navigation/actions.ts +78 -0
  95. package/src/navigation/createTrueSheetRouter.ts +222 -0
  96. package/src/navigation/expo-router/index.ts +89 -0
  97. package/src/navigation/index.ts +69 -2
  98. package/src/navigation/navigator.ts +18 -0
  99. package/src/navigation/screen/ReanimatedTrueSheetScreen.tsx +2 -2
  100. package/src/navigation/screen/TrueSheetScreen.tsx +2 -2
  101. package/src/navigation/screen/types.ts +4 -6
  102. package/src/navigation/screen/useSheetScreenState.ts +8 -15
  103. package/src/navigation/types.ts +32 -0
  104. package/src/navigation/useTrueSheetNavigation.ts +1 -1
  105. package/src/web/dom.ts +9 -0
  106. package/src/web/vaul/helpers.ts +13 -6
  107. package/lib/module/navigation/TrueSheetRouter.js.map +0 -1
  108. package/lib/module/navigation/TrueSheetView.js.map +0 -1
  109. package/lib/module/navigation/createTrueSheetNavigator.js +0 -63
  110. package/lib/module/navigation/createTrueSheetNavigator.js.map +0 -1
  111. package/lib/typescript/src/navigation/TrueSheetRouter.d.ts +0 -57
  112. package/lib/typescript/src/navigation/TrueSheetRouter.d.ts.map +0 -1
  113. package/lib/typescript/src/navigation/TrueSheetView.d.ts +0 -10
  114. package/lib/typescript/src/navigation/TrueSheetView.d.ts.map +0 -1
  115. package/lib/typescript/src/navigation/createTrueSheetNavigator.d.ts +0 -35
  116. package/lib/typescript/src/navigation/createTrueSheetNavigator.d.ts.map +0 -1
  117. package/src/navigation/TrueSheetRouter.ts +0 -234
  118. package/src/navigation/createTrueSheetNavigator.tsx +0 -93
@@ -15,6 +15,7 @@ import com.facebook.react.uimanager.ThemedReactContext
15
15
  import com.facebook.react.uimanager.events.EventDispatcher
16
16
  import com.facebook.react.views.view.ReactViewGroup
17
17
  import com.lodev09.truesheet.core.TrueSheetCoordinatorLayout
18
+ import com.lodev09.truesheet.utils.TouchEventDeduper
18
19
 
19
20
  /**
20
21
  * Delegate interface for footer view size changes and event dispatching
@@ -91,6 +92,7 @@ class TrueSheetFooterView(private val reactContext: ThemedReactContext) :
91
92
 
92
93
  private val jsTouchDispatcher = JSTouchDispatcher(this)
93
94
  private var jsPointerDispatcher: JSPointerDispatcher? = null
95
+ private val touchDeduper = TouchEventDeduper()
94
96
 
95
97
  init {
96
98
  jsPointerDispatcher = JSPointerDispatcher(this)
@@ -110,7 +112,9 @@ class TrueSheetFooterView(private val reactContext: ThemedReactContext) :
110
112
 
111
113
  override fun onInterceptTouchEvent(event: MotionEvent): Boolean {
112
114
  eventDispatcher?.let { dispatcher ->
113
- jsTouchDispatcher.handleTouchEvent(event, dispatcher, reactContext)
115
+ if (touchDeduper.shouldDispatch(event)) {
116
+ jsTouchDispatcher.handleTouchEvent(event, dispatcher, reactContext)
117
+ }
114
118
  jsPointerDispatcher?.handleMotionEvent(event, dispatcher, true)
115
119
  }
116
120
  return super.onInterceptTouchEvent(event)
@@ -128,7 +132,9 @@ class TrueSheetFooterView(private val reactContext: ThemedReactContext) :
128
132
  }
129
133
 
130
134
  eventDispatcher?.let { dispatcher ->
131
- jsTouchDispatcher.handleTouchEvent(event, dispatcher, reactContext)
135
+ if (touchDeduper.shouldDispatch(event)) {
136
+ jsTouchDispatcher.handleTouchEvent(event, dispatcher, reactContext)
137
+ }
132
138
  jsPointerDispatcher?.handleMotionEvent(event, dispatcher, false)
133
139
  }
134
140
  super.onTouchEvent(event)
@@ -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
  }
@@ -39,6 +39,7 @@ import com.lodev09.truesheet.core.TrueSheetKeyboardObserverDelegate
39
39
  import com.lodev09.truesheet.core.TrueSheetStackManager
40
40
  import com.lodev09.truesheet.utils.KeyboardUtils
41
41
  import com.lodev09.truesheet.utils.ScreenUtils
42
+ import com.lodev09.truesheet.utils.TouchEventDeduper
42
43
 
43
44
  // =============================================================================
44
45
  // MARK: - Data Types & Delegate Protocol
@@ -199,6 +200,8 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
199
200
  // the finger drifts outside the footer's rect mid-gesture.
200
201
  private var footerOwnsTouchStream = false
201
202
 
203
+ private val touchDeduper = TouchEventDeduper()
204
+
202
205
  private val eventDispatcher
203
206
  get() = delegate?.eventDispatcher
204
207
 
@@ -1419,7 +1422,9 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
1419
1422
 
1420
1423
  override fun onInterceptTouchEvent(event: MotionEvent): Boolean {
1421
1424
  eventDispatcher?.let {
1422
- jsTouchDispatcher.handleTouchEvent(event, it, reactContext)
1425
+ if (touchDeduper.shouldDispatch(event)) {
1426
+ jsTouchDispatcher.handleTouchEvent(event, it, reactContext)
1427
+ }
1423
1428
  jsPointerDispatcher.handleMotionEvent(event, it, true)
1424
1429
  }
1425
1430
  return super.onInterceptTouchEvent(event)
@@ -1433,7 +1438,9 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
1433
1438
 
1434
1439
  override fun onTouchEvent(event: MotionEvent): Boolean {
1435
1440
  eventDispatcher?.let {
1436
- jsTouchDispatcher.handleTouchEvent(event, it, reactContext)
1441
+ if (touchDeduper.shouldDispatch(event)) {
1442
+ jsTouchDispatcher.handleTouchEvent(event, it, reactContext)
1443
+ }
1437
1444
  jsPointerDispatcher.handleMotionEvent(event, it, false)
1438
1445
  }
1439
1446
  super.onTouchEvent(event)
@@ -179,6 +179,7 @@ class TrueSheetViewManager :
179
179
  @ReactProp(name = "initialDetentIndex", defaultInt = -1)
180
180
  override fun setInitialDetentIndex(view: TrueSheetView, index: Int) {
181
181
  view.initialDetentIndex = index
182
+ view.presentInitialIfNeeded()
182
183
  }
183
184
 
184
185
  @ReactProp(name = "initialDetentAnimated", defaultBoolean = true)
@@ -0,0 +1,31 @@
1
+ package com.lodev09.truesheet.utils
2
+
3
+ import android.view.MotionEvent
4
+
5
+ /**
6
+ * Guards a nested RootView against dispatching the same [MotionEvent] to JS twice.
7
+ *
8
+ * An event that no child consumes reaches both `onInterceptTouchEvent` and `onTouchEvent`,
9
+ * so dispatching from both starts a second JS touch stream for the same gesture.
10
+ *
11
+ * A plain root can live with that, but the sheet's roots are nested: touches that hit no
12
+ * React child resolve to the root's own tag, and the second stream then finds that view
13
+ * already holding the responder, so React only asks its ancestors - handing the touch to
14
+ * touchables outside the sheet.
15
+ */
16
+ internal class TouchEventDeduper {
17
+ private var downTime = Long.MIN_VALUE
18
+ private var eventTime = Long.MIN_VALUE
19
+ private var action = -1
20
+
21
+ fun shouldDispatch(event: MotionEvent): Boolean {
22
+ if (event.downTime == downTime && event.eventTime == eventTime && event.action == action) {
23
+ return false
24
+ }
25
+
26
+ downTime = event.downTime
27
+ eventTime = event.eventTime
28
+ action = event.action
29
+ return true
30
+ }
31
+ }
@@ -25,6 +25,13 @@ void TrueSheetFooterViewShadowNode::adjustLayoutWithState() {
25
25
  float bottomInset = stateData.bottomInset;
26
26
  if (!stateData.initialized && props.autoBottomInset) {
27
27
  bottomInset = TrueSheetInsets::bottomSafeArea();
28
+ if (bottomInset != stateData.bottomInset) {
29
+ // Record the seeded value so the mounted view reads exactly the inset
30
+ // baked into this layout — the global may change before mount.
31
+ auto seededData = stateData;
32
+ seededData.bottomInset = bottomInset;
33
+ setStateData(std::move(seededData));
34
+ }
28
35
  }
29
36
 
30
37
  // A footer pinned to the sheet's bottom edge owns the bottom safe-area
@@ -82,6 +82,11 @@ NS_ASSUME_NONNULL_BEGIN
82
82
  */
83
83
  - (CGFloat)footerHeight;
84
84
 
85
+ /**
86
+ * Returns the bottom inset currently baked into the footer's height
87
+ */
88
+ - (CGFloat)footerAppliedBottomInset;
89
+
85
90
  /**
86
91
  * Returns the current height of the peek view within the content
87
92
  */
@@ -118,6 +118,10 @@ using namespace facebook::react;
118
118
  return _footerView ? _footerView.frame.size.height : 0;
119
119
  }
120
120
 
121
+ - (CGFloat)footerAppliedBottomInset {
122
+ return _footerView ? _footerView.appliedBottomInset : 0;
123
+ }
124
+
121
125
  // Distance from the top of the content view to the bottom of the peek view.
122
126
  // Includes the peek view's offset within the content (padding, views above it)
123
127
  // so the peek detent reveals everything down to the peek content's bottom edge.
@@ -27,6 +27,12 @@ NS_ASSUME_NONNULL_BEGIN
27
27
  @property (nonatomic, weak, nullable) TrueSheetKeyboardObserver *keyboardObserver;
28
28
  @property (nonatomic, weak, nullable) id<TrueSheetFooterViewDelegate> delegate;
29
29
 
30
+ /**
31
+ * Bottom inset currently baked into the footer's layout height — pairs with
32
+ * the measured frame so detent math can subtract exactly what's baked in.
33
+ */
34
+ @property (nonatomic, readonly) CGFloat appliedBottomInset;
35
+
30
36
  /**
31
37
  * Re-applies the footer's keyboard slide using the current keyboard height.
32
38
  * No-op when the keyboard is hidden. Used to reflect live `keyboardOffset` changes.
@@ -25,6 +25,8 @@ using namespace facebook::react;
25
25
  CGFloat _currentKeyboardOffset;
26
26
  CGFloat _bottomInset;
27
27
  BOOL _keyboardVisible;
28
+ BOOL _didPushBottomInset;
29
+ CGFloat _appliedBottomInset;
28
30
  }
29
31
 
30
32
  + (ComponentDescriptorProvider)componentDescriptorProvider {
@@ -43,6 +45,8 @@ using namespace facebook::react;
43
45
  _currentKeyboardOffset = 0;
44
46
  _bottomInset = 0;
45
47
  _keyboardVisible = NO;
48
+ _didPushBottomInset = NO;
49
+ _appliedBottomInset = 0;
46
50
  }
47
51
  return self;
48
52
  }
@@ -55,7 +59,11 @@ using namespace facebook::react;
55
59
  // bottom safe-area inset — the footer owns the sheet's bottom edge, so it
56
60
  // absorbs the inset and its background fills it.
57
61
  - (void)setBottomInset:(CGFloat)bottomInset {
58
- if (_bottomInset == bottomInset) {
62
+ // The first push must go through even when the value matches the ivar
63
+ // default (0) — it marks the state initialized so the shadow node stops
64
+ // seeding from the app-wide precalculated inset, which may belong to
65
+ // another sheet (e.g. a floating sheet whose real inset is 0).
66
+ if (_didPushBottomInset && _bottomInset == bottomInset) {
59
67
  return;
60
68
  }
61
69
  _bottomInset = bottomInset;
@@ -85,6 +93,14 @@ using namespace facebook::react;
85
93
  // them an inset stale (same reason Android bridges through
86
94
  // TrueSheetStateUpdater).
87
95
  _state->updateState(std::move(newState), EventQueue::UpdateMode::unstable_Immediate);
96
+ _didPushBottomInset = YES;
97
+ }
98
+
99
+ // The inset baked into the current layout by the shadow node (pushed or
100
+ // seeded — see TrueSheetFooterViewShadowNode). Refreshed in
101
+ // updateLayoutMetrics so it always pairs with the measured height.
102
+ - (CGFloat)appliedBottomInset {
103
+ return _appliedBottomInset;
88
104
  }
89
105
 
90
106
  // Height the footer occupies above the keyboard — its layout height minus the
@@ -123,8 +139,19 @@ using namespace facebook::react;
123
139
  oldLayoutMetrics:(const facebook::react::LayoutMetrics &)oldLayoutMetrics {
124
140
  [super updateLayoutMetrics:layoutMetrics oldLayoutMetrics:oldLayoutMetrics];
125
141
 
142
+ // State lands before layout metrics within the same mount transaction, so
143
+ // this reflects the inset the shadow node baked into this frame — seeded
144
+ // values are recorded back into the state at layout time (see
145
+ // TrueSheetFooterViewShadowNode).
146
+ CGFloat previousAppliedBottomInset = _appliedBottomInset;
147
+ if (_state) {
148
+ _appliedBottomInset = _state->getData().bottomInset;
149
+ }
150
+
126
151
  CGFloat height = layoutMetrics.frame.size.height;
127
- if (height != _lastHeight) {
152
+ // An inset change alone (fixed-height footer: padding moves, frame doesn't)
153
+ // still changes what's baked in — report so the controller re-pairs.
154
+ if (height != _lastHeight || _appliedBottomInset != previousAppliedBottomInset) {
128
155
  _lastHeight = height;
129
156
  [self.delegate footerViewDidChangeSize:CGSizeMake(layoutMetrics.frame.size.width, height)];
130
157
  }
@@ -143,6 +170,8 @@ using namespace facebook::react;
143
170
  _currentKeyboardOffset = 0;
144
171
  _bottomInset = 0;
145
172
  _keyboardVisible = NO;
173
+ _didPushBottomInset = NO;
174
+ _appliedBottomInset = 0;
146
175
  }
147
176
 
148
177
  #pragma mark - TrueSheetKeyboardObserverDelegate
@@ -110,18 +110,47 @@ using namespace facebook::react;
110
110
  return;
111
111
  }
112
112
 
113
- if (_initialDetentIndex >= 0 && !_didInitiallyPresent) {
114
- UIViewController *vc = [self findPresentingViewController];
113
+ [self presentInitialIfNeeded];
114
+ }
115
115
 
116
- // Only present if the view controller is in the same window and not being dismissed
117
- if (vc && vc.view.window == self.window && !_controller.isBeingDismissed) {
118
- _didInitiallyPresent = YES;
119
- [self presentAtIndex:_initialDetentIndex animated:_initialDetentAnimated completion:nil];
120
- } else {
121
- // Animate next time when sheet finally moves to the correct window
122
- _initialDetentAnimated = YES;
123
- }
116
+ // JS holds initialDetentIndex back one commit so effect-driven content (e.g. a
117
+ // navigation footer set via setOptions) commits together with it — by the time
118
+ // the prop lands the tree is final and the sheet presents at its final height.
119
+ // Called from didMoveToWindow (re-attach) and finalizeUpdates (the prop
120
+ // arriving after attach, which didMoveToWindow won't re-fire for).
121
+ - (void)presentInitialIfNeeded {
122
+ if (_initialDetentIndex < 0 || _didInitiallyPresent)
123
+ return;
124
+
125
+ UIViewController *vc = [self findPresentingViewController];
126
+
127
+ // Only present if the view controller is in the same window and not being dismissed
128
+ if (!vc || vc.view.window != self.window || _controller.isBeingDismissed) {
129
+ // Animate next time when sheet finally moves to the correct window
130
+ _initialDetentAnimated = YES;
131
+ return;
124
132
  }
133
+
134
+ _didInitiallyPresent = YES;
135
+
136
+ // Deferred a runloop turn so sibling mounts from the current transaction
137
+ // (e.g. a footer committed alongside the prop) land before measuring.
138
+ __weak __typeof(self) weakSelf = self;
139
+ dispatch_async(dispatch_get_main_queue(), ^{
140
+ __typeof(self) strongSelf = weakSelf;
141
+ if (!strongSelf || strongSelf->_controller.isPresented || strongSelf->_controller.isBeingPresented)
142
+ return;
143
+
144
+ [strongSelf presentAtIndex:strongSelf->_initialDetentIndex
145
+ animated:strongSelf->_initialDetentAnimated
146
+ completion:^(BOOL success, NSError *_Nullable error) {
147
+ // Present can fail if the view detached during this turn —
148
+ // reset so the next attach/prop update retries.
149
+ if (!success) {
150
+ strongSelf->_didInitiallyPresent = NO;
151
+ }
152
+ }];
153
+ });
125
154
  }
126
155
 
127
156
  - (void)dealloc {
@@ -342,6 +371,9 @@ using namespace facebook::react;
342
371
  _pendingPropsUpdate = YES;
343
372
  } else if (_initialDetentIndex >= 0) {
344
373
  _pendingLayoutUpdate = NO;
374
+ if (self.window) {
375
+ [self presentInitialIfNeeded];
376
+ }
345
377
  }
346
378
  }
347
379
 
@@ -403,9 +435,8 @@ using namespace facebook::react;
403
435
  _controller.headerHeight = @(headerHeight);
404
436
  }
405
437
 
406
- CGFloat footerHeight = [_containerView footerHeight];
407
- if (footerHeight > 0) {
408
- _controller.footerHeight = @(footerHeight);
438
+ if ([_containerView footerHeight] > 0) {
439
+ [self syncFooterMetrics];
409
440
  }
410
441
 
411
442
  CGFloat peekContentHeight = [_containerView peekContentHeight];
@@ -480,11 +511,12 @@ using namespace facebook::react;
480
511
  if (_containerView) {
481
512
  _controller.contentHeight = @([_containerView contentHeight]);
482
513
  _controller.headerHeight = @([_containerView headerHeight]);
483
- _controller.footerHeight = @([_containerView footerHeight]);
514
+ [self syncFooterMetrics];
484
515
  _controller.peekContentHeight = @([_containerView peekContentHeight]);
485
516
  }
486
517
  _pendingSizeChange = NO;
487
518
 
519
+ [self refreshFooterBottomInset];
488
520
  [_controller setupSheetDetents];
489
521
  [_controller setupActiveDetentWithIndex:index];
490
522
 
@@ -583,6 +615,10 @@ using namespace facebook::react;
583
615
  * Debounced sheet update to handle rapid content/header size changes.
584
616
  */
585
617
  - (void)setupSheetDetentsForSizeChange {
618
+ // Keep the footer's absorbed inset in sync with the latest measured heights
619
+ // even before presenting, so the sheet presents at its final size.
620
+ [self refreshFooterBottomInset];
621
+
586
622
  // Retargeting the in-flight presentation makes UIKit snap the whole stack
587
623
  // (including the sheet behind) instead of animating. Defer to didPresent —
588
624
  // presentAtIndex measured synchronously, so this is usually a no-op.
@@ -610,6 +646,7 @@ using namespace facebook::react;
610
646
  // Refresh here (not just on peek size events) since the peek's offset
611
647
  // within the content can change without its own size changing.
612
648
  self->_controller.peekContentHeight = @([self->_containerView peekContentHeight]);
649
+ [self refreshFooterBottomInset];
613
650
  [self->_controller setupSheetDetentsForSizeChange];
614
651
  });
615
652
  }
@@ -625,7 +662,7 @@ using namespace facebook::react;
625
662
  }
626
663
 
627
664
  - (void)containerViewFooterDidChangeSize:(CGSize)newSize {
628
- _controller.footerHeight = @(newSize.height);
665
+ [self syncFooterMetrics];
629
666
  [self setupSheetDetentsForSizeChange];
630
667
  [_controller setupAccessibilityContainer];
631
668
  }
@@ -716,6 +753,10 @@ using namespace facebook::react;
716
753
  [self updateStateWithSize:size];
717
754
  }
718
755
 
756
+ - (void)viewControllerSafeAreaInsetsDidChange {
757
+ [self refreshFooterBottomInset];
758
+ }
759
+
719
760
  - (void)viewControllerWillFocus {
720
761
  [TrueSheetFocusEvents emitWillFocus:_eventEmitter];
721
762
  }
@@ -784,13 +825,36 @@ using namespace facebook::react;
784
825
  _containerView.insetAdjustment = _insetAdjustment;
785
826
  _containerView.scrollableOptions = _scrollableOptions;
786
827
  _containerView.hasAutoDetent = _hasAutoDetent;
787
- _containerView.footerBottomInset = _controller.footerBottomInset;
828
+ [self refreshFooterBottomInset];
788
829
  [_containerView setupScrollable];
830
+ }
831
+
832
+ // footerHeight and appliedFooterBottomInset must stay paired — detent math
833
+ // subtracts exactly the inset baked into the measured footer height (see
834
+ // autoDetentBottomInset/peekDetentBottomInset).
835
+ - (void)syncFooterMetrics {
836
+ _controller.footerHeight = @([_containerView footerHeight]);
837
+ _controller.appliedFooterBottomInset = [_containerView footerAppliedBottomInset];
838
+ }
839
+
840
+ // Recomputes the inset the footer absorbs and pushes it down. The inset
841
+ // depends on measured heights and detents (see maxNaturalDetentHeight), so
842
+ // this runs on size and detent changes — not just prop updates.
843
+ - (void)refreshFooterBottomInset {
844
+ if (!_containerView)
845
+ return;
846
+
847
+ // appliedFooterBottomInset is NOT set here — it tracks what's actually
848
+ // baked into the footer's measured height (reported with its layout), not
849
+ // what was just pushed. Setting it at push time desyncs the pair and lets
850
+ // a seeded inset read as natural content height (see maxNaturalDetentHeight).
851
+ CGFloat inset = _controller.footerBottomInset;
852
+ _containerView.footerBottomInset = inset;
789
853
 
790
854
  // Publish the inset so a footer set later (e.g. navigation setOptions) is
791
855
  // padded on its very first layout instead of resizing the sheet twice.
792
856
  if (_insetAdjustment == TrueSheetViewInsetAdjustment::Automatic) {
793
- TrueSheetInsets::setBottomSafeArea(_controller.footerBottomInset);
857
+ TrueSheetInsets::setBottomSafeArea(inset);
794
858
  }
795
859
  }
796
860
 
@@ -801,6 +865,7 @@ using namespace facebook::react;
801
865
  if (_pendingDetents) {
802
866
  _controller.detents = _pendingDetents;
803
867
  _pendingDetents = nil;
868
+ [self refreshFooterBottomInset];
804
869
  }
805
870
 
806
871
  UIView *presenterView = _controller.presentingViewController.view;
@@ -36,6 +36,7 @@ NS_ASSUME_NONNULL_BEGIN
36
36
  detent:(CGFloat)detent
37
37
  realtime:(BOOL)realtime;
38
38
  - (void)viewControllerDidChangeSize:(CGSize)size;
39
+ - (void)viewControllerSafeAreaInsetsDidChange;
39
40
  - (void)viewControllerWillFocus;
40
41
  - (void)viewControllerDidFocus;
41
42
  - (void)viewControllerWillBlur;
@@ -99,6 +100,14 @@ NS_ASSUME_NONNULL_BEGIN
99
100
  */
100
101
  @property (nonatomic, readonly) CGFloat footerBottomInset;
101
102
 
103
+ /**
104
+ * The inset currently baked into the footer's measured height, reported
105
+ * alongside its layout (see TrueSheetFooterView.appliedBottomInset). Detent
106
+ * resolution subtracts this exact value so the baked inset is never
107
+ * double-counted.
108
+ */
109
+ @property (nonatomic, assign) CGFloat appliedFooterBottomInset;
110
+
102
111
  - (void)setupAccessibilityContainer;
103
112
  - (void)applyActiveDetent;
104
113
  - (void)setupActiveDetentWithIndex:(NSInteger)index;