@lodev09/react-native-true-sheet 4.0.0-beta.25 → 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 (32) hide show
  1. package/README.md +1 -0
  2. package/android/src/main/java/com/lodev09/truesheet/TrueSheetOverlayView.kt +1 -1
  3. package/android/src/main/java/com/lodev09/truesheet/TrueSheetView.kt +31 -10
  4. package/android/src/main/java/com/lodev09/truesheet/TrueSheetViewController.kt +3 -3
  5. package/android/src/main/java/com/lodev09/truesheet/TrueSheetViewManager.kt +5 -0
  6. package/android/src/main/java/com/lodev09/truesheet/core/RNScreensEventObserver.kt +8 -16
  7. package/android/src/main/java/com/lodev09/truesheet/core/TrueSheetBottomSheetBehavior.kt +9 -0
  8. package/android/src/main/java/com/lodev09/truesheet/core/TrueSheetBottomSheetView.kt +0 -8
  9. package/android/src/main/java/com/lodev09/truesheet/core/TrueSheetStackManager.kt +29 -38
  10. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetContentViewShadowNode.cpp +6 -2
  11. package/ios/TrueSheetContainerView.h +1 -0
  12. package/ios/TrueSheetContainerView.mm +6 -0
  13. package/ios/TrueSheetContentView.h +1 -0
  14. package/ios/TrueSheetContentView.mm +2 -0
  15. package/ios/TrueSheetFooterView.mm +5 -0
  16. package/ios/TrueSheetView.mm +9 -2
  17. package/ios/TrueSheetViewController.h +12 -0
  18. package/ios/TrueSheetViewController.mm +332 -29
  19. package/ios/core/TrueSheetDetentCalculator.h +11 -0
  20. package/ios/core/TrueSheetDetentCalculator.mm +17 -5
  21. package/ios/core/TrueSheetKeyboardObserver.mm +89 -9
  22. package/lib/module/fabric/TrueSheetViewNativeComponent.ts +6 -0
  23. package/lib/typescript/src/TrueSheet.types.d.ts +31 -0
  24. package/lib/typescript/src/TrueSheet.types.d.ts.map +1 -1
  25. package/lib/typescript/src/fabric/TrueSheetViewNativeComponent.d.ts +5 -0
  26. package/lib/typescript/src/fabric/TrueSheetViewNativeComponent.d.ts.map +1 -1
  27. package/lib/typescript/src/navigation/types.d.ts +1 -1
  28. package/lib/typescript/src/navigation/types.d.ts.map +1 -1
  29. package/package.json +1 -1
  30. package/src/TrueSheet.types.ts +34 -0
  31. package/src/fabric/TrueSheetViewNativeComponent.ts +6 -0
  32. package/src/navigation/types.ts +1 -0
@@ -49,6 +49,10 @@ static char TrueSheetAccessibilityWindowPreviousElementsKey;
49
49
  - (void)setAccessibilityContentElement:(UIView *)contentView;
50
50
  - (NSArray *)accessibilityGrabberElements;
51
51
  - (void)endInteractiveDismissState;
52
+ - (void)updateHingeState;
53
+ - (void)correctHingeOffsetForIndex:(NSInteger)index;
54
+ - (CGFloat)tabletopHingeEdge;
55
+ - (CGFloat)hingeOffsetForHeight:(CGFloat)height;
52
56
  - (void)animateInteractiveContainerToTransform:(CGAffineTransform)transform
53
57
  duration:(NSTimeInterval)duration
54
58
  allowUserInteraction:(BOOL)allowUserInteraction
@@ -78,6 +82,7 @@ static char TrueSheetAccessibilityWindowPreviousElementsKey;
78
82
  NSInteger _pendingDetentIndex;
79
83
  NSInteger _displayedBackgroundIndex;
80
84
  BOOL _usesBackgroundEffects;
85
+ BOOL _refusedPanDismiss;
81
86
 
82
87
  // Present/dismiss tracking: a display link scoped to the transition
83
88
  // coordinator — started alongside the transition, stopped in its completion.
@@ -109,11 +114,23 @@ static char TrueSheetAccessibilityWindowPreviousElementsKey;
109
114
  UIView *_anchorView;
110
115
 
111
116
  __weak UIWindow *_accessibilityWindow;
117
+ BOOL _appliedAccessibilityFooterElements;
112
118
 
113
119
  TrueSheetBlurView *_blurView;
114
120
  TrueSheetGrabberView *_grabberView;
115
121
  TrueSheetDetentCalculator *_detentCalculator;
116
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;
117
134
  }
118
135
 
119
136
  #pragma mark - Initialization
@@ -148,6 +165,8 @@ static char TrueSheetAccessibilityWindowPreviousElementsKey;
148
165
  _pendingDetentIndex = -1;
149
166
 
150
167
  _insetAdjustment = TrueSheetViewInsetAdjustment::Automatic;
168
+ _hingeRestoreIndex = -1;
169
+ _hingeOffsets = [NSMutableDictionary dictionary];
151
170
  _detentCalculator = [[TrueSheetDetentCalculator alloc] init];
152
171
  _detentCalculator.delegate = self;
153
172
  }
@@ -234,14 +253,22 @@ static char TrueSheetAccessibilityWindowPreviousElementsKey;
234
253
  return self.currentPosition + (presentationY - modelY) + containerTy;
235
254
  }
236
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
+
237
262
  - (CGFloat)screenHeight {
238
263
  // In a windowed iPad app (Stage Manager, iPadOS 26 windowing) the window can be shorter than
239
- // UIScreen.mainScreen, so prefer the window's height. Some callers run before this controller's
240
- // view is attached (e.g. mid-presentation detent resolution), so fall back to the presenter's.
241
- UIWindow *window = self.view.window ?: self.presentingViewController.view.window;
264
+ // UIScreen.mainScreen, so prefer the window's height.
265
+ UIWindow *window = self.hostWindow;
242
266
  return window ? window.bounds.size.height : UIScreen.mainScreen.bounds.size.height;
243
267
  }
244
268
 
269
+ // Gap UIKit leaves between a floating sheet (iOS 26+) and the screen's bottom edge
270
+ static const CGFloat TrueSheetFloatingSheetInset = 8;
271
+
245
272
  // Only phone sheets absorb the bottom safe-area inset — non-phone idioms
246
273
  // (iPad form sheets) are inset from the screen edge by UIKit.
247
274
  static BOOL TrueSheetIsPhoneIdiom(void) {
@@ -443,6 +470,20 @@ static BOOL TrueSheetIsPhoneIdiom(void) {
443
470
  _grabberView = [[TrueSheetGrabberView alloc] init];
444
471
  _grabberView.hidden = YES;
445
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
446
487
  }
447
488
 
448
489
  #pragma mark - Custom Dimming
@@ -521,6 +562,11 @@ static BOOL TrueSheetIsPhoneIdiom(void) {
521
562
 
522
563
  [self setupDimView];
523
564
 
565
+ // A new presentation records the posture it starts in
566
+ _hingeStateKnown = NO;
567
+ _hingeRestoreIndex = -1;
568
+ _hingeOffset = 0;
569
+
524
570
  // UIKit hides the presenter's keyboard under a sheet dimmed at every detent and
525
571
  // brings it back on dismiss, but custom dimming leaves the native sheet undimmed.
526
572
  if ([self useCustomDimming] && [self isDimmedAtEveryDetent]) {
@@ -558,6 +604,9 @@ static BOOL TrueSheetIsPhoneIdiom(void) {
558
604
 
559
605
  [self->_grabberView updateAccessibilityValueWithIndex:index detentCount:self->_detents.count];
560
606
  [self settleAtDetentIndex:index debug:@"did present"];
607
+
608
+ // Apply a posture change that happened while presenting
609
+ [self updateHingeState];
561
610
  });
562
611
 
563
612
  [self setupGestureRecognizer];
@@ -606,6 +655,34 @@ static BOOL TrueSheetIsPhoneIdiom(void) {
606
655
  [self setAccessibilityContentElement:contentView];
607
656
  }
608
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
+
609
686
  - (UIViewController *)accessibilityPresentingViewController {
610
687
  UIViewController *presentingViewController = self.presentingViewController;
611
688
  while ([presentingViewController isKindOfClass:[TrueSheetViewController class]] &&
@@ -679,6 +756,7 @@ static BOOL TrueSheetIsPhoneIdiom(void) {
679
756
  // Footer controls exposed as separate accessibility elements can be skipped
680
757
  // by XCTest when the presented sheet is a hard modal accessibility boundary.
681
758
  BOOL isAccessibilityModal = _dimmed && !hasAccessibilityFooterElements;
759
+ _appliedAccessibilityFooterElements = hasAccessibilityFooterElements;
682
760
 
683
761
  // At a hard modal boundary XCTest skips nested elements, so flatten the
684
762
  // container's children into the array. Otherwise expose the container itself:
@@ -820,13 +898,20 @@ static BOOL TrueSheetIsPhoneIdiom(void) {
820
898
  }];
821
899
  }
822
900
 
823
- // Dispatch to allow pan gesture to set _isDragging before checking;
824
- // the transition tracker emits when the sheet is transitioning to dismiss
825
- dispatch_async(dispatch_get_main_queue(), ^{
826
- if (!self->_isDragging) {
827
- [self emitWillDismissEvents];
828
- }
829
- });
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
+ }
830
915
 
831
916
  [self setupTransitionTracker];
832
917
  }
@@ -846,6 +931,7 @@ static BOOL TrueSheetIsPhoneIdiom(void) {
846
931
  // the footer out of the auto detent.
847
932
  _keyboardSheetGrown = NO;
848
933
  _keyboardHeight = 0;
934
+ _minimizedKeyboardOverlap = 0;
849
935
 
850
936
  [self emitDidDismissEvents];
851
937
  }
@@ -902,7 +988,10 @@ static BOOL TrueSheetIsPhoneIdiom(void) {
902
988
  animateAlongsideTransition:^(id<UIViewControllerTransitionCoordinatorContext> context) {
903
989
  [self.sheet invalidateDetents];
904
990
  }
905
- completion:nil];
991
+ completion:^(id<UIViewControllerTransitionCoordinatorContext> context) {
992
+ // Rotating a half-open foldable swaps between book and tabletop
993
+ [self updateHingeState];
994
+ }];
906
995
  }
907
996
  }
908
997
 
@@ -943,6 +1032,10 @@ static BOOL TrueSheetIsPhoneIdiom(void) {
943
1032
  }
944
1033
  }
945
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
+
946
1039
  if (_pendingDetentIndex >= 0) {
947
1040
  NSInteger pendingIndex = _pendingDetentIndex;
948
1041
  _pendingDetentIndex = -1;
@@ -1118,13 +1211,6 @@ static BOOL TrueSheetIsPhoneIdiom(void) {
1118
1211
  CGFloat position = self.livePosition;
1119
1212
 
1120
1213
  if (self.isBeingDismissed) {
1121
- // Emit only once the dismiss is committed: on release UIKit moves the
1122
- // model frame off-screen, while a cancelled drag rewinds it back to the
1123
- // detent (isBeingDismissed stays YES during the rewind).
1124
- if (self.currentPosition >= self.screenHeight) {
1125
- [self emitWillDismissEvents];
1126
- }
1127
-
1128
1214
  // Hide blur at the end of dismiss to prevent UIVisualEffectView
1129
1215
  // from causing a flicker/flash at the bottom edge of the sheet.
1130
1216
  if (self.screenHeight - position < 1) {
@@ -1145,6 +1231,7 @@ static BOOL TrueSheetIsPhoneIdiom(void) {
1145
1231
  // measured frame; the next unobstructed settle learns.
1146
1232
  if (self.presentedViewController == nil) {
1147
1233
  [self learnOffsetForDetentIndex:index];
1234
+ [self correctHingeOffsetForIndex:index];
1148
1235
  }
1149
1236
 
1150
1237
  [self emitChangePositionDelegateWithPosition:self.currentPosition realtime:NO debug:debug];
@@ -1304,7 +1391,7 @@ static BOOL TrueSheetIsPhoneIdiom(void) {
1304
1391
  }
1305
1392
 
1306
1393
  - (void)learnOffsetForDetentIndex:(NSInteger)index {
1307
- if (_keyboardSheetGrown) {
1394
+ if (_keyboardSheetGrown || _minimizedKeyboardOverlap > 0) {
1308
1395
  return;
1309
1396
  }
1310
1397
  [_detentCalculator learnOffsetForDetentIndex:index];
@@ -1338,6 +1425,146 @@ static BOOL TrueSheetIsPhoneIdiom(void) {
1338
1425
  return [_detentCalculator detentValueForIndex:index];
1339
1426
  }
1340
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
+
1341
1568
  #pragma mark - Sheet Configuration
1342
1569
 
1343
1570
  /**
@@ -1359,12 +1586,19 @@ static BOOL TrueSheetIsPhoneIdiom(void) {
1359
1586
  _autoDetentHeight = autoHeight;
1360
1587
  _peekDetentHeight = peekHeight;
1361
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, *)) {
1362
1598
  [self.sheet animateChanges:^{
1363
- self->_pendingContentSizeChange = YES;
1364
1599
  [self.sheet invalidateDetents];
1365
1600
  }];
1366
1601
  }
1367
- // iOS 15: detents are fixed medium/large — size changes can't affect them.
1368
1602
  }
1369
1603
 
1370
1604
  - (void)setupSheetDetentsForDetentsChange {
@@ -1381,6 +1615,7 @@ static BOOL TrueSheetIsPhoneIdiom(void) {
1381
1615
 
1382
1616
  NSMutableArray<UISheetPresentationControllerDetent *> *detents = [NSMutableArray array];
1383
1617
  [_detentCalculator clearResolvedHeights];
1618
+ _hingeCorrections = 0;
1384
1619
 
1385
1620
  _autoDetentHeight = [_detentCalculator autoHeightForKeyboardHeight:_keyboardHeight];
1386
1621
  _peekDetentHeight = [_detentCalculator peekHeight];
@@ -1503,24 +1738,66 @@ static BOOL TrueSheetIsPhoneIdiom(void) {
1503
1738
  : maxDetentValue;
1504
1739
 
1505
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
+ }
1506
1772
 
1507
1773
  // Only the last detent may occupy the full maximum. Two detents
1508
1774
  // resolving to the same height (e.g. a capped auto and 1) make
1509
1775
  // UIKit skip the stack's deck animation when presenting a child
1510
1776
  // sheet and snap the sheet behind into place instead.
1511
- NSInteger lastIndex = (NSInteger)self.detents.count - 1;
1512
1777
  if (adjustedHeight >= maxValue && index < lastIndex) {
1513
1778
  maxValue -= lastIndex - index;
1514
1779
  }
1515
1780
 
1516
1781
  CGFloat resolved = fmin(adjustedHeight, maxValue);
1517
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
+
1518
1793
  NSMutableArray *heights = self->_detentCalculator.resolvedDetentHeights;
1519
1794
  if (heights && index >= 0 && index < (NSInteger)heights.count) {
1520
1795
  heights[index] = @(resolved);
1521
1796
  }
1522
1797
 
1523
- 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);
1524
1801
  }];
1525
1802
  }
1526
1803
 
@@ -1716,11 +1993,7 @@ static BOOL TrueSheetIsPhoneIdiom(void) {
1716
1993
  _grabberView.onDecrement = nil;
1717
1994
  }
1718
1995
 
1719
- // Only the topmost sheet may refresh, or a parent's prop update would steal the
1720
- // window accessibility override from the child presented over it.
1721
- if (_isPresented && self.isTopmostPresentedController) {
1722
- [self setupAccessibilityContainer];
1723
- }
1996
+ [self refreshAccessibilityContainer];
1724
1997
  }
1725
1998
 
1726
1999
  - (void)handleGrabberTap {
@@ -1861,13 +2134,43 @@ static BOOL TrueSheetIsPhoneIdiom(void) {
1861
2134
  #pragma mark - UISheetPresentationControllerDelegate
1862
2135
 
1863
2136
  - (BOOL)presentationControllerShouldDismiss:(UIPresentationController *)presentationController {
1864
- 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;
1865
2162
  }
1866
2163
 
1867
2164
  // UIKit calls this after shouldDismiss returns NO — the sheet has already
1868
2165
  // bounced back, so JS can confirm and dismiss programmatically (Apple's
1869
2166
  // unsaved-changes pattern).
1870
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
+ }
1871
2174
  [self.delegate viewControllerDidAttemptDismiss];
1872
2175
  }
1873
2176
 
@@ -76,6 +76,17 @@ NS_ASSUME_NONNULL_BEGIN
76
76
  */
77
77
  - (CGFloat)resolvedHeightForIndex:(NSInteger)index;
78
78
 
79
+ /**
80
+ Returns the offset learned for this detent itself, or 0 when none is learned.
81
+ */
82
+ - (CGFloat)learnedOffsetForIndex:(NSInteger)index;
83
+
84
+ /**
85
+ Sets the offset this detent is expected to rest with, unless its learned
86
+ offset is already within a point of it.
87
+ */
88
+ - (void)assumeOffset:(CGFloat)offset forIndex:(NSInteger)index;
89
+
79
90
  /**
80
91
  Finds the segment between detents for a given position.
81
92
  Returns YES if position is between two detents, NO if at edges.
@@ -80,13 +80,25 @@
80
80
  return detentValue * self.delegate.screenHeight;
81
81
  }
82
82
 
83
- - (CGFloat)offsetForIndex:(NSInteger)index {
84
- // Use this detent's own offset if available
83
+ - (CGFloat)learnedOffsetForIndex:(NSInteger)index {
85
84
  if (index >= 0 && index < (NSInteger)_resolvedDetentOffsets.count) {
86
- CGFloat offset = [_resolvedDetentOffsets[index] doubleValue];
87
- if (offset != 0)
88
- return offset;
85
+ return [_resolvedDetentOffsets[index] doubleValue];
86
+ }
87
+ return 0;
88
+ }
89
+
90
+ - (void)assumeOffset:(CGFloat)offset forIndex:(NSInteger)index {
91
+ if (index >= 0 && index < (NSInteger)_resolvedDetentOffsets.count &&
92
+ fabs([_resolvedDetentOffsets[index] doubleValue] - offset) > 1) {
93
+ _resolvedDetentOffsets[index] = @(offset);
89
94
  }
95
+ }
96
+
97
+ - (CGFloat)offsetForIndex:(NSInteger)index {
98
+ // Use this detent's own offset if available
99
+ CGFloat learnedOffset = [self learnedOffsetForIndex:index];
100
+ if (learnedOffset != 0)
101
+ return learnedOffset;
90
102
 
91
103
  // Fall back to any known offset
92
104
  for (NSInteger i = 0; i < (NSInteger)_resolvedDetentOffsets.count; i++) {