@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
@@ -53,6 +53,7 @@ using namespace facebook::react;
53
53
  NSInteger _initialDetentIndex;
54
54
  TrueSheetViewInsetAdjustment _insetAdjustment;
55
55
  ScrollableOptions *_scrollableOptions;
56
+ NSInteger _scrollableHandle;
56
57
  BOOL _hasAutoDetent;
57
58
  BOOL _initialDetentAnimated;
58
59
  BOOL _isSheetUpdatePending;
@@ -110,18 +111,47 @@ using namespace facebook::react;
110
111
  return;
111
112
  }
112
113
 
113
- if (_initialDetentIndex >= 0 && !_didInitiallyPresent) {
114
- UIViewController *vc = [self findPresentingViewController];
114
+ [self presentInitialIfNeeded];
115
+ }
115
116
 
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
- }
117
+ // JS holds initialDetentIndex back one commit so effect-driven content (e.g. a
118
+ // navigation footer set via setOptions) commits together with it — by the time
119
+ // the prop lands the tree is final and the sheet presents at its final height.
120
+ // Called from didMoveToWindow (re-attach) and finalizeUpdates (the prop
121
+ // arriving after attach, which didMoveToWindow won't re-fire for).
122
+ - (void)presentInitialIfNeeded {
123
+ if (_initialDetentIndex < 0 || _didInitiallyPresent)
124
+ return;
125
+
126
+ UIViewController *vc = [self findPresentingViewController];
127
+
128
+ // Only present if the view controller is in the same window and not being dismissed
129
+ if (!vc || vc.view.window != self.window || _controller.isBeingDismissed) {
130
+ // Animate next time when sheet finally moves to the correct window
131
+ _initialDetentAnimated = YES;
132
+ return;
124
133
  }
134
+
135
+ _didInitiallyPresent = YES;
136
+
137
+ // Deferred a runloop turn so sibling mounts from the current transaction
138
+ // (e.g. a footer committed alongside the prop) land before measuring.
139
+ __weak __typeof(self) weakSelf = self;
140
+ dispatch_async(dispatch_get_main_queue(), ^{
141
+ __typeof(self) strongSelf = weakSelf;
142
+ if (!strongSelf || strongSelf->_controller.isPresented || strongSelf->_controller.isBeingPresented)
143
+ return;
144
+
145
+ [strongSelf presentAtIndex:strongSelf->_initialDetentIndex
146
+ animated:strongSelf->_initialDetentAnimated
147
+ completion:^(BOOL success, NSError *_Nullable error) {
148
+ // Present can fail if the view detached during this turn —
149
+ // reset so the next attach/prop update retries.
150
+ if (!success) {
151
+ strongSelf->_didInitiallyPresent = NO;
152
+ }
153
+ }];
154
+ });
125
155
  }
126
156
 
127
157
  - (void)dealloc {
@@ -248,17 +278,23 @@ using namespace facebook::react;
248
278
  _initialDetentIndex = newProps.initialDetentIndex;
249
279
  _initialDetentAnimated = newProps.initialDetentAnimated;
250
280
 
281
+ _scrollableHandle = newProps.scrollableHandle;
282
+
251
283
  const auto &scrollableOpts = newProps.scrollableOptions;
252
284
  BOOL scrollingExpandsSheet = scrollableOpts.scrollingExpandsSheet;
285
+ BOOL contentInsetAdjustment = scrollableOpts.contentInsetAdjustmentBehavior;
253
286
  auto topEdgeEffect = scrollableOpts.topScrollEdgeEffect;
254
287
  auto bottomEdgeEffect = scrollableOpts.bottomScrollEdgeEffect;
255
- BOOL hasScrollableOptions = scrollableOpts.keyboardScrollOffset > 0 || !scrollingExpandsSheet ||
288
+ BOOL hasScrollableOptions = scrollableOpts.keyboardScrollOffset > 0 || scrollableOpts.keyboardOffset != 0 ||
289
+ !scrollingExpandsSheet || !contentInsetAdjustment ||
256
290
  topEdgeEffect != TrueSheetViewTopScrollEdgeEffect::Hidden ||
257
291
  bottomEdgeEffect != TrueSheetViewBottomScrollEdgeEffect::Hidden;
258
292
 
259
293
  if (hasScrollableOptions) {
260
294
  ScrollableOptions *options = [[ScrollableOptions alloc] init];
295
+ options.contentInsetAdjustment = contentInsetAdjustment;
261
296
  options.keyboardScrollOffset = scrollableOpts.keyboardScrollOffset;
297
+ options.keyboardOffset = scrollableOpts.keyboardOffset;
262
298
  options.scrollingExpandsSheet = scrollingExpandsSheet;
263
299
  options.topScrollEdgeEffect = topEdgeEffect;
264
300
  options.bottomScrollEdgeEffect = bottomEdgeEffect;
@@ -342,6 +378,9 @@ using namespace facebook::react;
342
378
  _pendingPropsUpdate = YES;
343
379
  } else if (_initialDetentIndex >= 0) {
344
380
  _pendingLayoutUpdate = NO;
381
+ if (self.window) {
382
+ [self presentInitialIfNeeded];
383
+ }
345
384
  }
346
385
  }
347
386
 
@@ -403,9 +442,8 @@ using namespace facebook::react;
403
442
  _controller.headerHeight = @(headerHeight);
404
443
  }
405
444
 
406
- CGFloat footerHeight = [_containerView footerHeight];
407
- if (footerHeight > 0) {
408
- _controller.footerHeight = @(footerHeight);
445
+ if ([_containerView footerHeight] > 0) {
446
+ [self syncFooterMetrics];
409
447
  }
410
448
 
411
449
  CGFloat peekContentHeight = [_containerView peekContentHeight];
@@ -471,7 +509,7 @@ using namespace facebook::react;
471
509
  [_controller setupSheetSizing];
472
510
  [_controller setupSheetProps];
473
511
 
474
- // Pin before measuring so a first-time pin doesn't shift sizes mid-animation.
512
+ // Detect before measuring so a first-time detection doesn't shift sizes mid-animation.
475
513
  [self setupScrollable];
476
514
 
477
515
  // Measure synchronously so the presentation starts at the right height —
@@ -480,11 +518,12 @@ using namespace facebook::react;
480
518
  if (_containerView) {
481
519
  _controller.contentHeight = @([_containerView contentHeight]);
482
520
  _controller.headerHeight = @([_containerView headerHeight]);
483
- _controller.footerHeight = @([_containerView footerHeight]);
521
+ [self syncFooterMetrics];
484
522
  _controller.peekContentHeight = @([_containerView peekContentHeight]);
485
523
  }
486
524
  _pendingSizeChange = NO;
487
525
 
526
+ [self refreshFooterBottomInset];
488
527
  [_controller setupSheetDetents];
489
528
  [_controller setupActiveDetentWithIndex:index];
490
529
 
@@ -583,6 +622,10 @@ using namespace facebook::react;
583
622
  * Debounced sheet update to handle rapid content/header size changes.
584
623
  */
585
624
  - (void)setupSheetDetentsForSizeChange {
625
+ // Keep the footer's absorbed inset in sync with the latest measured heights
626
+ // even before presenting, so the sheet presents at its final size.
627
+ [self refreshFooterBottomInset];
628
+
586
629
  // Retargeting the in-flight presentation makes UIKit snap the whole stack
587
630
  // (including the sheet behind) instead of animating. Defer to didPresent —
588
631
  // presentAtIndex measured synchronously, so this is usually a no-op.
@@ -610,6 +653,7 @@ using namespace facebook::react;
610
653
  // Refresh here (not just on peek size events) since the peek's offset
611
654
  // within the content can change without its own size changing.
612
655
  self->_controller.peekContentHeight = @([self->_containerView peekContentHeight]);
656
+ [self refreshFooterBottomInset];
613
657
  [self->_controller setupSheetDetentsForSizeChange];
614
658
  });
615
659
  }
@@ -625,7 +669,7 @@ using namespace facebook::react;
625
669
  }
626
670
 
627
671
  - (void)containerViewFooterDidChangeSize:(CGSize)newSize {
628
- _controller.footerHeight = @(newSize.height);
672
+ [self syncFooterMetrics];
629
673
  [self setupSheetDetentsForSizeChange];
630
674
  [_controller setupAccessibilityContainer];
631
675
  }
@@ -634,7 +678,7 @@ using namespace facebook::react;
634
678
  [self setupSheetDetentsForSizeChange];
635
679
  }
636
680
 
637
- // When the ScrollView changes (e.g. conditional remount), re-pin the new ScrollView.
681
+ // When the ScrollView changes (e.g. conditional remount), re-detect the new ScrollView.
638
682
  - (void)containerViewScrollViewDidChange {
639
683
  [self setupScrollable];
640
684
  }
@@ -716,6 +760,10 @@ using namespace facebook::react;
716
760
  [self updateStateWithSize:size];
717
761
  }
718
762
 
763
+ - (void)viewControllerSafeAreaInsetsDidChange {
764
+ [self refreshFooterBottomInset];
765
+ }
766
+
719
767
  - (void)viewControllerWillFocus {
720
768
  [TrueSheetFocusEvents emitWillFocus:_eventEmitter];
721
769
  }
@@ -781,16 +829,41 @@ using namespace facebook::react;
781
829
  if (!_containerView)
782
830
  return;
783
831
 
784
- _containerView.insetAdjustment = _insetAdjustment;
785
832
  _containerView.scrollableOptions = _scrollableOptions;
833
+ _containerView.scrollableHandle = _scrollableHandle;
834
+ _containerView.contentInsetAdjustment = (_scrollableOptions ? _scrollableOptions.contentInsetAdjustment : YES) &&
835
+ _insetAdjustment == TrueSheetViewInsetAdjustment::Automatic;
786
836
  _containerView.hasAutoDetent = _hasAutoDetent;
787
- _containerView.footerBottomInset = _controller.footerBottomInset;
837
+ [self refreshFooterBottomInset];
788
838
  [_containerView setupScrollable];
839
+ }
840
+
841
+ // footerHeight and appliedFooterBottomInset must stay paired — detent math
842
+ // subtracts exactly the inset baked into the measured footer height (see
843
+ // autoDetentBottomInset/peekDetentBottomInset).
844
+ - (void)syncFooterMetrics {
845
+ _controller.footerHeight = @([_containerView footerHeight]);
846
+ _controller.appliedFooterBottomInset = [_containerView footerAppliedBottomInset];
847
+ }
848
+
849
+ // Recomputes the inset the footer absorbs and pushes it down. The inset
850
+ // depends on measured heights and detents (see maxNaturalDetentHeight), so
851
+ // this runs on size and detent changes — not just prop updates.
852
+ - (void)refreshFooterBottomInset {
853
+ if (!_containerView)
854
+ return;
855
+
856
+ // appliedFooterBottomInset is NOT set here — it tracks what's actually
857
+ // baked into the footer's measured height (reported with its layout), not
858
+ // what was just pushed. Setting it at push time desyncs the pair and lets
859
+ // a seeded inset read as natural content height (see maxNaturalDetentHeight).
860
+ CGFloat inset = _controller.footerBottomInset;
861
+ _containerView.footerBottomInset = inset;
789
862
 
790
863
  // Publish the inset so a footer set later (e.g. navigation setOptions) is
791
864
  // padded on its very first layout instead of resizing the sheet twice.
792
865
  if (_insetAdjustment == TrueSheetViewInsetAdjustment::Automatic) {
793
- TrueSheetInsets::setBottomSafeArea(_controller.footerBottomInset);
866
+ TrueSheetInsets::setBottomSafeArea(inset);
794
867
  }
795
868
  }
796
869
 
@@ -801,6 +874,7 @@ using namespace facebook::react;
801
874
  if (_pendingDetents) {
802
875
  _controller.detents = _pendingDetents;
803
876
  _pendingDetents = nil;
877
+ [self refreshFooterBottomInset];
804
878
  }
805
879
 
806
880
  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;
@@ -70,6 +70,11 @@ static char TrueSheetAccessibilityWindowPreviousElementsKey;
70
70
  BOOL _isDragging;
71
71
  BOOL _isWillDismissEmitted;
72
72
 
73
+ // Last bottom safe area observed while presented — reused as the estimate
74
+ // for the next present (beats the float-threshold guess for re-presents).
75
+ BOOL _hasObservedBottomInset;
76
+ CGFloat _observedBottomInset;
77
+
73
78
  BOOL _isInteractiveDismiss;
74
79
  CGFloat _interactiveStartPosition;
75
80
  UIView *_interactiveContainerView;
@@ -206,6 +211,12 @@ static char TrueSheetAccessibilityWindowPreviousElementsKey;
206
211
  return window ? window.bounds.size.height : UIScreen.mainScreen.bounds.size.height;
207
212
  }
208
213
 
214
+ // Only phone sheets absorb the bottom safe-area inset — non-phone idioms
215
+ // (iPad form sheets) are inset from the screen edge by UIKit.
216
+ static BOOL TrueSheetIsPhoneIdiom(void) {
217
+ return UIDevice.currentDevice.userInterfaceIdiom == UIUserInterfaceIdiomPhone;
218
+ }
219
+
209
220
  - (CGFloat)detentBottomAdjustmentForHeight:(CGFloat)height {
210
221
  if (_insetAdjustment == TrueSheetViewInsetAdjustment::Automatic) {
211
222
  return 0;
@@ -215,7 +226,7 @@ static char TrueSheetAccessibilityWindowPreviousElementsKey;
215
226
  }
216
227
 
217
228
  - (CGFloat)bottomSafeAreaForHeight:(CGFloat)height {
218
- if (UIDevice.currentDevice.userInterfaceIdiom != UIUserInterfaceIdiomPhone) {
229
+ if (!TrueSheetIsPhoneIdiom()) {
219
230
  return 0;
220
231
  }
221
232
 
@@ -232,49 +243,101 @@ static char TrueSheetAccessibilityWindowPreviousElementsKey;
232
243
  }
233
244
 
234
245
  /**
235
- * Bottom inset excluded from the auto detent. A relative footer owns the
236
- * sheet's bottom edge, so it absorbs the inset UIKit lays custom detents out
237
- * above the safe area, which would otherwise gap the content from the footer.
246
+ * Largest height the sheet can reach across its configured detents, excluding
247
+ * the inset currently baked into the footer's height. Basis for the footer's
248
+ * inset absorption so it follows the same rule as bottomSafeAreaForHeight:
249
+ * a sheet that only ever floats (small detents on iOS 26) keeps its footer
250
+ * unpadded.
238
251
  */
239
- - (CGFloat)autoDetentBottomInsetForHeight:(CGFloat)height {
240
- if (_insetAdjustment != TrueSheetViewInsetAdjustment::Automatic) {
241
- // 'none' already excludes the inset for every detent
242
- return 0;
252
+ - (CGFloat)maxNaturalDetentHeight {
253
+ CGFloat applied = [self.footerHeight floatValue] > 0 ? _appliedFooterBottomInset : 0;
254
+ CGFloat maxHeight = 0;
255
+
256
+ for (id detent in self.detents) {
257
+ if (![detent isKindOfClass:[NSNumber class]]) {
258
+ continue;
259
+ }
260
+
261
+ CGFloat value = [detent doubleValue];
262
+ CGFloat height = 0;
263
+ if (value == -1) {
264
+ // Auto counts a relative footer's (padded) height
265
+ height = [_detentCalculator autoHeight] - (_absoluteFooter ? 0 : applied);
266
+ } else if (value == -2) {
267
+ // Peek counts an absolute footer's (padded) height
268
+ height = [_detentCalculator peekHeight] - (_absoluteFooter ? applied : 0);
269
+ } else if (value > 0 && value <= 1) {
270
+ height = value * self.screenHeight;
271
+ }
272
+ maxHeight = fmax(maxHeight, height);
243
273
  }
244
274
 
245
- if (_absoluteFooter || [self.footerHeight floatValue] <= 0) {
246
- return 0;
275
+ if (self.maxContentHeight) {
276
+ maxHeight = fmin(maxHeight, [self.maxContentHeight floatValue]);
247
277
  }
248
278
 
249
- return [self bottomSafeAreaForHeight:height];
279
+ return maxHeight > 0 ? maxHeight : self.screenHeight;
250
280
  }
251
281
 
252
- // The inset the footer absorbs as padding. Uses the auto detent height when
253
- // available so it matches the auto detent's inset exclusion above.
282
+ // The inset the footer absorbs as padding.
254
283
  - (CGFloat)footerBottomInset {
255
284
  if (_insetAdjustment != TrueSheetViewInsetAdjustment::Automatic) {
256
285
  return 0;
257
286
  }
258
287
 
259
- return [self bottomSafeAreaForHeight:_autoDetentHeight > 0 ? _autoDetentHeight : self.screenHeight];
288
+ // Early out so the maxNaturalDetentHeight fallback below doesn't run per
289
+ // layout pass on iPad only to be zeroed by bottomSafeAreaForHeight:.
290
+ if (!TrueSheetIsPhoneIdiom()) {
291
+ return 0;
292
+ }
293
+
294
+ // Once laid out, the sheet's own safe area is the authority — it reflects
295
+ // UIKit's actual float/anchored decision (a floating sheet gets 0), where
296
+ // bottomSafeAreaForHeight: can only guess the float threshold. Guessing
297
+ // wrong leaves the safe-area region unfilled (gap) or double-padded.
298
+ UIView *view = self.viewIfLoaded;
299
+ if (view.window) {
300
+ _hasObservedBottomInset = YES;
301
+ _observedBottomInset = view.safeAreaInsets.bottom;
302
+ return _observedBottomInset;
303
+ }
304
+
305
+ // Off-window while presented (e.g. hidden behind a native screen): the last
306
+ // observed value is this sheet's own truth — prefer it over the
307
+ // float-threshold guess. Cleared on dismiss so a re-present observes fresh
308
+ // geometry (rotation or detent changes while dismissed would stale it).
309
+ if (_hasObservedBottomInset) {
310
+ return _observedBottomInset;
311
+ }
312
+
313
+ return [self bottomSafeAreaForHeight:[self maxNaturalDetentHeight]];
260
314
  }
261
315
 
262
316
  /**
263
- * Bottom inset excluded from the peek detent. An absolute footer counts
264
- * toward the peek height and absorbs the inset as padding, but UIKit already
265
- * lays custom detents out above the safe area — subtract it so the inset
266
- * isn't doubled.
317
+ * Bottom inset excluded from the auto detent. A relative footer owns the
318
+ * sheet's bottom edge and carries the inset in its height (see
319
+ * footerBottomInset) subtract exactly what was baked in so UIKit's own
320
+ * safe-area layout doesn't double it.
267
321
  */
268
- - (CGFloat)peekDetentBottomInsetForHeight:(CGFloat)height {
269
- if (_insetAdjustment != TrueSheetViewInsetAdjustment::Automatic) {
322
+ - (CGFloat)autoDetentBottomInset {
323
+ if (_absoluteFooter || [self.footerHeight floatValue] <= 0) {
270
324
  return 0;
271
325
  }
272
326
 
327
+ return _appliedFooterBottomInset;
328
+ }
329
+
330
+ /**
331
+ * Bottom inset excluded from the peek detent. An absolute footer counts
332
+ * toward the peek height and carries the inset in its height — subtract
333
+ * exactly what was baked in so it isn't doubled.
334
+ */
335
+ - (CGFloat)peekDetentBottomInset {
273
336
  if (!_absoluteFooter || [self.footerHeight floatValue] <= 0) {
274
337
  return 0;
275
338
  }
276
339
 
277
- return [self bottomSafeAreaForHeight:height];
340
+ return _appliedFooterBottomInset;
278
341
  }
279
342
 
280
343
  - (BOOL)isDesignCompatibilityMode {
@@ -520,6 +583,7 @@ static char TrueSheetAccessibilityWindowPreviousElementsKey;
520
583
  [self restoreWindowAccessibilityElements];
521
584
  _isPresented = NO;
522
585
  _isWillDismissEmitted = NO;
586
+ _hasObservedBottomInset = NO;
523
587
 
524
588
  [_anchorView removeFromSuperview];
525
589
  _anchorView = nil;
@@ -605,9 +669,30 @@ static char TrueSheetAccessibilityWindowPreviousElementsKey;
605
669
  }
606
670
  }
607
671
 
672
+ // Fires when UIKit resolves the sheet's float/anchored placement (present,
673
+ // detent resize crossing the float threshold, rotation). The footer's
674
+ // absorbed inset follows the observed safe area — repush it right away so
675
+ // the footer pads/unpads in the same layout pass.
676
+ - (void)viewSafeAreaInsetsDidChange {
677
+ [super viewSafeAreaInsetsDidChange];
678
+
679
+ // Mid-dismiss the frame moves off-screen and reports transient insets —
680
+ // don't let them pollute the observed value (matches viewDidLayoutSubviews).
681
+ if (!self.isBeingDismissed) {
682
+ [self.delegate viewControllerSafeAreaInsetsDidChange];
683
+ }
684
+ }
685
+
608
686
  - (void)viewDidLayoutSubviews {
609
687
  [super viewDidLayoutSubviews];
610
688
 
689
+ // viewSafeAreaInsetsDidChange doesn't fire on re-present when the observed
690
+ // inset matches the previous presentation's — refresh every layout pass
691
+ // instead (deduped downstream, so unchanged values are free).
692
+ if (!self.isBeingDismissed) {
693
+ [self.delegate viewControllerSafeAreaInsetsDidChange];
694
+ }
695
+
611
696
  // Skip size reports while backgrounded behind a stacked child — the push-back
612
697
  // scaling changes the frame and would needlessly resize content.
613
698
  // _lastReportedSize stays stale so the restore pass re-reports any real change.
@@ -1079,8 +1164,7 @@ static char TrueSheetAccessibilityWindowPreviousElementsKey;
1079
1164
  return [self customDetentWithIdentifier:@"custom-auto"
1080
1165
  atIndex:index
1081
1166
  heightBlock:^CGFloat {
1082
- CGFloat height = self->_autoDetentHeight;
1083
- return height - [self autoDetentBottomInsetForHeight:height];
1167
+ return self->_autoDetentHeight - [self autoDetentBottomInset];
1084
1168
  }];
1085
1169
  } else {
1086
1170
  return [UISheetPresentationControllerDetent mediumDetent];
@@ -1092,8 +1176,7 @@ static char TrueSheetAccessibilityWindowPreviousElementsKey;
1092
1176
  return [self customDetentWithIdentifier:@"custom-peek"
1093
1177
  atIndex:index
1094
1178
  heightBlock:^CGFloat {
1095
- CGFloat height = self->_peekDetentHeight;
1096
- return height - [self peekDetentBottomInsetForHeight:height];
1179
+ return self->_peekDetentHeight - [self peekDetentBottomInset];
1097
1180
  }];
1098
1181
  } else {
1099
1182
  return [UISheetPresentationControllerDetent mediumDetent];
@@ -17,6 +17,7 @@ NS_ASSUME_NONNULL_BEGIN
17
17
  - (void)setupEdgeInteractionWithScrollView:(nullable UIScrollView *)scrollView
18
18
  edge:(UIRectEdge)edge API_AVAILABLE(ios(26.0));
19
19
  - (void)cleanupEdgeInteraction API_AVAILABLE(ios(26.0));
20
+ - (nullable UIScrollView *)edgeInteractionScrollView API_AVAILABLE(ios(26.0));
20
21
 
21
22
  @end
22
23
 
@@ -31,15 +31,37 @@ static const void *kEdgeEffectHintKey = &kEdgeEffectHintKey;
31
31
  objc_setAssociatedObject(self, kEdgeEffectHintKey, nil, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
32
32
  }
33
33
 
34
+ - (nullable UIScrollView *)edgeInteractionScrollView API_AVAILABLE(ios(26.0)) {
35
+ #if RNTS_IPHONE_OS_VERSION_AVAILABLE(26_0)
36
+ for (id<UIInteraction> interaction in self.interactions) {
37
+ if ([interaction isKindOfClass:[UIScrollEdgeElementContainerInteraction class]]) {
38
+ return ((UIScrollEdgeElementContainerInteraction *)interaction).scrollView;
39
+ }
40
+ }
41
+ #endif
42
+ return nil;
43
+ }
44
+
34
45
  - (void)setupEdgeInteractionWithScrollView:(nullable UIScrollView *)scrollView
35
46
  edge:(UIRectEdge)edge API_AVAILABLE(ios(26.0)) {
36
- [self cleanupEdgeInteraction];
37
-
38
47
  if (!scrollView) {
48
+ [self cleanupEdgeInteraction];
39
49
  return;
40
50
  }
41
51
 
42
52
  #if RNTS_IPHONE_OS_VERSION_AVAILABLE(26_0)
53
+ // Update in place — this re-runs on every footer/header size change, and
54
+ // tearing the interaction down mid keyboard animation flashes the edge
55
+ // effect at the footer's final position.
56
+ for (id<UIInteraction> interaction in self.interactions) {
57
+ if ([interaction isKindOfClass:[UIScrollEdgeElementContainerInteraction class]]) {
58
+ UIScrollEdgeElementContainerInteraction *existing = (UIScrollEdgeElementContainerInteraction *)interaction;
59
+ existing.scrollView = scrollView;
60
+ existing.edge = edge;
61
+ return;
62
+ }
63
+ }
64
+
43
65
  // UIScrollEdgeElementContainerInteraction requires standard UIKit element
44
66
  // descendants (UILabel, UIControl, etc.) to trigger the edge effect.
45
67
  // RCTViewComponentView subviews are not recognized, so we add a