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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/README.md +6 -5
  2. package/android/src/main/java/com/lodev09/truesheet/TrueSheetContainerView.kt +23 -2
  3. package/android/src/main/java/com/lodev09/truesheet/TrueSheetContentView.kt +116 -26
  4. package/android/src/main/java/com/lodev09/truesheet/TrueSheetContentViewManager.kt +7 -0
  5. package/android/src/main/java/com/lodev09/truesheet/TrueSheetFooterView.kt +48 -0
  6. package/android/src/main/java/com/lodev09/truesheet/TrueSheetFooterViewManager.kt +7 -0
  7. package/android/src/main/java/com/lodev09/truesheet/TrueSheetStateUpdater.kt +45 -0
  8. package/android/src/main/java/com/lodev09/truesheet/TrueSheetView.kt +28 -12
  9. package/android/src/main/java/com/lodev09/truesheet/TrueSheetViewController.kt +99 -34
  10. package/android/src/main/java/com/lodev09/truesheet/TrueSheetViewManager.kt +9 -5
  11. package/android/src/main/java/com/lodev09/truesheet/core/TrueSheetCoordinatorLayout.kt +12 -3
  12. package/android/src/main/java/com/lodev09/truesheet/core/TrueSheetDetentCalculator.kt +33 -4
  13. package/android/src/main/jni/TrueSheetSpec.h +2 -0
  14. package/android/src/main/jni/TrueSheetStateUpdater.cpp +96 -0
  15. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetContentViewComponentDescriptor.h +24 -0
  16. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetContentViewShadowNode.cpp +43 -0
  17. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetContentViewShadowNode.h +28 -0
  18. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetContentViewState.cpp +11 -0
  19. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetContentViewState.h +37 -0
  20. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetFooterViewComponentDescriptor.h +24 -0
  21. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetFooterViewShadowNode.cpp +56 -0
  22. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetFooterViewShadowNode.h +28 -0
  23. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetFooterViewState.cpp +11 -0
  24. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetFooterViewState.h +41 -0
  25. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetInsets.cpp +18 -0
  26. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetInsets.h +16 -0
  27. package/ios/TrueSheetContainerView.h +12 -10
  28. package/ios/TrueSheetContainerView.mm +11 -29
  29. package/ios/TrueSheetContentView.h +22 -7
  30. package/ios/TrueSheetContentView.mm +143 -41
  31. package/ios/TrueSheetFooterView.h +12 -2
  32. package/ios/TrueSheetFooterView.mm +74 -71
  33. package/ios/TrueSheetHeaderView.mm +0 -1
  34. package/ios/TrueSheetView.mm +60 -27
  35. package/ios/TrueSheetViewController.h +9 -0
  36. package/ios/TrueSheetViewController.mm +288 -122
  37. package/ios/core/TrueSheetDetentCalculator.h +9 -0
  38. package/ios/core/TrueSheetDetentCalculator.mm +13 -4
  39. package/ios/tvos/TrueSheetStubs.mm +2 -0
  40. package/lib/module/TrueSheet.js +32 -13
  41. package/lib/module/TrueSheet.js.map +1 -1
  42. package/lib/module/TrueSheet.web.js +262 -38
  43. package/lib/module/TrueSheet.web.js.map +1 -1
  44. package/lib/module/fabric/TrueSheetContentViewNativeComponent.ts +5 -1
  45. package/lib/module/fabric/TrueSheetFooterViewNativeComponent.ts +10 -2
  46. package/lib/module/fabric/TrueSheetViewNativeComponent.ts +8 -1
  47. package/lib/module/web/vaul/index.js +17 -6
  48. package/lib/module/web/vaul/index.js.map +1 -1
  49. package/lib/typescript/src/TrueSheet.d.ts.map +1 -1
  50. package/lib/typescript/src/TrueSheet.types.d.ts +35 -11
  51. package/lib/typescript/src/TrueSheet.types.d.ts.map +1 -1
  52. package/lib/typescript/src/TrueSheet.web.d.ts.map +1 -1
  53. package/lib/typescript/src/fabric/TrueSheetContentViewNativeComponent.d.ts.map +1 -1
  54. package/lib/typescript/src/fabric/TrueSheetFooterViewNativeComponent.d.ts +2 -0
  55. package/lib/typescript/src/fabric/TrueSheetFooterViewNativeComponent.d.ts.map +1 -1
  56. package/lib/typescript/src/fabric/TrueSheetViewNativeComponent.d.ts +5 -1
  57. package/lib/typescript/src/fabric/TrueSheetViewNativeComponent.d.ts.map +1 -1
  58. package/lib/typescript/src/navigation/types.d.ts +1 -1
  59. package/lib/typescript/src/navigation/types.d.ts.map +1 -1
  60. package/lib/typescript/src/web/vaul/index.d.ts +8 -1
  61. package/lib/typescript/src/web/vaul/index.d.ts.map +1 -1
  62. package/package.json +10 -10
  63. package/react-native.config.js +5 -1
  64. package/src/TrueSheet.tsx +45 -14
  65. package/src/TrueSheet.types.ts +38 -12
  66. package/src/TrueSheet.web.tsx +314 -44
  67. package/src/fabric/TrueSheetContentViewNativeComponent.ts +5 -1
  68. package/src/fabric/TrueSheetFooterViewNativeComponent.ts +10 -2
  69. package/src/fabric/TrueSheetViewNativeComponent.ts +8 -1
  70. package/src/navigation/types.ts +3 -1
  71. package/src/web/vaul/index.tsx +23 -5
  72. package/ios/utils/LayoutUtil.h +0 -77
  73. package/ios/utils/LayoutUtil.mm +0 -106
@@ -40,11 +40,12 @@ static char TrueSheetAccessibilityWindowPreviousElementsKey;
40
40
  @interface TrueSheetViewController ()
41
41
 
42
42
  - (UIViewController *)accessibilityPresentingViewController;
43
+ - (void)setupTransitionTracker;
44
+ - (void)settleAtDetentIndex:(NSInteger)index debug:(NSString *)debug;
43
45
  - (void)restoreWindowAccessibilityElements;
44
46
  - (void)setSheetAccessibilityElementsHidden:(BOOL)hidden;
45
47
  - (void)setAccessibilityContentElement:(UIView *)contentView;
46
48
  - (void)endInteractiveDismissState;
47
- - (void)emitInteractivePosition;
48
49
  - (void)animateInteractiveContainerToTransform:(CGAffineTransform)transform
49
50
  duration:(NSTimeInterval)duration
50
51
  allowUserInteraction:(BOOL)allowUserInteraction
@@ -54,24 +55,26 @@ static char TrueSheetAccessibilityWindowPreviousElementsKey;
54
55
 
55
56
  @implementation TrueSheetViewController {
56
57
  TrueSheetPositionState _lastEmittedPositionState;
57
- CGFloat _lastWidth;
58
+ CGSize _lastReportedSize;
59
+ CGFloat _autoDetentHeight;
60
+ CGFloat _peekDetentHeight;
58
61
  NSInteger _pendingDetentIndex;
62
+
63
+ // Present/dismiss tracking: a display link scoped to the transition
64
+ // coordinator — started alongside the transition, stopped in its completion.
65
+ CADisplayLink *_transitionLink;
66
+ BOOL _isTransitioning;
67
+
59
68
  BOOL _pendingContentSizeChange;
60
69
  BOOL _pendingDetentsChange;
61
-
62
- CADisplayLink *_transitioningTimer;
63
- UIView *_transitionFakeView;
64
70
  BOOL _isDragging;
65
- BOOL _isTransitioning;
66
- BOOL _isTransitionSnapping;
67
- BOOL _isTrackingPositionFromLayout;
68
71
  BOOL _isWillDismissEmitted;
69
72
 
70
73
  BOOL _isInteractiveDismiss;
71
74
  CGFloat _interactiveStartPosition;
72
75
  UIView *_interactiveContainerView;
73
- CADisplayLink *_interactivePositionLink;
74
76
  NSUInteger _interactiveGeneration;
77
+ CADisplayLink *_interactivePositionLink;
75
78
 
76
79
  __weak TrueSheetViewController *_parentSheetController;
77
80
 
@@ -103,15 +106,13 @@ static char TrueSheetAccessibilityWindowPreviousElementsKey;
103
106
  _lastEmittedPositionState = (TrueSheetPositionState){0, 0, 0};
104
107
  _isDragging = NO;
105
108
  _isPresented = NO;
106
- _isTransitioning = NO;
107
109
  _isWillDismissEmitted = NO;
110
+ _isTransitioning = NO;
108
111
  _pendingContentSizeChange = NO;
112
+ _pendingDetentsChange = NO;
109
113
  _activeDetentIndex = -1;
110
114
  _pendingDetentIndex = -1;
111
115
 
112
- _transitionFakeView = [UIView new];
113
- _isTrackingPositionFromLayout = NO;
114
-
115
116
  _blurInteraction = YES;
116
117
  _insetAdjustment = TrueSheetViewInsetAdjustment::Automatic;
117
118
  _detentCalculator = [[TrueSheetDetentCalculator alloc] init];
@@ -122,8 +123,8 @@ static char TrueSheetAccessibilityWindowPreviousElementsKey;
122
123
 
123
124
  - (void)dealloc {
124
125
  [self restoreWindowAccessibilityElements];
125
- [_transitioningTimer invalidate];
126
- _transitioningTimer = nil;
126
+ [_transitionLink invalidate];
127
+ _transitionLink = nil;
127
128
  [_interactivePositionLink invalidate];
128
129
  _interactivePositionLink = nil;
129
130
  [[NSNotificationCenter defaultCenter] removeObserver:self];
@@ -142,6 +143,13 @@ static char TrueSheetAccessibilityWindowPreviousElementsKey;
142
143
  return self.presentedViewController == nil;
143
144
  }
144
145
 
146
+ // YES while a child controller is stacked on top (e.g. a nested sheet) —
147
+ // UIKit's push-back scaling mutates the frame while the sheet is backgrounded.
148
+ - (BOOL)isStackedBehindChild {
149
+ UIViewController *presented = self.presentedViewController;
150
+ return presented != nil && !presented.isBeingDismissed;
151
+ }
152
+
145
153
  - (UIView *)presentedView {
146
154
  return self.sheet.presentedView;
147
155
  }
@@ -151,6 +159,48 @@ static char TrueSheetAccessibilityWindowPreviousElementsKey;
151
159
  return presentedView ? presentedView.frame.origin.y : 0.0;
152
160
  }
153
161
 
162
+ // YES when any layer from the presented view up to the window has in-flight
163
+ // animations (detent snap, present/dismiss transition, container settle).
164
+ - (BOOL)isPresentedViewAnimating {
165
+ for (CALayer *layer = self.presentedView.layer; layer; layer = layer.superlayer) {
166
+ if (layer.animationKeys.count > 0) {
167
+ return YES;
168
+ }
169
+ }
170
+ return NO;
171
+ }
172
+
173
+ /**
174
+ * The sheet's live on-screen position. At rest and during drags (direct frame
175
+ * sets) this is the model position; during animations it adds the
176
+ * presentation-vs-model delta measured in window space, so ancestor animations
177
+ * compose and persistent transforms (e.g. floating-sheet inset) cancel out.
178
+ */
179
+ - (CGFloat)livePosition {
180
+ UIView *presentedView = self.presentedView;
181
+ UIWindow *window = presentedView.window;
182
+ UIView *containerView = self.sheet.containerView;
183
+
184
+ // Direct (non-animated) translation applied during an interactive nav dismiss.
185
+ CGFloat containerTy = containerView ? containerView.transform.ty : 0;
186
+
187
+ if (!presentedView || !window || !self.isPresentedViewAnimating) {
188
+ return self.currentPosition + containerTy;
189
+ }
190
+
191
+ CALayer *modelLayer = presentedView.layer;
192
+ CALayer *presentationLayer = modelLayer.presentationLayer;
193
+ CALayer *rootModel = window.layer;
194
+ CALayer *rootPresentation = rootModel.presentationLayer;
195
+ if (!presentationLayer || !rootPresentation) {
196
+ return self.currentPosition + containerTy;
197
+ }
198
+
199
+ CGFloat modelY = [modelLayer convertPoint:CGPointZero toLayer:rootModel].y;
200
+ CGFloat presentationY = [presentationLayer convertPoint:CGPointZero toLayer:rootPresentation].y;
201
+ return self.currentPosition + (presentationY - modelY) + containerTy;
202
+ }
203
+
154
204
  - (CGFloat)screenHeight {
155
205
  UIWindow *window = self.view.window;
156
206
  return window ? window.bounds.size.height : UIScreen.mainScreen.bounds.size.height;
@@ -161,6 +211,10 @@ static char TrueSheetAccessibilityWindowPreviousElementsKey;
161
211
  return 0;
162
212
  }
163
213
 
214
+ return [self bottomSafeAreaForHeight:height];
215
+ }
216
+
217
+ - (CGFloat)bottomSafeAreaForHeight:(CGFloat)height {
164
218
  if (UIDevice.currentDevice.userInterfaceIdiom != UIUserInterfaceIdiomPhone) {
165
219
  return 0;
166
220
  }
@@ -177,6 +231,52 @@ static char TrueSheetAccessibilityWindowPreviousElementsKey;
177
231
  return window ? window.safeAreaInsets.bottom : 0;
178
232
  }
179
233
 
234
+ /**
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.
238
+ */
239
+ - (CGFloat)autoDetentBottomInsetForHeight:(CGFloat)height {
240
+ if (_insetAdjustment != TrueSheetViewInsetAdjustment::Automatic) {
241
+ // 'none' already excludes the inset for every detent
242
+ return 0;
243
+ }
244
+
245
+ if (_absoluteFooter || [self.footerHeight floatValue] <= 0) {
246
+ return 0;
247
+ }
248
+
249
+ return [self bottomSafeAreaForHeight:height];
250
+ }
251
+
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.
254
+ - (CGFloat)footerBottomInset {
255
+ if (_insetAdjustment != TrueSheetViewInsetAdjustment::Automatic) {
256
+ return 0;
257
+ }
258
+
259
+ return [self bottomSafeAreaForHeight:_autoDetentHeight > 0 ? _autoDetentHeight : self.screenHeight];
260
+ }
261
+
262
+ /**
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.
267
+ */
268
+ - (CGFloat)peekDetentBottomInsetForHeight:(CGFloat)height {
269
+ if (_insetAdjustment != TrueSheetViewInsetAdjustment::Automatic) {
270
+ return 0;
271
+ }
272
+
273
+ if (!_absoluteFooter || [self.footerHeight floatValue] <= 0) {
274
+ return 0;
275
+ }
276
+
277
+ return [self bottomSafeAreaForHeight:height];
278
+ }
279
+
180
280
  - (BOOL)isDesignCompatibilityMode {
181
281
  if (@available(iOS 26.0, *)) {
182
282
  NSNumber *value = [[NSBundle mainBundle] objectForInfoDictionaryKey:@"UIDesignRequiresCompatibility"];
@@ -439,8 +539,8 @@ static char TrueSheetAccessibilityWindowPreviousElementsKey;
439
539
  [self restoreWindowAccessibilityElements];
440
540
  [self setSheetAccessibilityElementsHidden:YES];
441
541
 
442
- // Dispatch to allow pan gesture to set _isDragging before checking
443
- // handleTransitionTracker will emit when sheet is transitioning to dismiss
542
+ // Dispatch to allow pan gesture to set _isDragging before checking;
543
+ // the transition tracker emits when the sheet is transitioning to dismiss
444
544
  dispatch_async(dispatch_get_main_queue(), ^{
445
545
  if (!self->_isDragging) {
446
546
  [self emitWillDismissEvents];
@@ -470,54 +570,63 @@ static char TrueSheetAccessibilityWindowPreviousElementsKey;
470
570
  - (void)viewWillLayoutSubviews {
471
571
  [super viewWillLayoutSubviews];
472
572
 
473
- // Skip during an interactive nav dismiss; emitInteractivePosition owns position then,
474
- // and currentPosition (presentedView frame) stays at rest since we move the container.
475
- if (!_isTransitioning && !_isInteractiveDismiss) {
476
- _isTrackingPositionFromLayout = YES;
573
+ // Skip during transitions and nav swipes their trackers own position then.
574
+ if (_isTransitioning || _isInteractiveDismiss) {
575
+ return;
576
+ }
477
577
 
478
- if (_pendingContentSizeChange || _pendingDetentsChange) {
479
- _pendingContentSizeChange = NO;
480
- _pendingDetentsChange = NO;
481
- [self settleAtDetentIndex:self.currentDetentIndex debug:@"layout"];
482
- } else {
483
- UIViewController *presented = self.presentedViewController;
484
- BOOL hasPresentedController = presented != nil && !presented.isBeingPresented && !presented.isBeingDismissed;
485
-
486
- // A layout pass at rest can nudge the frame by a sub-pixel amount
487
- // (pixel-grid snapping after present), leaving the interpolated index
488
- // slightly off the whole number. Re-learn when the frame is effectively
489
- // at the current detent so the drift is absorbed instead of emitted.
490
- // The tight threshold keeps real movement from being absorbed.
491
- if (presented == nil && !_isDragging) {
492
- NSInteger index = self.currentDetentIndex;
493
- CGFloat expectedHeight = [_detentCalculator resolvedHeightForIndex:index];
494
- CGFloat actualHeight = self.screenHeight - self.currentPosition;
495
- if (expectedHeight > 0 && fabs(actualHeight - expectedHeight) < 2) {
496
- [self learnOffsetForDetentIndex:index];
497
- }
578
+ if (_pendingContentSizeChange || _pendingDetentsChange) {
579
+ _pendingContentSizeChange = NO;
580
+ _pendingDetentsChange = NO;
581
+ [self settleAtDetentIndex:self.currentDetentIndex debug:@"layout"];
582
+ } else if (!_isDragging) {
583
+ // Drags emit from the pan handler. A child on top moves this sheet two
584
+ // ways: an animated collapse/restore step (presenting/dismissing) emit
585
+ // the target non-realtime so JS animates to it — or direct per-frame
586
+ // re-layouts while the child drags, which stay realtime.
587
+ BOOL realtime = self.presentedViewController == nil || !self.isPresentedViewAnimating;
588
+
589
+ // A layout pass at rest can nudge the frame by a sub-pixel amount
590
+ // (pixel-grid snapping after present), leaving the interpolated index
591
+ // slightly off the whole number. Re-learn when the frame is effectively
592
+ // at the current detent so the drift is absorbed instead of emitted.
593
+ // The tight threshold keeps real movement (e.g. scroll-driven expansion)
594
+ // from being absorbed.
595
+ if (self.presentedViewController == nil && !self.isPresentedViewAnimating) {
596
+ NSInteger index = self.currentDetentIndex;
597
+ CGFloat expectedHeight = [_detentCalculator resolvedHeightForIndex:index];
598
+ CGFloat actualHeight = self.screenHeight - self.currentPosition;
599
+ if (expectedHeight > 0 && fabs(actualHeight - expectedHeight) < 2) {
600
+ [self learnOffsetForDetentIndex:index];
498
601
  }
499
-
500
- [self emitChangePositionDelegateWithPosition:self.currentPosition
501
- realtime:!hasPresentedController
502
- debug:@"layout"];
503
602
  }
603
+
604
+ [self emitChangePositionDelegateWithPosition:self.currentPosition realtime:realtime debug:@"layout"];
504
605
  }
505
606
  }
506
607
 
507
608
  - (void)viewDidLayoutSubviews {
508
609
  [super viewDidLayoutSubviews];
509
610
 
510
- // Update state on rotation (width change)
511
- CGFloat width = self.view.frame.size.width;
512
- if (_lastWidth != width) {
513
- _lastWidth = width;
514
- [self.delegate viewControllerDidChangeSize:self.view.frame.size];
611
+ // Skip size reports while backgrounded behind a stacked child — the push-back
612
+ // scaling changes the frame and would needlessly resize content.
613
+ // _lastReportedSize stays stale so the restore pass re-reports any real change.
614
+ if (!self.isStackedBehindChild) {
615
+ // Report any size change (detent resize, keyboard, rotation) so Yoga
616
+ // relayouts the container synchronously with the sheet.
617
+ CGSize size = self.view.frame.size;
618
+ if (!CGSizeEqualToSize(_lastReportedSize, size)) {
619
+ _lastReportedSize = size;
620
+ [self.delegate viewControllerDidChangeSize:size];
621
+ }
515
622
  }
516
623
 
517
624
  if (_pendingDetentIndex >= 0) {
518
625
  NSInteger pendingIndex = _pendingDetentIndex;
519
626
  _pendingDetentIndex = -1;
520
627
 
628
+ // The presentedView frame isn't final until UIKit finishes the resize
629
+ // animation — no completion hook exists for it, hence the delay.
521
630
  dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.2 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
522
631
  CGFloat detent = [self detentValueForIndex:pendingIndex];
523
632
  [self.delegate viewControllerDidChangeDetent:pendingIndex position:self.currentPosition detent:detent];
@@ -525,8 +634,6 @@ static char TrueSheetAccessibilityWindowPreviousElementsKey;
525
634
  [self settleAtDetentIndex:pendingIndex debug:@"pending detent change"];
526
635
  });
527
636
  }
528
-
529
- _isTrackingPositionFromLayout = NO;
530
637
  }
531
638
 
532
639
  #pragma mark - Position & Gesture Handling
@@ -588,17 +695,17 @@ static char TrueSheetAccessibilityWindowPreviousElementsKey;
588
695
  _isDragging = YES;
589
696
  break;
590
697
  case UIGestureRecognizerStateChanged:
591
- if (!_isTrackingPositionFromLayout) {
592
- [self emitChangePositionDelegateWithPosition:self.currentPosition realtime:YES debug:@"drag change"];
593
- }
698
+ [self emitChangePositionDelegateWithPosition:self.currentPosition realtime:YES debug:@"drag change"];
594
699
  break;
595
700
  case UIGestureRecognizerStateEnded:
596
701
  case UIGestureRecognizerStateCancelled: {
597
702
  if (!_isTransitioning) {
703
+ // Dispatch so UIKit picks the target detent first; the settle emit is
704
+ // non-realtime and JS animates alongside UIKit's snap spring.
598
705
  dispatch_async(dispatch_get_main_queue(), ^{
599
- NSInteger index = self.currentDetentIndex;
600
- [self->_grabberView updateAccessibilityValueWithIndex:index detentCount:self->_detents.count];
601
- [self settleAtDetentIndex:index debug:@"drag end"];
706
+ NSInteger endIndex = self.currentDetentIndex;
707
+ [self->_grabberView updateAccessibilityValueWithIndex:endIndex detentCount:self->_detents.count];
708
+ [self settleAtDetentIndex:endIndex debug:@"drag end"];
602
709
  });
603
710
  }
604
711
 
@@ -610,9 +717,22 @@ static char TrueSheetAccessibilityWindowPreviousElementsKey;
610
717
  }
611
718
  }
612
719
 
720
+ #pragma mark - Position Tracking
721
+
722
+ /**
723
+ * Tracks the sheet's live position through a present/dismiss transition. The
724
+ * link's lifetime is scoped to the transition coordinator: started alongside
725
+ * the transition, stopped in its completion — which fires for finished and
726
+ * cancelled (interactive) transitions alike.
727
+ */
613
728
  - (void)setupTransitionTracker {
614
- if (!self.transitionCoordinator)
729
+ id<UIViewControllerTransitionCoordinator> coordinator = self.transitionCoordinator;
730
+
731
+ // A cancelled dismiss re-enters viewWillAppear with the same coordinator —
732
+ // the tracker from viewWillDisappear still owns it.
733
+ if (!coordinator || _isTransitioning) {
615
734
  return;
735
+ }
616
736
 
617
737
  _isTransitioning = YES;
618
738
 
@@ -623,43 +743,35 @@ static char TrueSheetAccessibilityWindowPreviousElementsKey;
623
743
  [self learnOffsetForDetentIndex:self.currentDetentIndex];
624
744
  }
625
745
 
626
- CGRect dismissedFrame = CGRectMake(0, self.screenHeight, 0, 0);
627
- CGRect presentedFrame = CGRectMake(0, self.currentPosition, 0, 0);
628
-
629
- _transitionFakeView.frame = self.isBeingDismissed ? presentedFrame : dismissedFrame;
630
-
631
746
  __weak __typeof(self) weakSelf = self;
632
-
633
- [self.transitionCoordinator
747
+ [coordinator
634
748
  animateAlongsideTransition:^(id<UIViewControllerTransitionCoordinatorContext> _Nonnull context) {
635
749
  __strong __typeof(weakSelf) strongSelf = weakSelf;
636
750
  if (!strongSelf)
637
751
  return;
638
752
 
639
- [[context containerView] addSubview:strongSelf->_transitionFakeView];
640
- strongSelf->_transitionFakeView.frame = strongSelf.isBeingDismissed ? dismissedFrame : presentedFrame;
641
-
642
- strongSelf->_transitioningTimer = [CADisplayLink displayLinkWithTarget:strongSelf
643
- selector:@selector(handleTransitionTracker)];
644
- [strongSelf->_transitioningTimer addToRunLoop:[NSRunLoop currentRunLoop] forMode:NSDefaultRunLoopMode];
753
+ [strongSelf->_transitionLink invalidate];
754
+ strongSelf->_transitionLink = [CADisplayLink displayLinkWithTarget:strongSelf
755
+ selector:@selector(handleTransitionTick)];
756
+ // Track at the display's native refresh rate — the default caps at 60Hz
757
+ // on ProMotion, dropping every other frame of a 120Hz animation.
758
+ strongSelf->_transitionLink.preferredFrameRateRange = CAFrameRateRangeMake(60, 120, 120);
759
+ [strongSelf->_transitionLink addToRunLoop:[NSRunLoop mainRunLoop] forMode:NSRunLoopCommonModes];
645
760
  }
646
761
  completion:^(id<UIViewControllerTransitionCoordinatorContext> _Nonnull context) {
647
762
  __strong __typeof(weakSelf) strongSelf = weakSelf;
648
763
  if (!strongSelf)
649
764
  return;
650
765
 
651
- [strongSelf->_transitioningTimer setPaused:YES];
652
- [strongSelf->_transitioningTimer invalidate];
653
- strongSelf->_transitioningTimer = nil;
654
- [strongSelf->_transitionFakeView removeFromSuperview];
766
+ [strongSelf->_transitionLink invalidate];
767
+ strongSelf->_transitionLink = nil;
655
768
  strongSelf->_isTransitioning = NO;
656
- strongSelf->_isTransitionSnapping = NO;
657
769
 
658
- // Settle after a present or detent-snap transition.
659
- // Uses dispatch_after because presentedView frame isn't final until UIKit
770
+ // Settle after a present, cancelled dismiss, or detent-snap transition.
771
+ // Delayed because the presentedView frame isn't final until UIKit
660
772
  // completes its layout pass after the transition animation — learning
661
773
  // here absorbs any sub-pixel drift since the earlier learns.
662
- if (strongSelf->_isPresented) {
774
+ if (strongSelf->_isPresented && !strongSelf.isBeingDismissed) {
663
775
  dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.1 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
664
776
  [strongSelf settleAtDetentIndex:strongSelf.currentDetentIndex debug:@"transition end"];
665
777
  });
@@ -667,34 +779,43 @@ static char TrueSheetAccessibilityWindowPreviousElementsKey;
667
779
  }];
668
780
  }
669
781
 
670
- - (void)handleTransitionTracker {
671
- if (!_isDragging && _transitionFakeView.layer) {
672
- CALayer *layer = _transitionFakeView.layer;
673
- CGFloat layerPosition = layer.presentationLayer.frame.origin.y;
782
+ - (void)handleTransitionTick {
783
+ // Interactive dismiss phase (finger down) — the pan handler emits.
784
+ if (!_isDragging) {
785
+ CGFloat position = self.livePosition;
674
786
 
675
- if (self.currentPosition >= self.screenHeight) {
676
- CGFloat position = fmax(_lastEmittedPositionState.position, layerPosition);
787
+ if (self.isBeingDismissed) {
788
+ // Emit only once the dismiss is committed: on release UIKit moves the
789
+ // model frame off-screen, while a cancelled drag rewinds it back to the
790
+ // detent (isBeingDismissed stays YES during the rewind).
791
+ if (self.currentPosition >= self.screenHeight) {
792
+ [self emitWillDismissEvents];
793
+ }
677
794
 
678
795
  // Hide blur at the end of dismiss to prevent UIVisualEffectView
679
796
  // from causing a flicker/flash at the bottom edge of the sheet.
680
797
  if (self.screenHeight - position < 1) {
681
798
  _blurView.alpha = 0;
682
799
  }
800
+ }
683
801
 
684
- [self emitWillDismissEvents];
685
- [self emitChangePositionDelegateWithPosition:position realtime:YES debug:@"transition out"];
802
+ [self emitChangePositionDelegateWithPosition:position realtime:YES debug:@"transition"];
803
+ }
804
+ }
686
805
 
687
- } else {
688
- CGFloat position = fmax(self.currentPosition, layerPosition);
689
- // Detect drag snap transition jump; stay non-realtime for the rest of the animation
690
- if (!_isTransitionSnapping && _isPresented && _lastEmittedPositionState.position > 0 &&
691
- fabs(_lastEmittedPositionState.position - position) > 20) {
692
- _isTransitionSnapping = YES;
693
- }
694
- BOOL realtime = !_isTransitionSnapping;
695
- [self emitChangePositionDelegateWithPosition:position realtime:realtime debug:@"transition in"];
696
- }
806
+ /**
807
+ * The sheet is at rest at a detent: learn the resolver-vs-actual offset and
808
+ * emit the settled position. Only valid when the presentedView frame is final
809
+ * never mid-drag or mid-animation, where learning would store a bogus offset.
810
+ */
811
+ - (void)settleAtDetentIndex:(NSInteger)index debug:(NSString *)debug {
812
+ // Don't learn while a child sits on top — the deck transform skews the
813
+ // measured frame; the next unobstructed settle learns.
814
+ if (self.presentedViewController == nil) {
815
+ [self learnOffsetForDetentIndex:index];
697
816
  }
817
+
818
+ [self emitChangePositionDelegateWithPosition:self.currentPosition realtime:NO debug:debug];
698
819
  }
699
820
 
700
821
  #pragma mark - Interactive Navigation Dismiss
@@ -716,6 +837,7 @@ static char TrueSheetAccessibilityWindowPreviousElementsKey;
716
837
  // Emit position from the container's presentation layer for the whole gesture, so
717
838
  // both the direct-set drag and the settle animation report a live, smooth position.
718
839
  _interactivePositionLink = [CADisplayLink displayLinkWithTarget:self selector:@selector(emitInteractivePosition)];
840
+ _interactivePositionLink.preferredFrameRateRange = CAFrameRateRangeMake(60, 120, 120);
719
841
  [_interactivePositionLink addToRunLoop:[NSRunLoop mainRunLoop] forMode:NSRunLoopCommonModes];
720
842
  }
721
843
 
@@ -849,16 +971,6 @@ static char TrueSheetAccessibilityWindowPreviousElementsKey;
849
971
  }
850
972
  }
851
973
 
852
- /**
853
- * Learn the resolver-vs-actual offset at a settled detent, then emit the corrected
854
- * position. Only valid when presentedView's frame is final — never mid-drag or
855
- * mid-animation, where learning would store a bogus offset.
856
- */
857
- - (void)settleAtDetentIndex:(NSInteger)index debug:(NSString *)debug {
858
- [self learnOffsetForDetentIndex:index];
859
- [self emitChangePositionDelegateWithPosition:self.currentPosition realtime:NO debug:debug];
860
- }
861
-
862
974
  - (BOOL)findSegmentForPosition:(CGFloat)position outIndex:(NSInteger *)outIndex outProgress:(CGFloat *)outProgress {
863
975
  return [_detentCalculator findSegmentForPosition:position outIndex:outIndex outProgress:outProgress];
864
976
  }
@@ -877,11 +989,31 @@ static char TrueSheetAccessibilityWindowPreviousElementsKey;
877
989
 
878
990
  #pragma mark - Sheet Configuration
879
991
 
992
+ /**
993
+ * Applies a content/header/footer/peek size change to the already-built
994
+ * detents. Auto and peek detents resolve lazily from snapshots, so this only
995
+ * refreshes the snapshots and invalidates — never reassigns `sheet.detents`,
996
+ * which would force UIKit to re-layout the whole presentation stack (visibly
997
+ * perturbing a sheet behind this one).
998
+ */
880
999
  - (void)setupSheetDetentsForSizeChange {
881
- [self.sheet animateChanges:^{
882
- _pendingContentSizeChange = YES;
883
- [self setupSheetDetents];
884
- }];
1000
+ if (@available(iOS 16.0, *)) {
1001
+ CGFloat autoHeight = [_detentCalculator autoHeight];
1002
+ CGFloat peekHeight = [_detentCalculator peekHeight];
1003
+
1004
+ if (fabs(autoHeight - _autoDetentHeight) < 0.5 && fabs(peekHeight - _peekDetentHeight) < 0.5) {
1005
+ return;
1006
+ }
1007
+
1008
+ _autoDetentHeight = autoHeight;
1009
+ _peekDetentHeight = peekHeight;
1010
+
1011
+ [self.sheet animateChanges:^{
1012
+ self->_pendingContentSizeChange = YES;
1013
+ [self.sheet invalidateDetents];
1014
+ }];
1015
+ }
1016
+ // iOS 15: detents are fixed medium/large — size changes can't affect them.
885
1017
  }
886
1018
 
887
1019
  - (void)setupSheetDetentsForDetentsChange {
@@ -899,13 +1031,12 @@ static char TrueSheetAccessibilityWindowPreviousElementsKey;
899
1031
  NSMutableArray<UISheetPresentationControllerDetent *> *detents = [NSMutableArray array];
900
1032
  [_detentCalculator clearResolvedHeights];
901
1033
 
902
- CGFloat autoHeight = [self.contentHeight floatValue] + [self.headerHeight floatValue];
1034
+ _autoDetentHeight = [_detentCalculator autoHeight];
1035
+ _peekDetentHeight = [_detentCalculator peekHeight];
903
1036
 
904
1037
  for (NSInteger index = 0; index < self.detents.count; index++) {
905
1038
  id detent = self.detents[index];
906
- UISheetPresentationControllerDetent *sheetDetent = [self detentForValue:detent
907
- withAutoHeight:autoHeight
908
- atIndex:index];
1039
+ UISheetPresentationControllerDetent *sheetDetent = [self detentForValue:detent atIndex:index];
909
1040
  [detents addObject:sheetDetent];
910
1041
  }
911
1042
 
@@ -932,18 +1063,25 @@ static char TrueSheetAccessibilityWindowPreviousElementsKey;
932
1063
  }
933
1064
  }
934
1065
 
935
- - (UISheetPresentationControllerDetent *)detentForValue:(id)detent
936
- withAutoHeight:(CGFloat)autoHeight
937
- atIndex:(NSInteger)index {
1066
+ - (UISheetPresentationControllerDetent *)detentForValue:(id)detent atIndex:(NSInteger)index {
938
1067
  if (![detent isKindOfClass:[NSNumber class]]) {
939
1068
  return [UISheetPresentationControllerDetent mediumDetent];
940
1069
  }
941
1070
 
942
1071
  CGFloat value = [detent doubleValue];
943
1072
 
1073
+ // Auto/peek resolve from height snapshots — refreshed only in
1074
+ // setupSheetDetents and setupSheetDetentsForSizeChange — so size changes are
1075
+ // picked up on invalidation, while UIKit's spontaneous re-resolutions (e.g.
1076
+ // while a child sheet dismisses) read a stable value.
944
1077
  if (value == -1) {
945
1078
  if (@available(iOS 16.0, *)) {
946
- return [self customDetentWithIdentifier:@"custom-auto" height:autoHeight atIndex:index];
1079
+ return [self customDetentWithIdentifier:@"custom-auto"
1080
+ atIndex:index
1081
+ heightBlock:^CGFloat {
1082
+ CGFloat height = self->_autoDetentHeight;
1083
+ return height - [self autoDetentBottomInsetForHeight:height];
1084
+ }];
947
1085
  } else {
948
1086
  return [UISheetPresentationControllerDetent mediumDetent];
949
1087
  }
@@ -951,7 +1089,12 @@ static char TrueSheetAccessibilityWindowPreviousElementsKey;
951
1089
 
952
1090
  if (value == -2) {
953
1091
  if (@available(iOS 16.0, *)) {
954
- return [self customDetentWithIdentifier:@"custom-peek" height:[_detentCalculator peekHeight] atIndex:index];
1092
+ return [self customDetentWithIdentifier:@"custom-peek"
1093
+ atIndex:index
1094
+ heightBlock:^CGFloat {
1095
+ CGFloat height = self->_peekDetentHeight;
1096
+ return height - [self peekDetentBottomInsetForHeight:height];
1097
+ }];
955
1098
  } else {
956
1099
  return [UISheetPresentationControllerDetent mediumDetent];
957
1100
  }
@@ -976,17 +1119,40 @@ static char TrueSheetAccessibilityWindowPreviousElementsKey;
976
1119
  - (UISheetPresentationControllerDetent *)customDetentWithIdentifier:(NSString *)identifier
977
1120
  height:(CGFloat)height
978
1121
  atIndex:(NSInteger)index API_AVAILABLE(ios(16.0)) {
979
- CGFloat bottomAdjustment = [self detentBottomAdjustmentForHeight:height];
1122
+ return [self customDetentWithIdentifier:identifier
1123
+ atIndex:index
1124
+ heightBlock:^CGFloat {
1125
+ return height;
1126
+ }];
1127
+ }
1128
+
1129
+ - (UISheetPresentationControllerDetent *)customDetentWithIdentifier:(NSString *)identifier
1130
+ atIndex:(NSInteger)index
1131
+ heightBlock:(CGFloat (^)(void))heightBlock
1132
+ API_AVAILABLE(ios(16.0)) {
980
1133
  return [UISheetPresentationControllerDetent
981
1134
  customDetentWithIdentifier:identifier
982
1135
  resolver:^CGFloat(id<UISheetPresentationControllerDetentResolutionContext> context) {
983
1136
  CGFloat maxDetentValue = context.maximumDetentValue;
984
1137
  self->_detentCalculator.maxDetentHeight = maxDetentValue;
985
1138
 
1139
+ CGFloat height = heightBlock();
1140
+ CGFloat bottomAdjustment = [self detentBottomAdjustmentForHeight:height];
986
1141
  CGFloat maxValue = self.maxContentHeight
987
1142
  ? fmin(maxDetentValue, [self.maxContentHeight floatValue])
988
1143
  : maxDetentValue;
1144
+
989
1145
  CGFloat adjustedHeight = height - bottomAdjustment;
1146
+
1147
+ // Only the last detent may occupy the full maximum. Two detents
1148
+ // resolving to the same height (e.g. a capped auto and 1) make
1149
+ // UIKit skip the stack's deck animation when presenting a child
1150
+ // sheet and snap the sheet behind into place instead.
1151
+ NSInteger lastIndex = (NSInteger)self.detents.count - 1;
1152
+ if (adjustedHeight >= maxValue && index < lastIndex) {
1153
+ maxValue -= lastIndex - index;
1154
+ }
1155
+
990
1156
  CGFloat resolved = fmin(adjustedHeight, maxValue);
991
1157
 
992
1158
  NSMutableArray *heights = self->_detentCalculator.resolvedDetentHeights;