@lodev09/react-native-true-sheet 4.0.0-beta.24 → 4.0.0-beta.26

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 (88) hide show
  1. package/README.md +2 -0
  2. package/android/src/main/java/com/lodev09/truesheet/TrueSheetModule.kt +13 -0
  3. package/android/src/main/java/com/lodev09/truesheet/TrueSheetOverlayView.kt +12 -1
  4. package/android/src/main/java/com/lodev09/truesheet/TrueSheetView.kt +120 -19
  5. package/android/src/main/java/com/lodev09/truesheet/TrueSheetViewController.kt +103 -27
  6. package/android/src/main/java/com/lodev09/truesheet/TrueSheetViewManager.kt +18 -10
  7. package/android/src/main/java/com/lodev09/truesheet/core/RNScreensEventObserver.kt +31 -14
  8. package/android/src/main/java/com/lodev09/truesheet/core/TrueSheetBottomSheetBehavior.kt +9 -0
  9. package/android/src/main/java/com/lodev09/truesheet/core/TrueSheetBottomSheetView.kt +3 -16
  10. package/android/src/main/java/com/lodev09/truesheet/core/TrueSheetCoordinatorLayout.kt +14 -1
  11. package/android/src/main/java/com/lodev09/truesheet/core/TrueSheetDimView.kt +11 -17
  12. package/android/src/main/java/com/lodev09/truesheet/core/TrueSheetGrabberView.kt +2 -4
  13. package/android/src/main/java/com/lodev09/truesheet/core/TrueSheetStackManager.kt +29 -38
  14. package/android/src/main/java/com/lodev09/truesheet/utils/ColorUtils.kt +19 -0
  15. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetContentViewShadowNode.cpp +6 -2
  16. package/ios/TrueSheetContainerView.h +1 -0
  17. package/ios/TrueSheetContainerView.mm +6 -0
  18. package/ios/TrueSheetContentView.h +1 -0
  19. package/ios/TrueSheetContentView.mm +2 -0
  20. package/ios/TrueSheetFooterView.mm +5 -0
  21. package/ios/TrueSheetModule.mm +11 -2
  22. package/ios/TrueSheetView.h +2 -0
  23. package/ios/TrueSheetView.mm +62 -9
  24. package/ios/TrueSheetViewController.h +14 -0
  25. package/ios/TrueSheetViewController.mm +490 -33
  26. package/ios/core/RNScreensEventObserver.mm +18 -1
  27. package/ios/core/TrueSheetDetentCalculator.h +11 -0
  28. package/ios/core/TrueSheetDetentCalculator.mm +17 -5
  29. package/ios/core/TrueSheetDimView.h +27 -0
  30. package/ios/core/TrueSheetDimView.mm +41 -0
  31. package/ios/core/TrueSheetKeyboardObserver.mm +98 -15
  32. package/lib/module/TrueSheet.js +88 -17
  33. package/lib/module/TrueSheet.js.map +1 -1
  34. package/lib/module/TrueSheet.web.js +150 -37
  35. package/lib/module/TrueSheet.web.js.map +1 -1
  36. package/lib/module/TrueSheetProvider.web.js +13 -9
  37. package/lib/module/TrueSheetProvider.web.js.map +1 -1
  38. package/lib/module/fabric/TrueSheetViewNativeComponent.ts +8 -0
  39. package/lib/module/scrollable.js +28 -0
  40. package/lib/module/scrollable.js.map +1 -0
  41. package/lib/module/web/keyboard.js +69 -0
  42. package/lib/module/web/keyboard.js.map +1 -0
  43. package/lib/module/web/layout.js +1 -1
  44. package/lib/module/web/layout.js.map +1 -1
  45. package/lib/module/web/vaul/context.js.map +1 -1
  46. package/lib/module/web/vaul/index.js +18 -8
  47. package/lib/module/web/vaul/index.js.map +1 -1
  48. package/lib/module/web/vaul/use-snap-points.js +15 -10
  49. package/lib/module/web/vaul/use-snap-points.js.map +1 -1
  50. package/lib/typescript/src/TrueSheet.d.ts +14 -1
  51. package/lib/typescript/src/TrueSheet.d.ts.map +1 -1
  52. package/lib/typescript/src/TrueSheet.types.d.ts +53 -2
  53. package/lib/typescript/src/TrueSheet.types.d.ts.map +1 -1
  54. package/lib/typescript/src/TrueSheet.web.d.ts.map +1 -1
  55. package/lib/typescript/src/TrueSheetProvider.web.d.ts +5 -1
  56. package/lib/typescript/src/TrueSheetProvider.web.d.ts.map +1 -1
  57. package/lib/typescript/src/fabric/TrueSheetViewNativeComponent.d.ts +7 -0
  58. package/lib/typescript/src/fabric/TrueSheetViewNativeComponent.d.ts.map +1 -1
  59. package/lib/typescript/src/navigation/types.d.ts +1 -1
  60. package/lib/typescript/src/navigation/types.d.ts.map +1 -1
  61. package/lib/typescript/src/scrollable.d.ts +2 -0
  62. package/lib/typescript/src/scrollable.d.ts.map +1 -0
  63. package/lib/typescript/src/web/keyboard.d.ts +15 -0
  64. package/lib/typescript/src/web/keyboard.d.ts.map +1 -0
  65. package/lib/typescript/src/web/layout.d.ts +1 -0
  66. package/lib/typescript/src/web/layout.d.ts.map +1 -1
  67. package/lib/typescript/src/web/vaul/context.d.ts +2 -1
  68. package/lib/typescript/src/web/vaul/context.d.ts.map +1 -1
  69. package/lib/typescript/src/web/vaul/index.d.ts +8 -1
  70. package/lib/typescript/src/web/vaul/index.d.ts.map +1 -1
  71. package/lib/typescript/src/web/vaul/types.d.ts +9 -0
  72. package/lib/typescript/src/web/vaul/types.d.ts.map +1 -1
  73. package/lib/typescript/src/web/vaul/use-snap-points.d.ts +3 -2
  74. package/lib/typescript/src/web/vaul/use-snap-points.d.ts.map +1 -1
  75. package/package.json +1 -1
  76. package/src/TrueSheet.tsx +91 -14
  77. package/src/TrueSheet.types.ts +60 -2
  78. package/src/TrueSheet.web.tsx +170 -41
  79. package/src/TrueSheetProvider.web.tsx +22 -13
  80. package/src/fabric/TrueSheetViewNativeComponent.ts +8 -0
  81. package/src/navigation/types.ts +3 -0
  82. package/src/scrollable.ts +25 -0
  83. package/src/web/keyboard.ts +79 -0
  84. package/src/web/layout.ts +1 -1
  85. package/src/web/vaul/context.ts +2 -1
  86. package/src/web/vaul/index.tsx +20 -5
  87. package/src/web/vaul/types.ts +10 -0
  88. package/src/web/vaul/use-snap-points.ts +19 -9
@@ -11,10 +11,12 @@
11
11
  #import "TrueSheetContentView.h"
12
12
  #import "core/TrueSheetBlurView.h"
13
13
  #import "core/TrueSheetDetentCalculator.h"
14
+ #import "core/TrueSheetDimView.h"
14
15
  #import "core/TrueSheetGrabberView.h"
15
16
  #import "utils/BlurUtil.h"
16
17
  #import "utils/GestureUtil.h"
17
18
  #import "utils/PlatformUtil.h"
19
+ #import "utils/UIView+FirstResponder.h"
18
20
  #import "utils/WindowUtil.h"
19
21
 
20
22
  #import <React/RCTLog.h>
@@ -47,6 +49,10 @@ static char TrueSheetAccessibilityWindowPreviousElementsKey;
47
49
  - (void)setAccessibilityContentElement:(UIView *)contentView;
48
50
  - (NSArray *)accessibilityGrabberElements;
49
51
  - (void)endInteractiveDismissState;
52
+ - (void)updateHingeState;
53
+ - (void)correctHingeOffsetForIndex:(NSInteger)index;
54
+ - (CGFloat)tabletopHingeEdge;
55
+ - (CGFloat)hingeOffsetForHeight:(CGFloat)height;
50
56
  - (void)animateInteractiveContainerToTransform:(CGAffineTransform)transform
51
57
  duration:(NSTimeInterval)duration
52
58
  allowUserInteraction:(BOOL)allowUserInteraction
@@ -76,11 +82,14 @@ static char TrueSheetAccessibilityWindowPreviousElementsKey;
76
82
  NSInteger _pendingDetentIndex;
77
83
  NSInteger _displayedBackgroundIndex;
78
84
  BOOL _usesBackgroundEffects;
85
+ BOOL _refusedPanDismiss;
79
86
 
80
87
  // Present/dismiss tracking: a display link scoped to the transition
81
88
  // coordinator — started alongside the transition, stopped in its completion.
82
89
  CADisplayLink *_transitionLink;
83
90
  BOOL _isTransitioning;
91
+ // Where the sheet rests during the transition: the target on present, the start on dismiss
92
+ CGFloat _transitionRestPosition;
84
93
 
85
94
  BOOL _pendingContentSizeChange;
86
95
  BOOL _pendingDetentsChange;
@@ -91,6 +100,7 @@ static char TrueSheetAccessibilityWindowPreviousElementsKey;
91
100
  // for the next present (beats the float-threshold guess for re-presents).
92
101
  BOOL _hasObservedBottomInset;
93
102
  CGFloat _observedBottomInset;
103
+ BOOL _isRefreshingSafeArea;
94
104
 
95
105
  BOOL _isInteractiveDismiss;
96
106
  CGFloat _interactiveStartPosition;
@@ -99,14 +109,28 @@ static char TrueSheetAccessibilityWindowPreviousElementsKey;
99
109
  CADisplayLink *_interactivePositionLink;
100
110
 
101
111
  __weak TrueSheetViewController *_parentSheetController;
112
+ __weak UIView *_presenterFirstResponder;
102
113
 
103
114
  UIView *_anchorView;
104
115
 
105
116
  __weak UIWindow *_accessibilityWindow;
117
+ BOOL _appliedAccessibilityFooterElements;
106
118
 
107
119
  TrueSheetBlurView *_blurView;
108
120
  TrueSheetGrabberView *_grabberView;
109
121
  TrueSheetDetentCalculator *_detentCalculator;
122
+ TrueSheetDimView *_dimView;
123
+
124
+ // Foldable hinge (iOS 27.1+). Tabletop resolves on demand from the window's
125
+ // division region; these track the last applied posture to detect changes.
126
+ BOOL _hingeStateKnown;
127
+ CGFloat _hingeEdge;
128
+ NSInteger _hingeRestoreIndex;
129
+ NSInteger _hingeCorrections;
130
+ // Offset learned at the hinge, shared by every hinge-placed detent
131
+ CGFloat _hingeOffset;
132
+ // Offset each hinge-placed detent last resolved with, checked on settle
133
+ NSMutableDictionary<NSNumber *, NSNumber *> *_hingeOffsets;
110
134
  }
111
135
 
112
136
  #pragma mark - Initialization
@@ -126,6 +150,8 @@ static char TrueSheetAccessibilityWindowPreviousElementsKey;
126
150
  _footerAvoidsKeyboard = YES;
127
151
  _dismissible = YES;
128
152
  _dimmed = YES;
153
+ _dimColor = nil;
154
+ _dimOpacity = nil;
129
155
  _dimmedDetentIndex = @(0);
130
156
  _presentation = facebook::react::TrueSheetViewPresentation::Page;
131
157
  _lastEmittedPositionState = (TrueSheetPositionState){0, 0, 0};
@@ -139,6 +165,8 @@ static char TrueSheetAccessibilityWindowPreviousElementsKey;
139
165
  _pendingDetentIndex = -1;
140
166
 
141
167
  _insetAdjustment = TrueSheetViewInsetAdjustment::Automatic;
168
+ _hingeRestoreIndex = -1;
169
+ _hingeOffsets = [NSMutableDictionary dictionary];
142
170
  _detentCalculator = [[TrueSheetDetentCalculator alloc] init];
143
171
  _detentCalculator.delegate = self;
144
172
  }
@@ -225,14 +253,22 @@ static char TrueSheetAccessibilityWindowPreviousElementsKey;
225
253
  return self.currentPosition + (presentationY - modelY) + containerTy;
226
254
  }
227
255
 
256
+ // Some callers run before this controller's view is attached (e.g. mid-presentation
257
+ // detent resolution), so fall back to the presenter's window.
258
+ - (UIWindow *)hostWindow {
259
+ return self.view.window ?: self.presentingViewController.view.window;
260
+ }
261
+
228
262
  - (CGFloat)screenHeight {
229
263
  // In a windowed iPad app (Stage Manager, iPadOS 26 windowing) the window can be shorter than
230
- // UIScreen.mainScreen, so prefer the window's height. Some callers run before this controller's
231
- // view is attached (e.g. mid-presentation detent resolution), so fall back to the presenter's.
232
- UIWindow *window = self.view.window ?: self.presentingViewController.view.window;
264
+ // UIScreen.mainScreen, so prefer the window's height.
265
+ UIWindow *window = self.hostWindow;
233
266
  return window ? window.bounds.size.height : UIScreen.mainScreen.bounds.size.height;
234
267
  }
235
268
 
269
+ // Gap UIKit leaves between a floating sheet (iOS 26+) and the screen's bottom edge
270
+ static const CGFloat TrueSheetFloatingSheetInset = 8;
271
+
236
272
  // Only phone sheets absorb the bottom safe-area inset — non-phone idioms
237
273
  // (iPad form sheets) are inset from the screen edge by UIKit.
238
274
  static BOOL TrueSheetIsPhoneIdiom(void) {
@@ -316,6 +352,12 @@ static BOOL TrueSheetIsPhoneIdiom(void) {
316
352
  // wrong leaves the safe-area region unfilled (gap) or double-padded.
317
353
  UIView *view = self.viewIfLoaded;
318
354
  if (view.window) {
355
+ // Mid-present, hold the expected inset over a stale zero so the footer
356
+ // doesn't shrink and regrow — viewDidAppear forces the real value.
357
+ if (!_isPresented && [self hasStaleBottomSafeArea]) {
358
+ return [self bottomSafeAreaForHeight:view.bounds.size.height];
359
+ }
360
+
319
361
  _hasObservedBottomInset = YES;
320
362
  _observedBottomInset = view.safeAreaInsets.bottom;
321
363
  return _observedBottomInset;
@@ -428,6 +470,81 @@ static BOOL TrueSheetIsPhoneIdiom(void) {
428
470
  _grabberView = [[TrueSheetGrabberView alloc] init];
429
471
  _grabberView.hidden = YES;
430
472
  [_grabberView addToView:self.view];
473
+
474
+ #if RNTS_IPHONE_OS_VERSION_AVAILABLE(27_1)
475
+ if (@available(iOS 27.1, *)) {
476
+ __weak __typeof(self) weakSelf = self;
477
+ UIHingeInteraction *hingeInteraction =
478
+ [[UIHingeInteraction alloc] initWithUpdateHandler:^(UIHingeInteraction *_, UIHingeInteractionUpdate *__) {
479
+ // Next run loop, so the division region reflects the new posture
480
+ dispatch_async(dispatch_get_main_queue(), ^{
481
+ [weakSelf updateHingeState];
482
+ });
483
+ }];
484
+ [self.view addInteraction:hingeInteraction];
485
+ }
486
+ #endif
487
+ }
488
+
489
+ #pragma mark - Custom Dimming
490
+
491
+ // UISheetPresentationController has no API for the dim's color or opacity, so a custom
492
+ // color/opacity turns the native dim off and fades in our own view behind the sheet.
493
+ - (BOOL)useCustomDimming {
494
+ return self.dimmed && (self.dimColor != nil || self.dimOpacity != nil);
495
+ }
496
+
497
+ - (BOOL)isDimmedAtEveryDetent {
498
+ return self.dimmed && [self.dimmedDetentIndex integerValue] == 0;
499
+ }
500
+
501
+ // Like the system dim, fades in between the detent below `dimmedDetentIndex` and the dimmed detent.
502
+ - (CGFloat)dimAlphaForIndex:(CGFloat)index {
503
+ if (![self useCustomDimming]) {
504
+ return 0;
505
+ }
506
+
507
+ CGFloat progress = index - [self.dimmedDetentIndex integerValue] + 1;
508
+ return fmax(0, fmin(1, progress));
509
+ }
510
+
511
+ // Present and dismiss fade the dim over the whole travel below the resting position.
512
+ - (CGFloat)dimAlphaForPosition:(CGFloat)position index:(CGFloat)index {
513
+ if (!_isTransitioning || position <= _transitionRestPosition) {
514
+ return [self dimAlphaForIndex:index];
515
+ }
516
+
517
+ CGFloat travel = self.screenHeight - _transitionRestPosition;
518
+ CGFloat progress = travel > 0 ? fmax(0, (self.screenHeight - position) / travel) : 0;
519
+ return [self dimAlphaForIndex:[self interpolatedIndexForPosition:_transitionRestPosition]] * progress;
520
+ }
521
+
522
+ - (void)setupDimView {
523
+ UIView *container = self.presentationController.containerView;
524
+ if (!container || (!_dimView && ![self useCustomDimming])) {
525
+ return;
526
+ }
527
+
528
+ if (!_dimView) {
529
+ _dimView = [[TrueSheetDimView alloc] init];
530
+
531
+ __weak __typeof(self) weakSelf = self;
532
+ _dimView.onTap = ^{
533
+ [weakSelf handleDimViewTap];
534
+ };
535
+ }
536
+
537
+ // Keep opacity in the color so a transparent dim still receives touches.
538
+ UIColor *color = self.dimColor ?: [UIColor blackColor];
539
+ CGFloat opacity = self.dimOpacity ? [self.dimOpacity doubleValue] : 0.5;
540
+ _dimView.backgroundColor = [color colorWithAlphaComponent:CGColorGetAlpha(color.CGColor) * opacity];
541
+ [_dimView addToView:container];
542
+ }
543
+
544
+ - (void)handleDimViewTap {
545
+ if (self.dismissible) {
546
+ [self.presentingViewController dismissViewControllerAnimated:YES completion:nil];
547
+ }
431
548
  }
432
549
 
433
550
  - (void)viewWillAppear:(BOOL)animated {
@@ -443,6 +560,22 @@ static BOOL TrueSheetIsPhoneIdiom(void) {
443
560
  [_parentSheetController setAccessibilityContentElement:self.accessibilityContentView ?: self.view];
444
561
  }
445
562
 
563
+ [self setupDimView];
564
+
565
+ // A new presentation records the posture it starts in
566
+ _hingeStateKnown = NO;
567
+ _hingeRestoreIndex = -1;
568
+ _hingeOffset = 0;
569
+
570
+ // UIKit hides the presenter's keyboard under a sheet dimmed at every detent and
571
+ // brings it back on dismiss, but custom dimming leaves the native sheet undimmed.
572
+ if ([self useCustomDimming] && [self isDimmedAtEveryDetent]) {
573
+ UIView *firstResponder = [presenter.view findFirstResponder];
574
+ if ([firstResponder resignFirstResponder]) {
575
+ _presenterFirstResponder = firstResponder;
576
+ }
577
+ }
578
+
446
579
  dispatch_async(dispatch_get_main_queue(), ^{
447
580
  NSInteger index = self.currentDetentIndex;
448
581
  CGFloat position = self.currentPosition;
@@ -471,15 +604,48 @@ static BOOL TrueSheetIsPhoneIdiom(void) {
471
604
 
472
605
  [self->_grabberView updateAccessibilityValueWithIndex:index detentCount:self->_detents.count];
473
606
  [self settleAtDetentIndex:index debug:@"did present"];
607
+
608
+ // Apply a posture change that happened while presenting
609
+ [self updateHingeState];
474
610
  });
475
611
 
476
612
  [self setupGestureRecognizer];
477
613
  _isPresented = YES;
614
+ [self refreshStaleBottomSafeArea];
478
615
  }
479
616
 
480
617
  [self setupAccessibilityContainer];
481
618
  }
482
619
 
620
+ /**
621
+ * A present that overlaps another transition (e.g. re-presenting while a
622
+ * native stack pops) can leave the root view's bottom safe area at zero, and
623
+ * UIKit doesn't recompute it until the sheet's frame next changes. Suspect a
624
+ * zero the presented height says shouldn't be there — a small floating sheet
625
+ * legitimately has none.
626
+ */
627
+ - (BOOL)hasStaleBottomSafeArea {
628
+ UIView *view = self.viewIfLoaded;
629
+ return view.window && view.safeAreaInsets.bottom <= 0 && [self bottomSafeAreaForHeight:view.bounds.size.height] > 0;
630
+ }
631
+
632
+ // Toggling additionalSafeAreaInsets forces UIKit to recompute the safe area.
633
+ // The toggle's intermediate value isn't reported, so the footer only sees the
634
+ // settled one.
635
+ - (void)refreshStaleBottomSafeArea {
636
+ if (![self hasStaleBottomSafeArea]) {
637
+ return;
638
+ }
639
+
640
+ _isRefreshingSafeArea = YES;
641
+ UIEdgeInsets insets = self.additionalSafeAreaInsets;
642
+ self.additionalSafeAreaInsets = UIEdgeInsetsMake(insets.top, insets.left, insets.bottom + 1, insets.right);
643
+ self.additionalSafeAreaInsets = insets;
644
+ _isRefreshingSafeArea = NO;
645
+
646
+ [self.delegate viewControllerSafeAreaInsetsDidChange];
647
+ }
648
+
483
649
  - (void)setupAccessibilityContainer {
484
650
  UIView *contentView = self.accessibilityContentView;
485
651
  if (!contentView) {
@@ -489,6 +655,34 @@ static BOOL TrueSheetIsPhoneIdiom(void) {
489
655
  [self setAccessibilityContentElement:contentView];
490
656
  }
491
657
 
658
+ // Only the topmost sheet may refresh, or it would steal the window
659
+ // accessibility override from the child presented over it.
660
+ - (BOOL)canRefreshAccessibilityContainer {
661
+ return _isPresented && !self.isBeingDismissed && self.isTopmostPresentedController;
662
+ }
663
+
664
+ - (void)refreshAccessibilityContainer {
665
+ if (self.canRefreshAccessibilityContainer) {
666
+ [self setupAccessibilityContainer];
667
+ }
668
+ }
669
+
670
+ - (void)refreshAccessibilityContainerIfFooterChanged {
671
+ // Only a dimmed sheet's boundary depends on the footer
672
+ if (!_dimmed || !self.canRefreshAccessibilityContainer) {
673
+ return;
674
+ }
675
+
676
+ UIView *contentView = self.accessibilityContentView;
677
+ if (![contentView isKindOfClass:[TrueSheetContainerView class]] ||
678
+ [(TrueSheetContainerView *)contentView hasAccessibilityFooterElements] == _appliedAccessibilityFooterElements) {
679
+ return;
680
+ }
681
+
682
+ [self setAccessibilityContentElement:contentView];
683
+ UIAccessibilityPostNotification(UIAccessibilityLayoutChangedNotification, nil);
684
+ }
685
+
492
686
  - (UIViewController *)accessibilityPresentingViewController {
493
687
  UIViewController *presentingViewController = self.presentingViewController;
494
688
  while ([presentingViewController isKindOfClass:[TrueSheetViewController class]] &&
@@ -562,6 +756,7 @@ static BOOL TrueSheetIsPhoneIdiom(void) {
562
756
  // Footer controls exposed as separate accessibility elements can be skipped
563
757
  // by XCTest when the presented sheet is a hard modal accessibility boundary.
564
758
  BOOL isAccessibilityModal = _dimmed && !hasAccessibilityFooterElements;
759
+ _appliedAccessibilityFooterElements = hasAccessibilityFooterElements;
565
760
 
566
761
  // At a hard modal boundary XCTest skips nested elements, so flatten the
567
762
  // container's children into the array. Otherwise expose the container itself:
@@ -656,6 +851,13 @@ static BOOL TrueSheetIsPhoneIdiom(void) {
656
851
  [self.delegate viewControllerWillBlur];
657
852
  [self.delegate viewControllerWillDismiss];
658
853
  [_parentSheetController.delegate viewControllerWillFocus];
854
+
855
+ // Restored once the dismiss commits, like UIKit's own keyboard preservation.
856
+ UIView *firstResponder = _presenterFirstResponder;
857
+ _presenterFirstResponder = nil;
858
+ if (!self.presentingViewController.isBeingDismissed) {
859
+ [firstResponder becomeFirstResponder];
860
+ }
659
861
  }
660
862
  }
661
863
 
@@ -666,6 +868,9 @@ static BOOL TrueSheetIsPhoneIdiom(void) {
666
868
  _isWillDismissEmitted = NO;
667
869
  _hasObservedBottomInset = NO;
668
870
 
871
+ [_dimView removeFromSuperview];
872
+ _dimView = nil;
873
+
669
874
  [_anchorView removeFromSuperview];
670
875
  _anchorView = nil;
671
876
 
@@ -684,13 +889,29 @@ static BOOL TrueSheetIsPhoneIdiom(void) {
684
889
  [self restoreWindowAccessibilityElements];
685
890
  [self setSheetAccessibilityElementsHidden:YES];
686
891
 
687
- // Dispatch to allow pan gesture to set _isDragging before checking;
688
- // the transition tracker emits when the sheet is transitioning to dismiss
689
- dispatch_async(dispatch_get_main_queue(), ^{
690
- if (!self->_isDragging) {
691
- [self emitWillDismissEvents];
692
- }
693
- });
892
+ // Dismissing with a child on top only animates the child out — this sheet
893
+ // doesn't move and isn't part of that transition, so fade its dim over it.
894
+ if (self.isBeingDismissed && self.presentedViewController) {
895
+ [UIView animateWithDuration:self.transitionCoordinator.transitionDuration
896
+ animations:^{
897
+ self->_dimView.alpha = 0;
898
+ }];
899
+ }
900
+
901
+ // A swipe can still be cancelled, so emit once it commits on release. Any
902
+ // other dismiss (programmatic, outside tap, navigation) is already committed.
903
+ id<UIViewControllerTransitionCoordinator> coordinator = self.transitionCoordinator;
904
+ if (coordinator.isInteractive) {
905
+ __weak __typeof(self) weakSelf = self;
906
+ [coordinator
907
+ notifyWhenInteractionChangesUsingBlock:^(id<UIViewControllerTransitionCoordinatorContext> _Nonnull context) {
908
+ if (!context.isCancelled) {
909
+ [weakSelf emitWillDismissEvents];
910
+ }
911
+ }];
912
+ } else {
913
+ [self emitWillDismissEvents];
914
+ }
694
915
 
695
916
  [self setupTransitionTracker];
696
917
  }
@@ -710,6 +931,7 @@ static BOOL TrueSheetIsPhoneIdiom(void) {
710
931
  // the footer out of the auto detent.
711
932
  _keyboardSheetGrown = NO;
712
933
  _keyboardHeight = 0;
934
+ _minimizedKeyboardOverlap = 0;
713
935
 
714
936
  [self emitDidDismissEvents];
715
937
  }
@@ -766,7 +988,10 @@ static BOOL TrueSheetIsPhoneIdiom(void) {
766
988
  animateAlongsideTransition:^(id<UIViewControllerTransitionCoordinatorContext> context) {
767
989
  [self.sheet invalidateDetents];
768
990
  }
769
- completion:nil];
991
+ completion:^(id<UIViewControllerTransitionCoordinatorContext> context) {
992
+ // Rotating a half-open foldable swaps between book and tabletop
993
+ [self updateHingeState];
994
+ }];
770
995
  }
771
996
  }
772
997
 
@@ -779,7 +1004,7 @@ static BOOL TrueSheetIsPhoneIdiom(void) {
779
1004
 
780
1005
  // Mid-dismiss the frame moves off-screen and reports transient insets —
781
1006
  // don't let them pollute the observed value (matches viewDidLayoutSubviews).
782
- if (!self.isBeingDismissed) {
1007
+ if (!self.isBeingDismissed && !_isRefreshingSafeArea) {
783
1008
  [self.delegate viewControllerSafeAreaInsetsDidChange];
784
1009
  }
785
1010
  }
@@ -807,6 +1032,10 @@ static BOOL TrueSheetIsPhoneIdiom(void) {
807
1032
  }
808
1033
  }
809
1034
 
1035
+ // The hinge callback can run before the division region updates; the
1036
+ // relayout UIKit does for the posture change always sees the new region.
1037
+ [self updateHingeState];
1038
+
810
1039
  if (_pendingDetentIndex >= 0) {
811
1040
  NSInteger pendingIndex = _pendingDetentIndex;
812
1041
  _pendingDetentIndex = -1;
@@ -928,6 +1157,7 @@ static BOOL TrueSheetIsPhoneIdiom(void) {
928
1157
  }
929
1158
 
930
1159
  _isTransitioning = YES;
1160
+ _transitionRestPosition = self.isBeingDismissed ? self.livePosition : self.currentPosition;
931
1161
 
932
1162
  // Learn the resolver-vs-actual offset before emitting transition positions so
933
1163
  // the interpolated index lands exactly on the target detent. The presented
@@ -963,10 +1193,13 @@ static BOOL TrueSheetIsPhoneIdiom(void) {
963
1193
  // Settle after a present, cancelled dismiss, or detent-snap transition.
964
1194
  // Delayed because the presentedView frame isn't final until UIKit
965
1195
  // completes its layout pass after the transition animation — learning
966
- // here absorbs any sub-pixel drift since the earlier learns.
1196
+ // here absorbs any sub-pixel drift since the earlier learns. A dismiss
1197
+ // cancelled mid-drag leaves the finger down, so the drag end settles instead.
967
1198
  if (strongSelf->_isPresented && !strongSelf.isBeingDismissed) {
968
1199
  dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.1 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
969
- [strongSelf settleAtDetentIndex:strongSelf.currentDetentIndex debug:@"transition end"];
1200
+ if (!strongSelf->_isDragging) {
1201
+ [strongSelf settleAtDetentIndex:strongSelf.currentDetentIndex debug:@"transition end"];
1202
+ }
970
1203
  });
971
1204
  }
972
1205
  }];
@@ -978,13 +1211,6 @@ static BOOL TrueSheetIsPhoneIdiom(void) {
978
1211
  CGFloat position = self.livePosition;
979
1212
 
980
1213
  if (self.isBeingDismissed) {
981
- // Emit only once the dismiss is committed: on release UIKit moves the
982
- // model frame off-screen, while a cancelled drag rewinds it back to the
983
- // detent (isBeingDismissed stays YES during the rewind).
984
- if (self.currentPosition >= self.screenHeight) {
985
- [self emitWillDismissEvents];
986
- }
987
-
988
1214
  // Hide blur at the end of dismiss to prevent UIVisualEffectView
989
1215
  // from causing a flicker/flash at the bottom edge of the sheet.
990
1216
  if (self.screenHeight - position < 1) {
@@ -1005,6 +1231,7 @@ static BOOL TrueSheetIsPhoneIdiom(void) {
1005
1231
  // measured frame; the next unobstructed settle learns.
1006
1232
  if (self.presentedViewController == nil) {
1007
1233
  [self learnOffsetForDetentIndex:index];
1234
+ [self correctHingeOffsetForIndex:index];
1008
1235
  }
1009
1236
 
1010
1237
  [self emitChangePositionDelegateWithPosition:self.currentPosition realtime:NO debug:debug];
@@ -1145,6 +1372,11 @@ static BOOL TrueSheetIsPhoneIdiom(void) {
1145
1372
  .index = [self interpolatedIndexForPosition:position],
1146
1373
  };
1147
1374
 
1375
+ // A child on top only pushes this sheet back, its detent stays.
1376
+ if (!presented) {
1377
+ _dimView.alpha = [self dimAlphaForPosition:position index:state.index];
1378
+ }
1379
+
1148
1380
  // Settle (non-realtime) emits are authoritative and bypass the dedupe — a
1149
1381
  // learn right before them can correct the interpolated index by less than
1150
1382
  // the tolerance, and the corrected value must still reach JS.
@@ -1159,7 +1391,7 @@ static BOOL TrueSheetIsPhoneIdiom(void) {
1159
1391
  }
1160
1392
 
1161
1393
  - (void)learnOffsetForDetentIndex:(NSInteger)index {
1162
- if (_keyboardSheetGrown) {
1394
+ if (_keyboardSheetGrown || _minimizedKeyboardOverlap > 0) {
1163
1395
  return;
1164
1396
  }
1165
1397
  [_detentCalculator learnOffsetForDetentIndex:index];
@@ -1193,6 +1425,146 @@ static BOOL TrueSheetIsPhoneIdiom(void) {
1193
1425
  return [_detentCalculator detentValueForIndex:index];
1194
1426
  }
1195
1427
 
1428
+ #pragma mark - Hinge
1429
+
1430
+ /**
1431
+ * Bottom edge (window coordinates) of an active horizontal fold, or 0 when the
1432
+ * device isn't in tabletop. The division region is only active while the hinge
1433
+ * is partially open, so this also excludes flat and closed postures.
1434
+ */
1435
+ - (CGFloat)tabletopHingeEdge {
1436
+ #if RNTS_IPHONE_OS_VERSION_AVAILABLE(27_1)
1437
+ if (@available(iOS 27.1, *)) {
1438
+ UIWindow *window = self.hostWindow;
1439
+ for (UIViewReservedRegion *region in [window reservedRegionsOfKind:[UIViewReservedRegionKind divisionRegionKind]]) {
1440
+ CGRect frame = region.frame;
1441
+ if (region.isActive && frame.size.width > frame.size.height) {
1442
+ return CGRectGetMaxY(frame);
1443
+ }
1444
+ }
1445
+ }
1446
+ #endif
1447
+ return 0;
1448
+ }
1449
+
1450
+ /**
1451
+ * Gap between a hinge-placed detent's resolved height and the sheet's actual top
1452
+ * edge: UIKit adds the bottom safe area, plus the floating sheet's inset. Every
1453
+ * hinge-placed detent rests at the same height, so they share one offset — the
1454
+ * first settle at the hinge learns it.
1455
+ */
1456
+ - (CGFloat)hingeOffsetForHeight:(CGFloat)height {
1457
+ if (_hingeOffset > 0) {
1458
+ return _hingeOffset;
1459
+ }
1460
+
1461
+ return [self bottomSafeAreaForHeight:height] + TrueSheetFloatingSheetInset;
1462
+ }
1463
+
1464
+ // A hinge-placed detent resolved with an off estimate lands a few points off
1465
+ // the hinge — re-resolve once the settle has learned the real offset.
1466
+ - (void)correctHingeOffsetForIndex:(NSInteger)index {
1467
+ NSNumber *usedOffset = _hingeOffsets[@(index)];
1468
+ if (!usedOffset || _hingeCorrections >= 2) {
1469
+ return;
1470
+ }
1471
+
1472
+ CGFloat learnedOffset = [_detentCalculator learnedOffsetForIndex:index];
1473
+ if (learnedOffset == 0 || fabs(learnedOffset - usedOffset.doubleValue) <= 1) {
1474
+ return;
1475
+ }
1476
+
1477
+ _hingeOffset = learnedOffset;
1478
+ _hingeCorrections++;
1479
+ dispatch_async(dispatch_get_main_queue(), ^{
1480
+ [self invalidateDetentsAnimated];
1481
+ });
1482
+ }
1483
+
1484
+ - (void)invalidateDetentsAnimated {
1485
+ if (@available(iOS 16.0, *)) {
1486
+ [self.sheet animateChanges:^{
1487
+ self->_pendingContentSizeChange = YES;
1488
+ [self.sheet invalidateDetents];
1489
+ }];
1490
+ }
1491
+ }
1492
+
1493
+ /**
1494
+ * Re-resolves hinge-placed detents when the posture changes, and runs 'snap':
1495
+ * entering tabletop moves the sheet to the hinge detent, leaving it restores the
1496
+ * previous detent if the sheet is still on the hinge. The posture a presentation
1497
+ * starts in only records state, so presenting while already in tabletop doesn't
1498
+ * jump the sheet. A change while presenting, dismissing, or dragging stays
1499
+ * pending until the sheet is at rest again.
1500
+ */
1501
+ - (void)updateHingeState {
1502
+ NSInteger hingeIndex = self.foldOptions.hingeDetentIndex;
1503
+ if (hingeIndex < 0 && self.foldOptions.hingeBehavior == TrueSheetViewHingeBehavior::None) {
1504
+ return;
1505
+ }
1506
+
1507
+ // Covered by a full-screen presentation, the sheet has no window to read the posture from
1508
+ if (!self.viewIfLoaded.window) {
1509
+ return;
1510
+ }
1511
+
1512
+ CGFloat edge = [self tabletopHingeEdge];
1513
+ if (!_hingeStateKnown) {
1514
+ _hingeStateKnown = YES;
1515
+ _hingeEdge = edge;
1516
+ return;
1517
+ }
1518
+
1519
+ if (!self.isPresented || self.isBeingDismissed || _isDragging || fabs(edge - _hingeEdge) < 0.5) {
1520
+ return;
1521
+ }
1522
+
1523
+ BOOL entered = edge > 0 && _hingeEdge == 0;
1524
+ BOOL left = edge == 0 && _hingeEdge > 0;
1525
+ _hingeEdge = edge;
1526
+ _hingeOffset = 0;
1527
+ _hingeCorrections = 0;
1528
+
1529
+ NSInteger targetIndex = -1;
1530
+ NSInteger detentCount = (NSInteger)self.detents.count;
1531
+ BOOL snaps =
1532
+ self.foldOptions.hingeBehavior == TrueSheetViewHingeBehavior::Snap && hingeIndex >= 0 && hingeIndex < detentCount;
1533
+ if (entered && snaps) {
1534
+ _hingeRestoreIndex = self.currentDetentIndex;
1535
+ targetIndex = hingeIndex;
1536
+ } else if (left && _hingeRestoreIndex >= 0) {
1537
+ // Keep a detent the user picked while folded
1538
+ if (snaps && self.currentDetentIndex == hingeIndex) {
1539
+ targetIndex = MIN(_hingeRestoreIndex, detentCount - 1);
1540
+ }
1541
+ _hingeRestoreIndex = -1;
1542
+ }
1543
+
1544
+ // A resize settles through the pending detent change
1545
+ BOOL resizes = targetIndex >= 0 && targetIndex != self.activeDetentIndex;
1546
+
1547
+ if (@available(iOS 16.0, *)) {
1548
+ [self.sheet animateChanges:^{
1549
+ [self.sheet invalidateDetents];
1550
+ if (resizes) {
1551
+ [self resizeToDetentIndex:targetIndex];
1552
+ }
1553
+ }];
1554
+ }
1555
+
1556
+ // UIKit often re-resolves detents for the posture change on its own before
1557
+ // this runs, so the invalidation above triggers no layout pass to settle in.
1558
+ // Settle once the frame is final, same as a pending detent change.
1559
+ if (!resizes) {
1560
+ dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.2 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
1561
+ if (self.isPresented && !self.isBeingDismissed && !self->_isDragging && !self->_isTransitioning) {
1562
+ [self settleAtDetentIndex:self.currentDetentIndex debug:@"hinge"];
1563
+ }
1564
+ });
1565
+ }
1566
+ }
1567
+
1196
1568
  #pragma mark - Sheet Configuration
1197
1569
 
1198
1570
  /**
@@ -1214,12 +1586,19 @@ static BOOL TrueSheetIsPhoneIdiom(void) {
1214
1586
  _autoDetentHeight = autoHeight;
1215
1587
  _peekDetentHeight = peekHeight;
1216
1588
 
1589
+ [self invalidateDetentsAnimated];
1590
+ }
1591
+ // iOS 15: detents are fixed medium/large — size changes can't affect them.
1592
+ }
1593
+
1594
+ // Re-resolves the detents in place, e.g. when an input to the resolver
1595
+ // changes after UIKit has already laid out the sheet.
1596
+ - (void)invalidateDetents {
1597
+ if (@available(iOS 16.0, *)) {
1217
1598
  [self.sheet animateChanges:^{
1218
- self->_pendingContentSizeChange = YES;
1219
1599
  [self.sheet invalidateDetents];
1220
1600
  }];
1221
1601
  }
1222
- // iOS 15: detents are fixed medium/large — size changes can't affect them.
1223
1602
  }
1224
1603
 
1225
1604
  - (void)setupSheetDetentsForDetentsChange {
@@ -1236,6 +1615,7 @@ static BOOL TrueSheetIsPhoneIdiom(void) {
1236
1615
 
1237
1616
  NSMutableArray<UISheetPresentationControllerDetent *> *detents = [NSMutableArray array];
1238
1617
  [_detentCalculator clearResolvedHeights];
1618
+ _hingeCorrections = 0;
1239
1619
 
1240
1620
  _autoDetentHeight = [_detentCalculator autoHeightForKeyboardHeight:_keyboardHeight];
1241
1621
  _peekDetentHeight = [_detentCalculator peekHeight];
@@ -1263,7 +1643,9 @@ static BOOL TrueSheetIsPhoneIdiom(void) {
1263
1643
 
1264
1644
  sheet.detents = detents;
1265
1645
 
1266
- if (self.dimmed && [self.dimmedDetentIndex integerValue] == 0) {
1646
+ if ([self useCustomDimming]) {
1647
+ sheet.largestUndimmedDetentIdentifier = sheet.detents.lastObject.identifier;
1648
+ } else if ([self isDimmedAtEveryDetent]) {
1267
1649
  sheet.largestUndimmedDetentIdentifier = nil;
1268
1650
  } else {
1269
1651
  sheet.largestUndimmedDetentIdentifier = UISheetPresentationControllerDetentIdentifierLarge;
@@ -1356,24 +1738,66 @@ static BOOL TrueSheetIsPhoneIdiom(void) {
1356
1738
  : maxDetentValue;
1357
1739
 
1358
1740
  CGFloat adjustedHeight = height - bottomAdjustment;
1741
+ NSInteger lastIndex = (NSInteger)self.detents.count - 1;
1742
+
1743
+ // Tabletop: the hinge detent rests on the hinge, and 'avoid'/'snap'
1744
+ // cap every detent there.
1745
+ BOOL avoidsHinge = self.foldOptions.hingeBehavior != TrueSheetViewHingeBehavior::None;
1746
+ NSInteger hingeIndex = self.foldOptions.hingeDetentIndex;
1747
+ BOOL hasHingeDetent = hingeIndex >= 0 && hingeIndex <= lastIndex;
1748
+ CGFloat hingeEdge = (hasHingeDetent || avoidsHinge) ? [self tabletopHingeEdge] : 0;
1749
+ CGFloat hingeOffset = 0;
1750
+ CGFloat hingeHeight = 0;
1751
+ if (hingeEdge > 0) {
1752
+ CGFloat panelHeight = self.screenHeight - hingeEdge;
1753
+ hingeOffset = [self hingeOffsetForHeight:panelHeight];
1754
+ hingeHeight = panelHeight - hingeOffset;
1755
+ if (hasHingeDetent) {
1756
+ // Lower detents stay below the hinge detent and higher ones above
1757
+ // it, so the detents keep their order
1758
+ CGFloat hingeDetentHeight =
1759
+ avoidsHinge ? hingeHeight - (lastIndex - hingeIndex) : hingeHeight;
1760
+ if (index == hingeIndex) {
1761
+ adjustedHeight = hingeHeight;
1762
+ } else if (index < hingeIndex) {
1763
+ adjustedHeight = fmin(adjustedHeight, hingeDetentHeight - (hingeIndex - index));
1764
+ } else {
1765
+ adjustedHeight = fmax(adjustedHeight, hingeDetentHeight + (index - hingeIndex));
1766
+ }
1767
+ }
1768
+ if (avoidsHinge) {
1769
+ maxValue = fmin(maxValue, hingeHeight);
1770
+ }
1771
+ }
1359
1772
 
1360
1773
  // Only the last detent may occupy the full maximum. Two detents
1361
1774
  // resolving to the same height (e.g. a capped auto and 1) make
1362
1775
  // UIKit skip the stack's deck animation when presenting a child
1363
1776
  // sheet and snap the sheet behind into place instead.
1364
- NSInteger lastIndex = (NSInteger)self.detents.count - 1;
1365
1777
  if (adjustedHeight >= maxValue && index < lastIndex) {
1366
1778
  maxValue -= lastIndex - index;
1367
1779
  }
1368
1780
 
1369
1781
  CGFloat resolved = fmin(adjustedHeight, maxValue);
1370
1782
 
1783
+ // Detents within the collapse spacing of the hinge are hinge-placed.
1784
+ // They rest with the hinge offset, so position events map them right
1785
+ // before the first settle there.
1786
+ if (hingeHeight > 0 && fabs(resolved - hingeHeight) <= lastIndex + 0.5) {
1787
+ self->_hingeOffsets[@(index)] = @(hingeOffset);
1788
+ [self->_detentCalculator assumeOffset:hingeOffset forIndex:index];
1789
+ } else {
1790
+ [self->_hingeOffsets removeObjectForKey:@(index)];
1791
+ }
1792
+
1371
1793
  NSMutableArray *heights = self->_detentCalculator.resolvedDetentHeights;
1372
1794
  if (heights && index >= 0 && index < (NSInteger)heights.count) {
1373
1795
  heights[index] = @(resolved);
1374
1796
  }
1375
1797
 
1376
- return resolved;
1798
+ // Recorded uncompensated above: UIKit adds the overlap back on
1799
+ // top. A sheet shorter than the overlap still grows by the rest.
1800
+ return fmax(resolved - self.minimizedKeyboardOverlap, 0);
1377
1801
  }];
1378
1802
  }
1379
1803
 
@@ -1569,11 +1993,7 @@ static BOOL TrueSheetIsPhoneIdiom(void) {
1569
1993
  _grabberView.onDecrement = nil;
1570
1994
  }
1571
1995
 
1572
- // Only the topmost sheet may refresh, or a parent's prop update would steal the
1573
- // window accessibility override from the child presented over it.
1574
- if (_isPresented && self.isTopmostPresentedController) {
1575
- [self setupAccessibilityContainer];
1576
- }
1996
+ [self refreshAccessibilityContainer];
1577
1997
  }
1578
1998
 
1579
1999
  - (void)handleGrabberTap {
@@ -1702,18 +2122,55 @@ static BOOL TrueSheetIsPhoneIdiom(void) {
1702
2122
 
1703
2123
  [self setupBackground];
1704
2124
  [self setupGrabber];
2125
+
2126
+ // Dim props changed while presented. A hidden custom dim stays until dismiss;
2127
+ // at alpha 0 it no longer takes touches.
2128
+ if (_isPresented) {
2129
+ [self setupDimView];
2130
+ _dimView.alpha = [self dimAlphaForIndex:self.currentDetentIndex];
2131
+ }
1705
2132
  }
1706
2133
 
1707
2134
  #pragma mark - UISheetPresentationControllerDelegate
1708
2135
 
1709
2136
  - (BOOL)presentationControllerShouldDismiss:(UIPresentationController *)presentationController {
1710
- return self.dismissible;
2137
+ // A sheet that can't be dragged can't be swiped away either, including by
2138
+ // pulling down its scroll view: refuse a dismissal while any pan is under
2139
+ // way, and keep the one from a tap outside the sheet. Reset on every call:
2140
+ // UIKit skips didAttemptToDismiss for a short pull.
2141
+ _refusedPanDismiss =
2142
+ self.dismissible && !self.draggable && [self hasActivePanInView:presentationController.containerView];
2143
+ return self.dismissible && !_refusedPanDismiss;
2144
+ }
2145
+
2146
+ - (BOOL)hasActivePanInView:(UIView *)view {
2147
+ if (!view) {
2148
+ return NO;
2149
+ }
2150
+ for (UIGestureRecognizer *recognizer in view.gestureRecognizers ?: @[]) {
2151
+ if ([recognizer isKindOfClass:[UIPanGestureRecognizer class]] &&
2152
+ (recognizer.state == UIGestureRecognizerStateBegan || recognizer.state == UIGestureRecognizerStateChanged)) {
2153
+ return YES;
2154
+ }
2155
+ }
2156
+ for (UIView *subview in view.subviews) {
2157
+ if ([self hasActivePanInView:subview]) {
2158
+ return YES;
2159
+ }
2160
+ }
2161
+ return NO;
1711
2162
  }
1712
2163
 
1713
2164
  // UIKit calls this after shouldDismiss returns NO — the sheet has already
1714
2165
  // bounced back, so JS can confirm and dismiss programmatically (Apple's
1715
2166
  // unsaved-changes pattern).
1716
2167
  - (void)presentationControllerDidAttemptToDismiss:(UIPresentationController *)presentationController {
2168
+ // A swipe refused only because the sheet can't be dragged isn't an attempt
2169
+ // to dismiss a non-dismissible sheet.
2170
+ if (_refusedPanDismiss) {
2171
+ _refusedPanDismiss = NO;
2172
+ return;
2173
+ }
1717
2174
  [self.delegate viewControllerDidAttemptDismiss];
1718
2175
  }
1719
2176