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

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 (244) hide show
  1. package/README.md +28 -5
  2. package/android/build.gradle +23 -1
  3. package/android/proguard-rules.pro +3 -0
  4. package/android/src/main/java/com/lodev09/truesheet/TrueSheetContainerView.kt +50 -16
  5. package/android/src/main/java/com/lodev09/truesheet/TrueSheetContentView.kt +303 -184
  6. package/android/src/main/java/com/lodev09/truesheet/TrueSheetContentViewManager.kt +5 -1
  7. package/android/src/main/java/com/lodev09/truesheet/TrueSheetFooterView.kt +38 -109
  8. package/android/src/main/java/com/lodev09/truesheet/TrueSheetModule.kt +13 -0
  9. package/android/src/main/java/com/lodev09/truesheet/TrueSheetOverlayView.kt +127 -0
  10. package/android/src/main/java/com/lodev09/truesheet/TrueSheetOverlayViewManager.kt +45 -0
  11. package/android/src/main/java/com/lodev09/truesheet/TrueSheetPackage.kt +2 -1
  12. package/android/src/main/java/com/lodev09/truesheet/TrueSheetStateUpdater.kt +29 -3
  13. package/android/src/main/java/com/lodev09/truesheet/TrueSheetView.kt +234 -66
  14. package/android/src/main/java/com/lodev09/truesheet/TrueSheetViewController.kt +491 -186
  15. package/android/src/main/java/com/lodev09/truesheet/TrueSheetViewManager.kt +64 -10
  16. package/android/src/main/java/com/lodev09/truesheet/core/RNScreensEventObserver.kt +31 -14
  17. package/android/src/main/java/com/lodev09/truesheet/core/TrueSheetBottomSheetBehavior.kt +135 -0
  18. package/android/src/main/java/com/lodev09/truesheet/core/TrueSheetBottomSheetView.kt +23 -23
  19. package/android/src/main/java/com/lodev09/truesheet/core/TrueSheetCoordinatorLayout.kt +130 -6
  20. package/android/src/main/java/com/lodev09/truesheet/core/TrueSheetDetentCalculator.kt +104 -39
  21. package/android/src/main/java/com/lodev09/truesheet/core/TrueSheetDimView.kt +14 -17
  22. package/android/src/main/java/com/lodev09/truesheet/core/TrueSheetGrabberView.kt +2 -4
  23. package/android/src/main/java/com/lodev09/truesheet/core/TrueSheetOverlayRootView.kt +148 -0
  24. package/android/src/main/java/com/lodev09/truesheet/core/TrueSheetStackManager.kt +33 -40
  25. package/android/src/main/java/com/lodev09/truesheet/events/TrueSheetLifecycleEvents.kt +15 -0
  26. package/android/src/main/java/com/lodev09/truesheet/utils/ColorUtils.kt +19 -0
  27. package/android/src/main/java/com/lodev09/truesheet/utils/KeyboardUtils.kt +15 -2
  28. package/android/src/main/java/com/lodev09/truesheet/utils/ScreenUtils.kt +0 -12
  29. package/android/src/main/java/com/lodev09/truesheet/utils/ViewUtils.kt +19 -7
  30. package/android/src/main/jni/TrueSheetSpec.h +1 -0
  31. package/android/src/main/jni/TrueSheetStateUpdater.cpp +37 -6
  32. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetContentViewComponentDescriptor.h +1 -9
  33. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetContentViewShadowNode.cpp +114 -30
  34. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetContentViewShadowNode.h +30 -7
  35. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetContentViewState.cpp +2 -2
  36. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetContentViewState.h +9 -8
  37. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetFooterViewComponentDescriptor.h +3 -5
  38. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetFooterViewShadowNode.cpp +14 -3
  39. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetFooterViewShadowNode.h +5 -6
  40. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetFooterViewState.cpp +1 -1
  41. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetFooterViewState.h +10 -6
  42. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetInsets.cpp +1 -1
  43. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetInsets.h +1 -1
  44. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetLayoutUtils.h +43 -0
  45. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetOverlayViewComponentDescriptor.h +21 -0
  46. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetOverlayViewShadowNode.cpp +17 -0
  47. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetOverlayViewShadowNode.h +33 -0
  48. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetViewComponentDescriptor.h +3 -5
  49. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetViewShadowNode.cpp +7 -40
  50. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetViewShadowNode.h +3 -7
  51. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetViewState.cpp +3 -5
  52. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetViewState.h +4 -5
  53. package/ios/TrueSheetContainerView.h +23 -7
  54. package/ios/TrueSheetContainerView.mm +45 -25
  55. package/ios/TrueSheetContentView.h +44 -15
  56. package/ios/TrueSheetContentView.mm +221 -190
  57. package/ios/TrueSheetFooterView.mm +133 -28
  58. package/ios/TrueSheetModule.h +2 -7
  59. package/ios/TrueSheetModule.mm +18 -18
  60. package/ios/TrueSheetOverlayView.h +42 -0
  61. package/ios/TrueSheetOverlayView.mm +175 -0
  62. package/ios/TrueSheetView.h +2 -0
  63. package/ios/TrueSheetView.mm +268 -52
  64. package/ios/TrueSheetViewController.h +36 -3
  65. package/ios/TrueSheetViewController.mm +797 -102
  66. package/ios/core/RNScreensEventObserver.mm +31 -28
  67. package/ios/core/TrueSheetBlurView.h +0 -2
  68. package/ios/core/TrueSheetBlurView.mm +3 -36
  69. package/ios/core/TrueSheetDetentCalculator.h +18 -1
  70. package/ios/core/TrueSheetDetentCalculator.mm +44 -9
  71. package/ios/core/TrueSheetDimView.h +27 -0
  72. package/ios/core/TrueSheetDimView.mm +41 -0
  73. package/ios/core/TrueSheetGrabberView.mm +13 -3
  74. package/ios/core/TrueSheetKeyboardObserver.h +7 -0
  75. package/ios/core/TrueSheetKeyboardObserver.mm +107 -15
  76. package/ios/core/TrueSheetOverlayContainerView.h +31 -0
  77. package/ios/core/TrueSheetOverlayContainerView.mm +35 -0
  78. package/ios/events/TrueSheetLifecycleEvents.h +2 -0
  79. package/ios/events/TrueSheetLifecycleEvents.mm +8 -0
  80. package/ios/tvos/TrueSheetStubs.mm +12 -0
  81. package/ios/utils/UIView+ScrollEdgeInteraction.h +1 -0
  82. package/ios/utils/UIView+ScrollEdgeInteraction.mm +24 -2
  83. package/lib/module/TrueSheet.js +222 -54
  84. package/lib/module/TrueSheet.js.map +1 -1
  85. package/lib/module/TrueSheet.web.js +364 -275
  86. package/lib/module/TrueSheet.web.js.map +1 -1
  87. package/lib/module/TrueSheetBackground.js +15 -0
  88. package/lib/module/TrueSheetBackground.js.map +1 -0
  89. package/lib/module/TrueSheetOverlay.js +30 -0
  90. package/lib/module/TrueSheetOverlay.js.map +1 -0
  91. package/lib/module/TrueSheetOverlay.web.js +35 -0
  92. package/lib/module/TrueSheetOverlay.web.js.map +1 -0
  93. package/lib/module/TrueSheetPeek.web.js +20 -15
  94. package/lib/module/TrueSheetPeek.web.js.map +1 -1
  95. package/lib/module/TrueSheetProvider.web.js +13 -9
  96. package/lib/module/TrueSheetProvider.web.js.map +1 -1
  97. package/lib/module/detentBackgrounds.js +21 -0
  98. package/lib/module/detentBackgrounds.js.map +1 -0
  99. package/lib/module/fabric/TrueSheetContentViewNativeComponent.ts +1 -1
  100. package/lib/module/fabric/TrueSheetFooterViewNativeComponent.ts +2 -3
  101. package/lib/module/fabric/TrueSheetOverlayViewNativeComponent.ts +12 -0
  102. package/lib/module/fabric/TrueSheetViewNativeComponent.ts +89 -73
  103. package/lib/module/index.js +1 -0
  104. package/lib/module/index.js.map +1 -1
  105. package/lib/module/mocks/index.js +18 -2
  106. package/lib/module/mocks/index.js.map +1 -1
  107. package/lib/module/mocks/navigationExpoRouter.js.map +1 -1
  108. package/lib/module/navigation/base-types.js +2 -0
  109. package/lib/module/navigation/base-types.js.map +1 -0
  110. package/lib/module/navigation/createTrueSheetRouter.js +158 -100
  111. package/lib/module/navigation/createTrueSheetRouter.js.map +1 -1
  112. package/lib/module/navigation/expo-router/base-types.js +2 -0
  113. package/lib/module/navigation/expo-router/base-types.js.map +1 -0
  114. package/lib/module/navigation/expo-router/extendTrueSheetRouter.js +71 -0
  115. package/lib/module/navigation/expo-router/extendTrueSheetRouter.js.map +1 -0
  116. package/lib/module/navigation/expo-router/index.js +18 -7
  117. package/lib/module/navigation/expo-router/index.js.map +1 -1
  118. package/lib/module/navigation/expo-router/types.js +4 -0
  119. package/lib/module/navigation/expo-router/types.js.map +1 -0
  120. package/lib/module/navigation/index.js.map +1 -1
  121. package/lib/module/navigation/screen/useSheetScreenState.js +1 -0
  122. package/lib/module/navigation/screen/useSheetScreenState.js.map +1 -1
  123. package/lib/module/scrollable.js +28 -0
  124. package/lib/module/scrollable.js.map +1 -0
  125. package/lib/module/web/constants.js +1 -1
  126. package/lib/module/web/constants.js.map +1 -1
  127. package/lib/module/web/keyboard.js +69 -0
  128. package/lib/module/web/keyboard.js.map +1 -0
  129. package/lib/module/web/layout.js +168 -0
  130. package/lib/module/web/layout.js.map +1 -0
  131. package/lib/module/web/vaul/constants.js +9 -0
  132. package/lib/module/web/vaul/constants.js.map +1 -1
  133. package/lib/module/web/vaul/context.js +4 -0
  134. package/lib/module/web/vaul/context.js.map +1 -1
  135. package/lib/module/web/vaul/helpers.js +1 -0
  136. package/lib/module/web/vaul/helpers.js.map +1 -1
  137. package/lib/module/web/vaul/index.js +83 -21
  138. package/lib/module/web/vaul/index.js.map +1 -1
  139. package/lib/module/web/vaul/use-snap-points.js +57 -28
  140. package/lib/module/web/vaul/use-snap-points.js.map +1 -1
  141. package/lib/typescript/src/TrueSheet.d.ts +18 -1
  142. package/lib/typescript/src/TrueSheet.d.ts.map +1 -1
  143. package/lib/typescript/src/TrueSheet.types.d.ts +209 -35
  144. package/lib/typescript/src/TrueSheet.types.d.ts.map +1 -1
  145. package/lib/typescript/src/TrueSheet.web.d.ts.map +1 -1
  146. package/lib/typescript/src/TrueSheetBackground.d.ts +3 -0
  147. package/lib/typescript/src/TrueSheetBackground.d.ts.map +1 -0
  148. package/lib/typescript/src/TrueSheetOverlay.d.ts +8 -0
  149. package/lib/typescript/src/TrueSheetOverlay.d.ts.map +1 -0
  150. package/lib/typescript/src/TrueSheetOverlay.web.d.ts +8 -0
  151. package/lib/typescript/src/TrueSheetOverlay.web.d.ts.map +1 -0
  152. package/lib/typescript/src/TrueSheetPeek.web.d.ts +8 -4
  153. package/lib/typescript/src/TrueSheetPeek.web.d.ts.map +1 -1
  154. package/lib/typescript/src/TrueSheetProvider.web.d.ts +5 -1
  155. package/lib/typescript/src/TrueSheetProvider.web.d.ts.map +1 -1
  156. package/lib/typescript/src/detentBackgrounds.d.ts +10 -0
  157. package/lib/typescript/src/detentBackgrounds.d.ts.map +1 -0
  158. package/lib/typescript/src/fabric/TrueSheetFooterViewNativeComponent.d.ts +2 -3
  159. package/lib/typescript/src/fabric/TrueSheetFooterViewNativeComponent.d.ts.map +1 -1
  160. package/lib/typescript/src/fabric/TrueSheetOverlayViewNativeComponent.d.ts +6 -0
  161. package/lib/typescript/src/fabric/TrueSheetOverlayViewNativeComponent.d.ts.map +1 -0
  162. package/lib/typescript/src/fabric/TrueSheetViewNativeComponent.d.ts +76 -65
  163. package/lib/typescript/src/fabric/TrueSheetViewNativeComponent.d.ts.map +1 -1
  164. package/lib/typescript/src/index.d.ts +1 -0
  165. package/lib/typescript/src/index.d.ts.map +1 -1
  166. package/lib/typescript/src/mocks/index.d.ts +4 -0
  167. package/lib/typescript/src/mocks/index.d.ts.map +1 -1
  168. package/lib/typescript/src/mocks/navigationExpoRouter.d.ts +3 -3
  169. package/lib/typescript/src/mocks/navigationExpoRouter.d.ts.map +1 -1
  170. package/lib/typescript/src/navigation/base-types.d.ts +9 -0
  171. package/lib/typescript/src/navigation/base-types.d.ts.map +1 -0
  172. package/lib/typescript/src/navigation/createTrueSheetRouter.d.ts +36 -10
  173. package/lib/typescript/src/navigation/createTrueSheetRouter.d.ts.map +1 -1
  174. package/lib/typescript/src/navigation/expo-router/base-types.d.ts +8 -0
  175. package/lib/typescript/src/navigation/expo-router/base-types.d.ts.map +1 -0
  176. package/lib/typescript/src/navigation/expo-router/extendTrueSheetRouter.d.ts +33 -0
  177. package/lib/typescript/src/navigation/expo-router/extendTrueSheetRouter.d.ts.map +1 -0
  178. package/lib/typescript/src/navigation/expo-router/index.d.ts +9 -6
  179. package/lib/typescript/src/navigation/expo-router/index.d.ts.map +1 -1
  180. package/lib/typescript/src/navigation/expo-router/types.d.ts +44 -0
  181. package/lib/typescript/src/navigation/expo-router/types.d.ts.map +1 -0
  182. package/lib/typescript/src/navigation/index.d.ts +3 -4
  183. package/lib/typescript/src/navigation/index.d.ts.map +1 -1
  184. package/lib/typescript/src/navigation/screen/useSheetScreenState.d.ts +2 -1
  185. package/lib/typescript/src/navigation/screen/useSheetScreenState.d.ts.map +1 -1
  186. package/lib/typescript/src/navigation/types.d.ts +43 -6
  187. package/lib/typescript/src/navigation/types.d.ts.map +1 -1
  188. package/lib/typescript/src/scrollable.d.ts +2 -0
  189. package/lib/typescript/src/scrollable.d.ts.map +1 -0
  190. package/lib/typescript/src/web/constants.d.ts +1 -1
  191. package/lib/typescript/src/web/constants.d.ts.map +1 -1
  192. package/lib/typescript/src/web/keyboard.d.ts +15 -0
  193. package/lib/typescript/src/web/keyboard.d.ts.map +1 -0
  194. package/lib/typescript/src/web/layout.d.ts +20 -0
  195. package/lib/typescript/src/web/layout.d.ts.map +1 -0
  196. package/lib/typescript/src/web/vaul/constants.d.ts +3 -0
  197. package/lib/typescript/src/web/vaul/constants.d.ts.map +1 -1
  198. package/lib/typescript/src/web/vaul/context.d.ts +5 -1
  199. package/lib/typescript/src/web/vaul/context.d.ts.map +1 -1
  200. package/lib/typescript/src/web/vaul/helpers.d.ts +1 -0
  201. package/lib/typescript/src/web/vaul/helpers.d.ts.map +1 -1
  202. package/lib/typescript/src/web/vaul/index.d.ts +20 -1
  203. package/lib/typescript/src/web/vaul/index.d.ts.map +1 -1
  204. package/lib/typescript/src/web/vaul/types.d.ts +9 -0
  205. package/lib/typescript/src/web/vaul/types.d.ts.map +1 -1
  206. package/lib/typescript/src/web/vaul/use-snap-points.d.ts +6 -4
  207. package/lib/typescript/src/web/vaul/use-snap-points.d.ts.map +1 -1
  208. package/package.json +18 -8
  209. package/react-native.config.js +1 -0
  210. package/src/TrueSheet.tsx +257 -62
  211. package/src/TrueSheet.types.ts +225 -35
  212. package/src/TrueSheet.web.tsx +445 -333
  213. package/src/TrueSheetBackground.tsx +17 -0
  214. package/src/TrueSheetOverlay.tsx +24 -0
  215. package/src/TrueSheetOverlay.web.tsx +33 -0
  216. package/src/TrueSheetPeek.web.tsx +25 -23
  217. package/src/TrueSheetProvider.web.tsx +22 -13
  218. package/src/detentBackgrounds.ts +24 -0
  219. package/src/fabric/TrueSheetContentViewNativeComponent.ts +1 -1
  220. package/src/fabric/TrueSheetFooterViewNativeComponent.ts +2 -3
  221. package/src/fabric/TrueSheetOverlayViewNativeComponent.ts +12 -0
  222. package/src/fabric/TrueSheetViewNativeComponent.ts +89 -73
  223. package/src/index.ts +1 -0
  224. package/src/mocks/index.ts +17 -2
  225. package/src/mocks/navigationExpoRouter.ts +6 -5
  226. package/src/navigation/base-types.ts +20 -0
  227. package/src/navigation/createTrueSheetRouter.ts +220 -144
  228. package/src/navigation/expo-router/base-types.ts +18 -0
  229. package/src/navigation/expo-router/extendTrueSheetRouter.ts +99 -0
  230. package/src/navigation/expo-router/index.ts +41 -30
  231. package/src/navigation/expo-router/types.ts +116 -0
  232. package/src/navigation/index.ts +6 -7
  233. package/src/navigation/screen/useSheetScreenState.ts +2 -0
  234. package/src/navigation/types.ts +81 -23
  235. package/src/scrollable.ts +25 -0
  236. package/src/web/constants.ts +1 -1
  237. package/src/web/keyboard.ts +79 -0
  238. package/src/web/layout.ts +196 -0
  239. package/src/web/vaul/constants.ts +9 -0
  240. package/src/web/vaul/context.ts +7 -1
  241. package/src/web/vaul/helpers.ts +5 -0
  242. package/src/web/vaul/index.tsx +113 -16
  243. package/src/web/vaul/types.ts +10 -0
  244. package/src/web/vaul/use-snap-points.ts +86 -33
@@ -9,12 +9,15 @@
9
9
  #import "TrueSheetViewController.h"
10
10
  #import "TrueSheetContainerView.h"
11
11
  #import "TrueSheetContentView.h"
12
+ #import "TrueSheetOverlayView.h"
12
13
  #import "core/TrueSheetBlurView.h"
13
14
  #import "core/TrueSheetDetentCalculator.h"
15
+ #import "core/TrueSheetDimView.h"
14
16
  #import "core/TrueSheetGrabberView.h"
15
17
  #import "utils/BlurUtil.h"
16
18
  #import "utils/GestureUtil.h"
17
19
  #import "utils/PlatformUtil.h"
20
+ #import "utils/UIView+FirstResponder.h"
18
21
  #import "utils/WindowUtil.h"
19
22
 
20
23
  #import <React/RCTLog.h>
@@ -45,7 +48,12 @@ static char TrueSheetAccessibilityWindowPreviousElementsKey;
45
48
  - (void)restoreWindowAccessibilityElements;
46
49
  - (void)setSheetAccessibilityElementsHidden:(BOOL)hidden;
47
50
  - (void)setAccessibilityContentElement:(UIView *)contentView;
51
+ - (NSArray *)accessibilityGrabberElements;
48
52
  - (void)endInteractiveDismissState;
53
+ - (void)updateHingeState;
54
+ - (void)correctHingeOffsetForIndex:(NSInteger)index;
55
+ - (CGFloat)tabletopHingeEdge;
56
+ - (CGFloat)hingeOffsetForHeight:(CGFloat)height;
49
57
  - (void)animateInteractiveContainerToTransform:(CGAffineTransform)transform
50
58
  duration:(NSTimeInterval)duration
51
59
  allowUserInteraction:(BOOL)allowUserInteraction
@@ -53,17 +61,39 @@ static char TrueSheetAccessibilityWindowPreviousElementsKey;
53
61
 
54
62
  @end
55
63
 
64
+ // VoiceOver delivers the two-finger Z (escape) gesture by walking up the view
65
+ // hierarchy from the focused element, so the sheet's root view handles it.
66
+ @interface TrueSheetControllerView : UIView
67
+ @property (nonatomic, weak) TrueSheetViewController *controller;
68
+ @end
69
+
70
+ @implementation TrueSheetControllerView
71
+
72
+ - (BOOL)accessibilityPerformEscape {
73
+ return [self.controller accessibilityPerformEscape];
74
+ }
75
+
76
+ @end
77
+
56
78
  @implementation TrueSheetViewController {
57
79
  TrueSheetPositionState _lastEmittedPositionState;
58
80
  CGSize _lastReportedSize;
81
+ // Net of the footer's baked-in inset, snapshotted together: the inset updates
82
+ // with the footer's layout, but a size change mid-present is deferred to
83
+ // didPresent, so subtracting it live pairs a new inset with a stale height.
59
84
  CGFloat _autoDetentHeight;
60
85
  CGFloat _peekDetentHeight;
61
86
  NSInteger _pendingDetentIndex;
87
+ NSInteger _displayedBackgroundIndex;
88
+ BOOL _usesBackgroundEffects;
89
+ BOOL _refusedPanDismiss;
62
90
 
63
91
  // Present/dismiss tracking: a display link scoped to the transition
64
92
  // coordinator — started alongside the transition, stopped in its completion.
65
93
  CADisplayLink *_transitionLink;
66
94
  BOOL _isTransitioning;
95
+ // Where the sheet rests during the transition: the target on present, the start on dismiss
96
+ CGFloat _transitionRestPosition;
67
97
 
68
98
  BOOL _pendingContentSizeChange;
69
99
  BOOL _pendingDetentsChange;
@@ -74,6 +104,7 @@ static char TrueSheetAccessibilityWindowPreviousElementsKey;
74
104
  // for the next present (beats the float-threshold guess for re-presents).
75
105
  BOOL _hasObservedBottomInset;
76
106
  CGFloat _observedBottomInset;
107
+ BOOL _isRefreshingSafeArea;
77
108
 
78
109
  BOOL _isInteractiveDismiss;
79
110
  CGFloat _interactiveStartPosition;
@@ -82,14 +113,28 @@ static char TrueSheetAccessibilityWindowPreviousElementsKey;
82
113
  CADisplayLink *_interactivePositionLink;
83
114
 
84
115
  __weak TrueSheetViewController *_parentSheetController;
116
+ __weak UIView *_presenterFirstResponder;
85
117
 
86
118
  UIView *_anchorView;
87
119
 
88
120
  __weak UIWindow *_accessibilityWindow;
121
+ BOOL _appliedAccessibilityFooterElements;
89
122
 
90
123
  TrueSheetBlurView *_blurView;
91
124
  TrueSheetGrabberView *_grabberView;
92
125
  TrueSheetDetentCalculator *_detentCalculator;
126
+ TrueSheetDimView *_dimView;
127
+
128
+ // Foldable hinge (iOS 27.1+). Tabletop resolves on demand from the window's
129
+ // division region; these track the last applied posture to detect changes.
130
+ BOOL _hingeStateKnown;
131
+ CGFloat _hingeEdge;
132
+ NSInteger _hingeRestoreIndex;
133
+ NSInteger _hingeCorrections;
134
+ // Offset learned at the hinge, shared by every hinge-placed detent
135
+ CGFloat _hingeOffset;
136
+ // Offset each hinge-placed detent last resolved with, checked on settle
137
+ NSMutableDictionary<NSNumber *, NSNumber *> *_hingeOffsets;
93
138
  }
94
139
 
95
140
  #pragma mark - Initialization
@@ -97,6 +142,8 @@ static char TrueSheetAccessibilityWindowPreviousElementsKey;
97
142
  - (instancetype)init {
98
143
  if (self = [super initWithNibName:nil bundle:nil]) {
99
144
  _detents = @[ @0.5, @1 ];
145
+ _detentBackgrounds = @[];
146
+ _displayedBackgroundIndex = 0;
100
147
  _contentHeight = @(0);
101
148
  _headerHeight = @(0);
102
149
  _footerHeight = @(0);
@@ -104,8 +151,11 @@ static char TrueSheetAccessibilityWindowPreviousElementsKey;
104
151
  _grabber = YES;
105
152
  _draggable = YES;
106
153
  _scrollingExpandsSheet = YES;
154
+ _footerAvoidsKeyboard = YES;
107
155
  _dismissible = YES;
108
156
  _dimmed = YES;
157
+ _dimColor = nil;
158
+ _dimOpacity = nil;
109
159
  _dimmedDetentIndex = @(0);
110
160
  _presentation = facebook::react::TrueSheetViewPresentation::Page;
111
161
  _lastEmittedPositionState = (TrueSheetPositionState){0, 0, 0};
@@ -118,10 +168,16 @@ static char TrueSheetAccessibilityWindowPreviousElementsKey;
118
168
  _activeDetentIndex = -1;
119
169
  _pendingDetentIndex = -1;
120
170
 
121
- _blurInteraction = YES;
122
171
  _insetAdjustment = TrueSheetViewInsetAdjustment::Automatic;
172
+ _hingeRestoreIndex = -1;
173
+ _hingeOffsets = [NSMutableDictionary dictionary];
123
174
  _detentCalculator = [[TrueSheetDetentCalculator alloc] init];
124
175
  _detentCalculator.delegate = self;
176
+
177
+ [[NSNotificationCenter defaultCenter] addObserver:self
178
+ selector:@selector(overlayWindowDidChange)
179
+ name:TrueSheetOverlayWindowDidChangeNotification
180
+ object:nil];
125
181
  }
126
182
  return self;
127
183
  }
@@ -206,11 +262,22 @@ static char TrueSheetAccessibilityWindowPreviousElementsKey;
206
262
  return self.currentPosition + (presentationY - modelY) + containerTy;
207
263
  }
208
264
 
265
+ // Some callers run before this controller's view is attached (e.g. mid-presentation
266
+ // detent resolution), so fall back to the presenter's window.
267
+ - (UIWindow *)hostWindow {
268
+ return self.view.window ?: self.presentingViewController.view.window;
269
+ }
270
+
209
271
  - (CGFloat)screenHeight {
210
- UIWindow *window = self.view.window;
272
+ // In a windowed iPad app (Stage Manager, iPadOS 26 windowing) the window can be shorter than
273
+ // UIScreen.mainScreen, so prefer the window's height.
274
+ UIWindow *window = self.hostWindow;
211
275
  return window ? window.bounds.size.height : UIScreen.mainScreen.bounds.size.height;
212
276
  }
213
277
 
278
+ // Gap UIKit leaves between a floating sheet (iOS 26+) and the screen's bottom edge
279
+ static const CGFloat TrueSheetFloatingSheetInset = 8;
280
+
214
281
  // Only phone sheets absorb the bottom safe-area inset — non-phone idioms
215
282
  // (iPad form sheets) are inset from the screen edge by UIKit.
216
283
  static BOOL TrueSheetIsPhoneIdiom(void) {
@@ -250,7 +317,6 @@ static BOOL TrueSheetIsPhoneIdiom(void) {
250
317
  * unpadded.
251
318
  */
252
319
  - (CGFloat)maxNaturalDetentHeight {
253
- CGFloat applied = [self.footerHeight floatValue] > 0 ? _appliedFooterBottomInset : 0;
254
320
  CGFloat maxHeight = 0;
255
321
 
256
322
  for (id detent in self.detents) {
@@ -261,11 +327,9 @@ static BOOL TrueSheetIsPhoneIdiom(void) {
261
327
  CGFloat value = [detent doubleValue];
262
328
  CGFloat height = 0;
263
329
  if (value == -1) {
264
- // Auto counts a relative footer's (padded) height
265
- height = [_detentCalculator autoHeight] - (_absoluteFooter ? 0 : applied);
330
+ height = [_detentCalculator autoHeightForKeyboardHeight:0] - [self autoDetentBottomInset];
266
331
  } else if (value == -2) {
267
- // Peek counts an absolute footer's (padded) height
268
- height = [_detentCalculator peekHeight] - (_absoluteFooter ? applied : 0);
332
+ height = [_detentCalculator peekHeight] - [self peekDetentBottomInset];
269
333
  } else if (value > 0 && value <= 1) {
270
334
  height = value * self.screenHeight;
271
335
  }
@@ -297,6 +361,12 @@ static BOOL TrueSheetIsPhoneIdiom(void) {
297
361
  // wrong leaves the safe-area region unfilled (gap) or double-padded.
298
362
  UIView *view = self.viewIfLoaded;
299
363
  if (view.window) {
364
+ // Mid-present, hold the expected inset over a stale zero so the footer
365
+ // doesn't shrink and regrow — viewDidAppear forces the real value.
366
+ if (!_isPresented && [self hasStaleBottomSafeArea]) {
367
+ return [self bottomSafeAreaForHeight:view.bounds.size.height];
368
+ }
369
+
300
370
  _hasObservedBottomInset = YES;
301
371
  _observedBottomInset = view.safeAreaInsets.bottom;
302
372
  return _observedBottomInset;
@@ -313,14 +383,27 @@ static BOOL TrueSheetIsPhoneIdiom(void) {
313
383
  return [self bottomSafeAreaForHeight:[self maxNaturalDetentHeight]];
314
384
  }
315
385
 
386
+ // Re-resolve alongside the keyboard animation as its overlap with the pinned
387
+ // footer changes, including keyboard height changes while already visible.
388
+ - (void)setKeyboardHeight:(CGFloat)keyboardHeight {
389
+ if (_keyboardHeight == keyboardHeight) {
390
+ return;
391
+ }
392
+ _keyboardHeight = keyboardHeight;
393
+
394
+ if (_footerInsetAdjustment && !_footerAvoidsKeyboard && self.isPresented) {
395
+ [self setupSheetDetentsForSizeChange];
396
+ }
397
+ }
398
+
316
399
  /**
317
- * Bottom inset excluded from the auto detent. A relative footer owns the
318
- * sheet's bottom edge and carries the inset in its height (see
319
- * footerBottomInset) — subtract exactly what was baked in so UIKit's own
320
- * safe-area layout doesn't double it.
400
+ * Bottom inset excluded from the auto detent. A relative footer (or an
401
+ * absolute one padding the scrollable) owns the sheet's bottom edge and
402
+ * carries the inset in its height (see footerBottomInset) — subtract exactly
403
+ * what was baked in so UIKit's own safe-area layout doesn't double it.
321
404
  */
322
405
  - (CGFloat)autoDetentBottomInset {
323
- if (_absoluteFooter || [self.footerHeight floatValue] <= 0) {
406
+ if ((_absoluteFooter && !self.footerInsetAdjustment) || [self.footerHeight floatValue] <= 0) {
324
407
  return 0;
325
408
  }
326
409
 
@@ -341,6 +424,9 @@ static BOOL TrueSheetIsPhoneIdiom(void) {
341
424
  }
342
425
 
343
426
  - (BOOL)isDesignCompatibilityMode {
427
+ if (@available(iOS 27.0, *)) {
428
+ return NO;
429
+ }
344
430
  if (@available(iOS 26.0, *)) {
345
431
  NSNumber *value = [[NSBundle mainBundle] objectForInfoDictionaryKey:@"UIDesignRequiresCompatibility"];
346
432
  return value.boolValue;
@@ -377,6 +463,12 @@ static BOOL TrueSheetIsPhoneIdiom(void) {
377
463
 
378
464
  #pragma mark - View Lifecycle
379
465
 
466
+ - (void)loadView {
467
+ TrueSheetControllerView *view = [[TrueSheetControllerView alloc] init];
468
+ view.controller = self;
469
+ self.view = view;
470
+ }
471
+
380
472
  - (void)viewDidLoad {
381
473
  [super viewDidLoad];
382
474
  self.view.autoresizingMask = UIViewAutoresizingFlexibleHeight | UIViewAutoresizingFlexibleWidth;
@@ -387,6 +479,81 @@ static BOOL TrueSheetIsPhoneIdiom(void) {
387
479
  _grabberView = [[TrueSheetGrabberView alloc] init];
388
480
  _grabberView.hidden = YES;
389
481
  [_grabberView addToView:self.view];
482
+
483
+ #if RNTS_IPHONE_OS_VERSION_AVAILABLE(27_1)
484
+ if (@available(iOS 27.1, *)) {
485
+ __weak __typeof(self) weakSelf = self;
486
+ UIHingeInteraction *hingeInteraction =
487
+ [[UIHingeInteraction alloc] initWithUpdateHandler:^(UIHingeInteraction *_, UIHingeInteractionUpdate *__) {
488
+ // Next run loop, so the division region reflects the new posture
489
+ dispatch_async(dispatch_get_main_queue(), ^{
490
+ [weakSelf updateHingeState];
491
+ });
492
+ }];
493
+ [self.view addInteraction:hingeInteraction];
494
+ }
495
+ #endif
496
+ }
497
+
498
+ #pragma mark - Custom Dimming
499
+
500
+ // UISheetPresentationController has no API for the dim's color or opacity, so a custom
501
+ // color/opacity turns the native dim off and fades in our own view behind the sheet.
502
+ - (BOOL)useCustomDimming {
503
+ return self.dimmed && (self.dimColor != nil || self.dimOpacity != nil);
504
+ }
505
+
506
+ - (BOOL)isDimmedAtEveryDetent {
507
+ return self.dimmed && [self.dimmedDetentIndex integerValue] == 0;
508
+ }
509
+
510
+ // Like the system dim, fades in between the detent below `dimmedDetentIndex` and the dimmed detent.
511
+ - (CGFloat)dimAlphaForIndex:(CGFloat)index {
512
+ if (![self useCustomDimming]) {
513
+ return 0;
514
+ }
515
+
516
+ CGFloat progress = index - [self.dimmedDetentIndex integerValue] + 1;
517
+ return fmax(0, fmin(1, progress));
518
+ }
519
+
520
+ // Present and dismiss fade the dim over the whole travel below the resting position.
521
+ - (CGFloat)dimAlphaForPosition:(CGFloat)position index:(CGFloat)index {
522
+ if (!_isTransitioning || position <= _transitionRestPosition) {
523
+ return [self dimAlphaForIndex:index];
524
+ }
525
+
526
+ CGFloat travel = self.screenHeight - _transitionRestPosition;
527
+ CGFloat progress = travel > 0 ? fmax(0, (self.screenHeight - position) / travel) : 0;
528
+ return [self dimAlphaForIndex:[self interpolatedIndexForPosition:_transitionRestPosition]] * progress;
529
+ }
530
+
531
+ - (void)setupDimView {
532
+ UIView *container = self.presentationController.containerView;
533
+ if (!container || (!_dimView && ![self useCustomDimming])) {
534
+ return;
535
+ }
536
+
537
+ if (!_dimView) {
538
+ _dimView = [[TrueSheetDimView alloc] init];
539
+
540
+ __weak __typeof(self) weakSelf = self;
541
+ _dimView.onTap = ^{
542
+ [weakSelf handleDimViewTap];
543
+ };
544
+ }
545
+
546
+ // Keep opacity in the color so a transparent dim still receives touches.
547
+ UIColor *color = self.dimColor ?: [UIColor blackColor];
548
+ CGFloat opacity = self.dimOpacity ? [self.dimOpacity doubleValue] : 0.5;
549
+ _dimView.backgroundColor = [color colorWithAlphaComponent:CGColorGetAlpha(color.CGColor) * opacity];
550
+ [_dimView addToView:container];
551
+ }
552
+
553
+ - (void)handleDimViewTap {
554
+ if (self.dismissible) {
555
+ [self.presentingViewController dismissViewControllerAnimated:YES completion:nil];
556
+ }
390
557
  }
391
558
 
392
559
  - (void)viewWillAppear:(BOOL)animated {
@@ -402,6 +569,22 @@ static BOOL TrueSheetIsPhoneIdiom(void) {
402
569
  [_parentSheetController setAccessibilityContentElement:self.accessibilityContentView ?: self.view];
403
570
  }
404
571
 
572
+ [self setupDimView];
573
+
574
+ // A new presentation records the posture it starts in
575
+ _hingeStateKnown = NO;
576
+ _hingeRestoreIndex = -1;
577
+ _hingeOffset = 0;
578
+
579
+ // UIKit hides the presenter's keyboard under a sheet dimmed at every detent and
580
+ // brings it back on dismiss, but custom dimming leaves the native sheet undimmed.
581
+ if ([self useCustomDimming] && [self isDimmedAtEveryDetent]) {
582
+ UIView *firstResponder = [presenter.view findFirstResponder];
583
+ if ([firstResponder resignFirstResponder]) {
584
+ _presenterFirstResponder = firstResponder;
585
+ }
586
+ }
587
+
405
588
  dispatch_async(dispatch_get_main_queue(), ^{
406
589
  NSInteger index = self.currentDetentIndex;
407
590
  CGFloat position = self.currentPosition;
@@ -430,15 +613,48 @@ static BOOL TrueSheetIsPhoneIdiom(void) {
430
613
 
431
614
  [self->_grabberView updateAccessibilityValueWithIndex:index detentCount:self->_detents.count];
432
615
  [self settleAtDetentIndex:index debug:@"did present"];
616
+
617
+ // Apply a posture change that happened while presenting
618
+ [self updateHingeState];
433
619
  });
434
620
 
435
621
  [self setupGestureRecognizer];
436
622
  _isPresented = YES;
623
+ [self refreshStaleBottomSafeArea];
437
624
  }
438
625
 
439
626
  [self setupAccessibilityContainer];
440
627
  }
441
628
 
629
+ /**
630
+ * A present that overlaps another transition (e.g. re-presenting while a
631
+ * native stack pops) can leave the root view's bottom safe area at zero, and
632
+ * UIKit doesn't recompute it until the sheet's frame next changes. Suspect a
633
+ * zero the presented height says shouldn't be there — a small floating sheet
634
+ * legitimately has none.
635
+ */
636
+ - (BOOL)hasStaleBottomSafeArea {
637
+ UIView *view = self.viewIfLoaded;
638
+ return view.window && view.safeAreaInsets.bottom <= 0 && [self bottomSafeAreaForHeight:view.bounds.size.height] > 0;
639
+ }
640
+
641
+ // Toggling additionalSafeAreaInsets forces UIKit to recompute the safe area.
642
+ // The toggle's intermediate value isn't reported, so the footer only sees the
643
+ // settled one.
644
+ - (void)refreshStaleBottomSafeArea {
645
+ if (![self hasStaleBottomSafeArea]) {
646
+ return;
647
+ }
648
+
649
+ _isRefreshingSafeArea = YES;
650
+ UIEdgeInsets insets = self.additionalSafeAreaInsets;
651
+ self.additionalSafeAreaInsets = UIEdgeInsetsMake(insets.top, insets.left, insets.bottom + 1, insets.right);
652
+ self.additionalSafeAreaInsets = insets;
653
+ _isRefreshingSafeArea = NO;
654
+
655
+ [self.delegate viewControllerSafeAreaInsetsDidChange];
656
+ }
657
+
442
658
  - (void)setupAccessibilityContainer {
443
659
  UIView *contentView = self.accessibilityContentView;
444
660
  if (!contentView) {
@@ -448,6 +664,43 @@ static BOOL TrueSheetIsPhoneIdiom(void) {
448
664
  [self setAccessibilityContentElement:contentView];
449
665
  }
450
666
 
667
+ // Only the topmost sheet may refresh, or it would steal the window
668
+ // accessibility override from the child presented over it.
669
+ - (BOOL)canRefreshAccessibilityContainer {
670
+ return _isPresented && !self.isBeingDismissed && self.isTopmostPresentedController;
671
+ }
672
+
673
+ - (void)refreshAccessibilityContainer {
674
+ if (self.canRefreshAccessibilityContainer) {
675
+ [self setupAccessibilityContainer];
676
+ }
677
+ }
678
+
679
+ - (void)refreshAccessibilityContainerIfFooterChanged {
680
+ // Only a dimmed sheet's boundary depends on the footer
681
+ if (!_dimmed || !self.canRefreshAccessibilityContainer) {
682
+ return;
683
+ }
684
+
685
+ UIView *contentView = self.accessibilityContentView;
686
+ if (![contentView isKindOfClass:[TrueSheetContainerView class]] ||
687
+ [(TrueSheetContainerView *)contentView hasAccessibilityFooterElements] == _appliedAccessibilityFooterElements) {
688
+ return;
689
+ }
690
+
691
+ [self setAccessibilityContentElement:contentView];
692
+ UIAccessibilityPostNotification(UIAccessibilityLayoutChangedNotification, nil);
693
+ }
694
+
695
+ - (void)overlayWindowDidChange {
696
+ if (!self.canRefreshAccessibilityContainer) {
697
+ return;
698
+ }
699
+
700
+ [self setupAccessibilityContainer];
701
+ UIAccessibilityPostNotification(UIAccessibilityLayoutChangedNotification, nil);
702
+ }
703
+
451
704
  - (UIViewController *)accessibilityPresentingViewController {
452
705
  UIViewController *presentingViewController = self.presentingViewController;
453
706
  while ([presentingViewController isKindOfClass:[TrueSheetViewController class]] &&
@@ -484,24 +737,56 @@ static BOOL TrueSheetIsPhoneIdiom(void) {
484
737
  }
485
738
  }
486
739
 
740
+ - (NSArray *)accessibilityGrabberElements {
741
+ if (_grabberView && !_grabberView.hidden) {
742
+ return @[ _grabberView ];
743
+ }
744
+
745
+ // The system grabber is an accessibility element UIKit adds to the presented view
746
+ // (a sibling of our root view). Expose it so VoiceOver can resize or dismiss with it.
747
+ // UIKit also keeps a transparent bottom grabber there, so skip invisible views.
748
+ NSMutableArray *elements = [NSMutableArray array];
749
+ for (UIView *subview in self.presentationController.presentedView.subviews) {
750
+ if (subview != self.view && subview.isAccessibilityElement && !subview.hidden && subview.alpha > 0) {
751
+ [elements addObject:subview];
752
+ }
753
+ }
754
+ return elements;
755
+ }
756
+
757
+ - (BOOL)accessibilityPerformEscape {
758
+ if (!_isPresented) {
759
+ return NO;
760
+ }
761
+
762
+ if (!self.dismissible) {
763
+ [self.delegate viewControllerDidAttemptDismiss];
764
+ return NO;
765
+ }
766
+
767
+ [self.presentingViewController dismissViewControllerAnimated:YES completion:nil];
768
+ return YES;
769
+ }
770
+
487
771
  - (void)setAccessibilityContentElement:(UIView *)contentView {
488
772
  BOOL hasAccessibilityFooterElements = [contentView isKindOfClass:[TrueSheetContainerView class]] &&
489
773
  [(TrueSheetContainerView *)contentView hasAccessibilityFooterElements];
490
774
  // Footer controls exposed as separate accessibility elements can be skipped
491
775
  // by XCTest when the presented sheet is a hard modal accessibility boundary.
492
776
  BOOL isAccessibilityModal = _dimmed && !hasAccessibilityFooterElements;
777
+ _appliedAccessibilityFooterElements = hasAccessibilityFooterElements;
493
778
 
494
779
  // At a hard modal boundary XCTest skips nested elements, so flatten the
495
780
  // container's children into the array. Otherwise expose the container itself:
496
781
  // its accessibilityElements getter recomputes live, so a footer/content subtree
497
782
  // that remounts (e.g. swapping a footer button on a state change) stays
498
783
  // discoverable instead of leaving this snapshot pointing at destroyed views.
499
- NSArray *accessibilityElements;
784
+ NSMutableArray *accessibilityElements = [[self accessibilityGrabberElements] mutableCopy];
500
785
  if (isAccessibilityModal) {
501
786
  NSArray *contentElements = contentView.accessibilityElements;
502
- accessibilityElements = contentElements.count > 0 ? contentElements : @[ contentView ];
787
+ [accessibilityElements addObjectsFromArray:contentElements.count > 0 ? contentElements : @[ contentView ]];
503
788
  } else {
504
- accessibilityElements = @[ contentView ];
789
+ [accessibilityElements addObject:contentView];
505
790
  }
506
791
 
507
792
  self.view.isAccessibilityElement = NO;
@@ -514,6 +799,15 @@ static BOOL TrueSheetIsPhoneIdiom(void) {
514
799
  presentedView.accessibilityViewIsModal = isAccessibilityModal;
515
800
  presentedView.accessibilityElements = accessibilityElements;
516
801
 
802
+ // The system grabber is a sibling of our root view, so an escape performed while it
803
+ // is focused walks presentedView → window and never reaches TrueSheetControllerView.
804
+ if (@available(iOS 17.0, *)) {
805
+ __weak __typeof(self) weakSelf = self;
806
+ presentedView.accessibilityPerformEscapeBlock = ^BOOL {
807
+ return [weakSelf accessibilityPerformEscape];
808
+ };
809
+ }
810
+
517
811
  UIWindow *window = self.view.window;
518
812
  UIViewController *presentingViewController = [self accessibilityPresentingViewController];
519
813
  if (window && presentingViewController.view) {
@@ -528,8 +822,12 @@ static BOOL TrueSheetIsPhoneIdiom(void) {
528
822
  }
529
823
  objc_setAssociatedObject(window, &TrueSheetAccessibilityWindowOwnerKey, [NSValue valueWithNonretainedObject:self],
530
824
  OBJC_ASSOCIATION_RETAIN_NONATOMIC);
531
- window.accessibilityElements =
825
+ NSArray *sheetElements =
532
826
  isAccessibilityModal ? @[ presentedView ] : @[ presentingViewController.view, presentedView ];
827
+ // Overlays render above every sheet. Listed first, since accessibility
828
+ // hit testing takes the first element containing the point.
829
+ window.accessibilityElements =
830
+ [[TrueSheetOverlayView overlayContainersInWindow:window] arrayByAddingObjectsFromArray:sheetElements];
533
831
  return;
534
832
  }
535
833
  }
@@ -575,6 +873,13 @@ static BOOL TrueSheetIsPhoneIdiom(void) {
575
873
  [self.delegate viewControllerWillBlur];
576
874
  [self.delegate viewControllerWillDismiss];
577
875
  [_parentSheetController.delegate viewControllerWillFocus];
876
+
877
+ // Restored once the dismiss commits, like UIKit's own keyboard preservation.
878
+ UIView *firstResponder = _presenterFirstResponder;
879
+ _presenterFirstResponder = nil;
880
+ if (!self.presentingViewController.isBeingDismissed) {
881
+ [firstResponder becomeFirstResponder];
882
+ }
578
883
  }
579
884
  }
580
885
 
@@ -585,6 +890,9 @@ static BOOL TrueSheetIsPhoneIdiom(void) {
585
890
  _isWillDismissEmitted = NO;
586
891
  _hasObservedBottomInset = NO;
587
892
 
893
+ [_dimView removeFromSuperview];
894
+ _dimView = nil;
895
+
588
896
  [_anchorView removeFromSuperview];
589
897
  _anchorView = nil;
590
898
 
@@ -603,13 +911,29 @@ static BOOL TrueSheetIsPhoneIdiom(void) {
603
911
  [self restoreWindowAccessibilityElements];
604
912
  [self setSheetAccessibilityElementsHidden:YES];
605
913
 
606
- // Dispatch to allow pan gesture to set _isDragging before checking;
607
- // the transition tracker emits when the sheet is transitioning to dismiss
608
- dispatch_async(dispatch_get_main_queue(), ^{
609
- if (!self->_isDragging) {
610
- [self emitWillDismissEvents];
611
- }
612
- });
914
+ // Dismissing with a child on top only animates the child out — this sheet
915
+ // doesn't move and isn't part of that transition, so fade its dim over it.
916
+ if (self.isBeingDismissed && self.presentedViewController) {
917
+ [UIView animateWithDuration:self.transitionCoordinator.transitionDuration
918
+ animations:^{
919
+ self->_dimView.alpha = 0;
920
+ }];
921
+ }
922
+
923
+ // A swipe can still be cancelled, so emit once it commits on release. Any
924
+ // other dismiss (programmatic, outside tap, navigation) is already committed.
925
+ id<UIViewControllerTransitionCoordinator> coordinator = self.transitionCoordinator;
926
+ if (coordinator.isInteractive) {
927
+ __weak __typeof(self) weakSelf = self;
928
+ [coordinator
929
+ notifyWhenInteractionChangesUsingBlock:^(id<UIViewControllerTransitionCoordinatorContext> _Nonnull context) {
930
+ if (!context.isCancelled) {
931
+ [weakSelf emitWillDismissEvents];
932
+ }
933
+ }];
934
+ } else {
935
+ [self emitWillDismissEvents];
936
+ }
613
937
 
614
938
  [self setupTransitionTracker];
615
939
  }
@@ -625,8 +949,11 @@ static BOOL TrueSheetIsPhoneIdiom(void) {
625
949
  }
626
950
 
627
951
  // Dismissing with the keyboard up skips the hide notification — don't let
628
- // the stale flag block learning on the next present.
952
+ // the stale flag block learning on the next present, or a stale height keep
953
+ // the footer out of the auto detent.
629
954
  _keyboardSheetGrown = NO;
955
+ _keyboardHeight = 0;
956
+ _minimizedKeyboardOverlap = 0;
630
957
 
631
958
  [self emitDidDismissEvents];
632
959
  }
@@ -639,24 +966,26 @@ static BOOL TrueSheetIsPhoneIdiom(void) {
639
966
  return;
640
967
  }
641
968
 
642
- if (_pendingContentSizeChange || _pendingDetentsChange) {
969
+ // Mid-drag the frame tracks the finger — never settle or learn from it;
970
+ // pending changes keep their flags and settle on the first post-drag layout.
971
+ if ((_pendingContentSizeChange || _pendingDetentsChange) && !_isDragging) {
643
972
  _pendingContentSizeChange = NO;
644
973
  _pendingDetentsChange = NO;
645
974
  [self settleAtDetentIndex:self.currentDetentIndex debug:@"layout"];
646
- } else if (!_isDragging) {
647
- // Drags emit from the pan handler. A child on top moves this sheet two
648
- // ways: an animated collapse/restore step (presenting/dismissing) — emit
649
- // the target non-realtime so JS animates to it — or direct per-frame
650
- // re-layouts while the child drags, which stay realtime.
975
+ } else {
976
+ // A child on top moves this sheet two ways: an animated collapse/restore
977
+ // step (presenting/dismissing) — emit the target non-realtime so JS
978
+ // animates to it — or direct per-frame re-layouts while the child drags,
979
+ // which stay realtime. Mid-drag no child is on top, so drags emit realtime.
651
980
  BOOL realtime = self.presentedViewController == nil || !self.isPresentedViewAnimating;
652
981
 
653
982
  // A layout pass at rest can nudge the frame by a sub-pixel amount
654
983
  // (pixel-grid snapping after present), leaving the interpolated index
655
984
  // slightly off the whole number. Re-learn when the frame is effectively
656
985
  // at the current detent so the drift is absorbed instead of emitted.
657
- // The tight threshold keeps real movement (e.g. scroll-driven expansion)
658
- // from being absorbed.
659
- if (self.presentedViewController == nil && !self.isPresentedViewAnimating) {
986
+ // The tight threshold keeps real movement (e.g. scroll-driven expansion
987
+ // or a drag hovering near the detent) from being absorbed.
988
+ if (!_isDragging && self.presentedViewController == nil && !self.isPresentedViewAnimating) {
660
989
  NSInteger index = self.currentDetentIndex;
661
990
  CGFloat expectedHeight = [_detentCalculator resolvedHeightForIndex:index];
662
991
  CGFloat actualHeight = self.screenHeight - self.currentPosition;
@@ -669,6 +998,25 @@ static BOOL TrueSheetIsPhoneIdiom(void) {
669
998
  }
670
999
  }
671
1000
 
1001
+ // Rotation or an iPad window resize changes the height fractional detents
1002
+ // resolve against — re-resolve alongside the transition so the sheet lands on
1003
+ // the new height instead of waiting for a content-driven rebuild.
1004
+ - (void)viewWillTransitionToSize:(CGSize)size
1005
+ withTransitionCoordinator:(id<UIViewControllerTransitionCoordinator>)coordinator {
1006
+ [super viewWillTransitionToSize:size withTransitionCoordinator:coordinator];
1007
+
1008
+ if (@available(iOS 16.0, *)) {
1009
+ [coordinator
1010
+ animateAlongsideTransition:^(id<UIViewControllerTransitionCoordinatorContext> context) {
1011
+ [self.sheet invalidateDetents];
1012
+ }
1013
+ completion:^(id<UIViewControllerTransitionCoordinatorContext> context) {
1014
+ // Rotating a half-open foldable swaps between book and tabletop
1015
+ [self updateHingeState];
1016
+ }];
1017
+ }
1018
+ }
1019
+
672
1020
  // Fires when UIKit resolves the sheet's float/anchored placement (present,
673
1021
  // detent resize crossing the float threshold, rotation). The footer's
674
1022
  // absorbed inset follows the observed safe area — repush it right away so
@@ -678,7 +1026,7 @@ static BOOL TrueSheetIsPhoneIdiom(void) {
678
1026
 
679
1027
  // Mid-dismiss the frame moves off-screen and reports transient insets —
680
1028
  // don't let them pollute the observed value (matches viewDidLayoutSubviews).
681
- if (!self.isBeingDismissed) {
1029
+ if (!self.isBeingDismissed && !_isRefreshingSafeArea) {
682
1030
  [self.delegate viewControllerSafeAreaInsetsDidChange];
683
1031
  }
684
1032
  }
@@ -706,6 +1054,10 @@ static BOOL TrueSheetIsPhoneIdiom(void) {
706
1054
  }
707
1055
  }
708
1056
 
1057
+ // The hinge callback can run before the division region updates; the
1058
+ // relayout UIKit does for the posture change always sees the new region.
1059
+ [self updateHingeState];
1060
+
709
1061
  if (_pendingDetentIndex >= 0) {
710
1062
  NSInteger pendingIndex = _pendingDetentIndex;
711
1063
  _pendingDetentIndex = -1;
@@ -780,7 +1132,14 @@ static BOOL TrueSheetIsPhoneIdiom(void) {
780
1132
  _isDragging = YES;
781
1133
  break;
782
1134
  case UIGestureRecognizerStateChanged:
783
- [self emitChangePositionDelegateWithPosition:self.currentPosition realtime:YES debug:@"drag change"];
1135
+ // Layout owns the emit while the sheet resizes with the finger (a
1136
+ // layout pass is already queued), and the transition tick owns it
1137
+ // during an interactive dismiss. Translation-only movement (e.g.
1138
+ // dragging down to the first detent) triggers neither — emit here as
1139
+ // the backup.
1140
+ if (!_isTransitioning && !self.presentedView.layer.needsLayout && !self.viewIfLoaded.layer.needsLayout) {
1141
+ [self emitChangePositionDelegateWithPosition:self.currentPosition realtime:YES debug:@"drag change"];
1142
+ }
784
1143
  break;
785
1144
  case UIGestureRecognizerStateEnded:
786
1145
  case UIGestureRecognizerStateCancelled: {
@@ -820,6 +1179,7 @@ static BOOL TrueSheetIsPhoneIdiom(void) {
820
1179
  }
821
1180
 
822
1181
  _isTransitioning = YES;
1182
+ _transitionRestPosition = self.isBeingDismissed ? self.livePosition : self.currentPosition;
823
1183
 
824
1184
  // Learn the resolver-vs-actual offset before emitting transition positions so
825
1185
  // the interpolated index lands exactly on the target detent. The presented
@@ -855,37 +1215,32 @@ static BOOL TrueSheetIsPhoneIdiom(void) {
855
1215
  // Settle after a present, cancelled dismiss, or detent-snap transition.
856
1216
  // Delayed because the presentedView frame isn't final until UIKit
857
1217
  // completes its layout pass after the transition animation — learning
858
- // here absorbs any sub-pixel drift since the earlier learns.
1218
+ // here absorbs any sub-pixel drift since the earlier learns. A dismiss
1219
+ // cancelled mid-drag leaves the finger down, so the drag end settles instead.
859
1220
  if (strongSelf->_isPresented && !strongSelf.isBeingDismissed) {
860
1221
  dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.1 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
861
- [strongSelf settleAtDetentIndex:strongSelf.currentDetentIndex debug:@"transition end"];
1222
+ if (!strongSelf->_isDragging) {
1223
+ [strongSelf settleAtDetentIndex:strongSelf.currentDetentIndex debug:@"transition end"];
1224
+ }
862
1225
  });
863
1226
  }
864
1227
  }];
865
1228
  }
866
1229
 
867
1230
  - (void)handleTransitionTick {
868
- // Interactive dismiss phase (finger down) — the pan handler emits.
869
- if (!_isDragging) {
870
- CGFloat position = self.livePosition;
871
-
872
- if (self.isBeingDismissed) {
873
- // Emit only once the dismiss is committed: on release UIKit moves the
874
- // model frame off-screen, while a cancelled drag rewinds it back to the
875
- // detent (isBeingDismissed stays YES during the rewind).
876
- if (self.currentPosition >= self.screenHeight) {
877
- [self emitWillDismissEvents];
878
- }
1231
+ // Also covers the interactive dismiss phase (finger down) — layout passes
1232
+ // are skipped while transitioning, so the tick owns position there too.
1233
+ CGFloat position = self.livePosition;
879
1234
 
880
- // Hide blur at the end of dismiss to prevent UIVisualEffectView
881
- // from causing a flicker/flash at the bottom edge of the sheet.
882
- if (self.screenHeight - position < 1) {
883
- _blurView.alpha = 0;
884
- }
1235
+ if (self.isBeingDismissed) {
1236
+ // Hide blur at the end of dismiss to prevent UIVisualEffectView
1237
+ // from causing a flicker/flash at the bottom edge of the sheet.
1238
+ if (self.screenHeight - position < 1) {
1239
+ _blurView.alpha = 0;
885
1240
  }
886
-
887
- [self emitChangePositionDelegateWithPosition:position realtime:YES debug:@"transition"];
888
1241
  }
1242
+
1243
+ [self emitChangePositionDelegateWithPosition:position realtime:YES debug:@"transition"];
889
1244
  }
890
1245
 
891
1246
  /**
@@ -898,6 +1253,7 @@ static BOOL TrueSheetIsPhoneIdiom(void) {
898
1253
  // measured frame; the next unobstructed settle learns.
899
1254
  if (self.presentedViewController == nil) {
900
1255
  [self learnOffsetForDetentIndex:index];
1256
+ [self correctHingeOffsetForIndex:index];
901
1257
  }
902
1258
 
903
1259
  [self emitChangePositionDelegateWithPosition:self.currentPosition realtime:NO debug:debug];
@@ -1018,12 +1374,31 @@ static BOOL TrueSheetIsPhoneIdiom(void) {
1018
1374
  }
1019
1375
  }
1020
1376
 
1377
+ if (self.isPresented && !self.isBeingDismissed && !_isWillDismissEmitted && !_usesBackgroundEffects) {
1378
+ NSInteger index = [_detentCalculator backgroundIndexForPosition:position displayedIndex:_displayedBackgroundIndex];
1379
+ if (index != _displayedBackgroundIndex) {
1380
+ _displayedBackgroundIndex = index;
1381
+ [UIView animateWithDuration:0.2
1382
+ delay:0
1383
+ options:UIViewAnimationOptionBeginFromCurrentState | UIViewAnimationOptionAllowUserInteraction
1384
+ animations:^{
1385
+ [self setupBackground];
1386
+ }
1387
+ completion:nil];
1388
+ }
1389
+ }
1390
+
1021
1391
  TrueSheetPositionState state = {
1022
1392
  .position = position,
1023
1393
  .detent = [self interpolatedDetentForPosition:position],
1024
1394
  .index = [self interpolatedIndexForPosition:position],
1025
1395
  };
1026
1396
 
1397
+ // A child on top only pushes this sheet back, its detent stays.
1398
+ if (!presented) {
1399
+ _dimView.alpha = [self dimAlphaForPosition:position index:state.index];
1400
+ }
1401
+
1027
1402
  // Settle (non-realtime) emits are authoritative and bypass the dedupe — a
1028
1403
  // learn right before them can correct the interpolated index by less than
1029
1404
  // the tolerance, and the corrected value must still reach JS.
@@ -1038,7 +1413,7 @@ static BOOL TrueSheetIsPhoneIdiom(void) {
1038
1413
  }
1039
1414
 
1040
1415
  - (void)learnOffsetForDetentIndex:(NSInteger)index {
1041
- if (_keyboardSheetGrown) {
1416
+ if (_keyboardSheetGrown || _minimizedKeyboardOverlap > 0) {
1042
1417
  return;
1043
1418
  }
1044
1419
  [_detentCalculator learnOffsetForDetentIndex:index];
@@ -1072,6 +1447,146 @@ static BOOL TrueSheetIsPhoneIdiom(void) {
1072
1447
  return [_detentCalculator detentValueForIndex:index];
1073
1448
  }
1074
1449
 
1450
+ #pragma mark - Hinge
1451
+
1452
+ /**
1453
+ * Bottom edge (window coordinates) of an active horizontal fold, or 0 when the
1454
+ * device isn't in tabletop. The division region is only active while the hinge
1455
+ * is partially open, so this also excludes flat and closed postures.
1456
+ */
1457
+ - (CGFloat)tabletopHingeEdge {
1458
+ #if RNTS_IPHONE_OS_VERSION_AVAILABLE(27_1)
1459
+ if (@available(iOS 27.1, *)) {
1460
+ UIWindow *window = self.hostWindow;
1461
+ for (UIViewReservedRegion *region in [window reservedRegionsOfKind:[UIViewReservedRegionKind divisionRegionKind]]) {
1462
+ CGRect frame = region.frame;
1463
+ if (region.isActive && frame.size.width > frame.size.height) {
1464
+ return CGRectGetMaxY(frame);
1465
+ }
1466
+ }
1467
+ }
1468
+ #endif
1469
+ return 0;
1470
+ }
1471
+
1472
+ /**
1473
+ * Gap between a hinge-placed detent's resolved height and the sheet's actual top
1474
+ * edge: UIKit adds the bottom safe area, plus the floating sheet's inset. Every
1475
+ * hinge-placed detent rests at the same height, so they share one offset — the
1476
+ * first settle at the hinge learns it.
1477
+ */
1478
+ - (CGFloat)hingeOffsetForHeight:(CGFloat)height {
1479
+ if (_hingeOffset > 0) {
1480
+ return _hingeOffset;
1481
+ }
1482
+
1483
+ return [self bottomSafeAreaForHeight:height] + TrueSheetFloatingSheetInset;
1484
+ }
1485
+
1486
+ // A hinge-placed detent resolved with an off estimate lands a few points off
1487
+ // the hinge — re-resolve once the settle has learned the real offset.
1488
+ - (void)correctHingeOffsetForIndex:(NSInteger)index {
1489
+ NSNumber *usedOffset = _hingeOffsets[@(index)];
1490
+ if (!usedOffset || _hingeCorrections >= 2) {
1491
+ return;
1492
+ }
1493
+
1494
+ CGFloat learnedOffset = [_detentCalculator learnedOffsetForIndex:index];
1495
+ if (learnedOffset == 0 || fabs(learnedOffset - usedOffset.doubleValue) <= 1) {
1496
+ return;
1497
+ }
1498
+
1499
+ _hingeOffset = learnedOffset;
1500
+ _hingeCorrections++;
1501
+ dispatch_async(dispatch_get_main_queue(), ^{
1502
+ [self invalidateDetentsAnimated];
1503
+ });
1504
+ }
1505
+
1506
+ - (void)invalidateDetentsAnimated {
1507
+ if (@available(iOS 16.0, *)) {
1508
+ [self.sheet animateChanges:^{
1509
+ self->_pendingContentSizeChange = YES;
1510
+ [self.sheet invalidateDetents];
1511
+ }];
1512
+ }
1513
+ }
1514
+
1515
+ /**
1516
+ * Re-resolves hinge-placed detents when the posture changes, and runs 'snap':
1517
+ * entering tabletop moves the sheet to the hinge detent, leaving it restores the
1518
+ * previous detent if the sheet is still on the hinge. The posture a presentation
1519
+ * starts in only records state, so presenting while already in tabletop doesn't
1520
+ * jump the sheet. A change while presenting, dismissing, or dragging stays
1521
+ * pending until the sheet is at rest again.
1522
+ */
1523
+ - (void)updateHingeState {
1524
+ NSInteger hingeIndex = self.foldOptions.hingeDetentIndex;
1525
+ if (hingeIndex < 0 && self.foldOptions.hingeBehavior == TrueSheetViewHingeBehavior::None) {
1526
+ return;
1527
+ }
1528
+
1529
+ // Covered by a full-screen presentation, the sheet has no window to read the posture from
1530
+ if (!self.viewIfLoaded.window) {
1531
+ return;
1532
+ }
1533
+
1534
+ CGFloat edge = [self tabletopHingeEdge];
1535
+ if (!_hingeStateKnown) {
1536
+ _hingeStateKnown = YES;
1537
+ _hingeEdge = edge;
1538
+ return;
1539
+ }
1540
+
1541
+ if (!self.isPresented || self.isBeingDismissed || _isDragging || fabs(edge - _hingeEdge) < 0.5) {
1542
+ return;
1543
+ }
1544
+
1545
+ BOOL entered = edge > 0 && _hingeEdge == 0;
1546
+ BOOL left = edge == 0 && _hingeEdge > 0;
1547
+ _hingeEdge = edge;
1548
+ _hingeOffset = 0;
1549
+ _hingeCorrections = 0;
1550
+
1551
+ NSInteger targetIndex = -1;
1552
+ NSInteger detentCount = (NSInteger)self.detents.count;
1553
+ BOOL snaps =
1554
+ self.foldOptions.hingeBehavior == TrueSheetViewHingeBehavior::Snap && hingeIndex >= 0 && hingeIndex < detentCount;
1555
+ if (entered && snaps) {
1556
+ _hingeRestoreIndex = self.currentDetentIndex;
1557
+ targetIndex = hingeIndex;
1558
+ } else if (left && _hingeRestoreIndex >= 0) {
1559
+ // Keep a detent the user picked while folded
1560
+ if (snaps && self.currentDetentIndex == hingeIndex) {
1561
+ targetIndex = MIN(_hingeRestoreIndex, detentCount - 1);
1562
+ }
1563
+ _hingeRestoreIndex = -1;
1564
+ }
1565
+
1566
+ // A resize settles through the pending detent change
1567
+ BOOL resizes = targetIndex >= 0 && targetIndex != self.activeDetentIndex;
1568
+
1569
+ if (@available(iOS 16.0, *)) {
1570
+ [self.sheet animateChanges:^{
1571
+ [self.sheet invalidateDetents];
1572
+ if (resizes) {
1573
+ [self resizeToDetentIndex:targetIndex];
1574
+ }
1575
+ }];
1576
+ }
1577
+
1578
+ // UIKit often re-resolves detents for the posture change on its own before
1579
+ // this runs, so the invalidation above triggers no layout pass to settle in.
1580
+ // Settle once the frame is final, same as a pending detent change.
1581
+ if (!resizes) {
1582
+ dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.2 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
1583
+ if (self.isPresented && !self.isBeingDismissed && !self->_isDragging && !self->_isTransitioning) {
1584
+ [self settleAtDetentIndex:self.currentDetentIndex debug:@"hinge"];
1585
+ }
1586
+ });
1587
+ }
1588
+ }
1589
+
1075
1590
  #pragma mark - Sheet Configuration
1076
1591
 
1077
1592
  /**
@@ -1083,8 +1598,8 @@ static BOOL TrueSheetIsPhoneIdiom(void) {
1083
1598
  */
1084
1599
  - (void)setupSheetDetentsForSizeChange {
1085
1600
  if (@available(iOS 16.0, *)) {
1086
- CGFloat autoHeight = [_detentCalculator autoHeight];
1087
- CGFloat peekHeight = [_detentCalculator peekHeight];
1601
+ CGFloat autoHeight = [_detentCalculator autoHeightForKeyboardHeight:_keyboardHeight] - [self autoDetentBottomInset];
1602
+ CGFloat peekHeight = [_detentCalculator peekHeight] - [self peekDetentBottomInset];
1088
1603
 
1089
1604
  if (fabs(autoHeight - _autoDetentHeight) < 0.5 && fabs(peekHeight - _peekDetentHeight) < 0.5) {
1090
1605
  return;
@@ -1093,12 +1608,19 @@ static BOOL TrueSheetIsPhoneIdiom(void) {
1093
1608
  _autoDetentHeight = autoHeight;
1094
1609
  _peekDetentHeight = peekHeight;
1095
1610
 
1611
+ [self invalidateDetentsAnimated];
1612
+ }
1613
+ // iOS 15: detents are fixed medium/large — size changes can't affect them.
1614
+ }
1615
+
1616
+ // Re-resolves the detents in place, e.g. when an input to the resolver
1617
+ // changes after UIKit has already laid out the sheet.
1618
+ - (void)invalidateDetents {
1619
+ if (@available(iOS 16.0, *)) {
1096
1620
  [self.sheet animateChanges:^{
1097
- self->_pendingContentSizeChange = YES;
1098
1621
  [self.sheet invalidateDetents];
1099
1622
  }];
1100
1623
  }
1101
- // iOS 15: detents are fixed medium/large — size changes can't affect them.
1102
1624
  }
1103
1625
 
1104
1626
  - (void)setupSheetDetentsForDetentsChange {
@@ -1115,9 +1637,10 @@ static BOOL TrueSheetIsPhoneIdiom(void) {
1115
1637
 
1116
1638
  NSMutableArray<UISheetPresentationControllerDetent *> *detents = [NSMutableArray array];
1117
1639
  [_detentCalculator clearResolvedHeights];
1640
+ _hingeCorrections = 0;
1118
1641
 
1119
- _autoDetentHeight = [_detentCalculator autoHeight];
1120
- _peekDetentHeight = [_detentCalculator peekHeight];
1642
+ _autoDetentHeight = [_detentCalculator autoHeightForKeyboardHeight:_keyboardHeight] - [self autoDetentBottomInset];
1643
+ _peekDetentHeight = [_detentCalculator peekHeight] - [self peekDetentBottomInset];
1121
1644
 
1122
1645
  for (NSInteger index = 0; index < self.detents.count; index++) {
1123
1646
  id detent = self.detents[index];
@@ -1126,9 +1649,25 @@ static BOOL TrueSheetIsPhoneIdiom(void) {
1126
1649
  }
1127
1650
 
1128
1651
  [_detentCalculator setDetentCount:self.detents.count];
1652
+ _usesBackgroundEffects = [self canUseBackgroundEffects:detents];
1653
+ #if RNTS_IPHONE_OS_VERSION_AVAILABLE(26_1) && !TARGET_OS_MACCATALYST
1654
+ if (@available(iOS 26.1, *)) {
1655
+ if (_usesBackgroundEffects) {
1656
+ for (NSInteger index = 0; index < detents.count; index++) {
1657
+ NSDictionary *entry = [self backgroundForIndex:index];
1658
+ auto blur = entry[@"blur"] ? static_cast<TrueSheetViewBackgroundBlur>([entry[@"blur"] intValue])
1659
+ : TrueSheetViewBackgroundBlur::None;
1660
+ detents[index].backgroundEffect = [self backgroundEffectForColor:entry[@"color"] blur:blur];
1661
+ }
1662
+ }
1663
+ }
1664
+ #endif
1665
+
1129
1666
  sheet.detents = detents;
1130
1667
 
1131
- if (self.dimmed && [self.dimmedDetentIndex integerValue] == 0) {
1668
+ if ([self useCustomDimming]) {
1669
+ sheet.largestUndimmedDetentIdentifier = sheet.detents.lastObject.identifier;
1670
+ } else if ([self isDimmedAtEveryDetent]) {
1132
1671
  sheet.largestUndimmedDetentIdentifier = nil;
1133
1672
  } else {
1134
1673
  sheet.largestUndimmedDetentIdentifier = UISheetPresentationControllerDetentIdentifierLarge;
@@ -1164,7 +1703,7 @@ static BOOL TrueSheetIsPhoneIdiom(void) {
1164
1703
  return [self customDetentWithIdentifier:@"custom-auto"
1165
1704
  atIndex:index
1166
1705
  heightBlock:^CGFloat {
1167
- return self->_autoDetentHeight - [self autoDetentBottomInset];
1706
+ return self->_autoDetentHeight;
1168
1707
  }];
1169
1708
  } else {
1170
1709
  return [UISheetPresentationControllerDetent mediumDetent];
@@ -1176,7 +1715,7 @@ static BOOL TrueSheetIsPhoneIdiom(void) {
1176
1715
  return [self customDetentWithIdentifier:@"custom-peek"
1177
1716
  atIndex:index
1178
1717
  heightBlock:^CGFloat {
1179
- return self->_peekDetentHeight - [self peekDetentBottomInset];
1718
+ return self->_peekDetentHeight;
1180
1719
  }];
1181
1720
  } else {
1182
1721
  return [UISheetPresentationControllerDetent mediumDetent];
@@ -1190,8 +1729,13 @@ static BOOL TrueSheetIsPhoneIdiom(void) {
1190
1729
 
1191
1730
  if (@available(iOS 16.0, *)) {
1192
1731
  NSString *detentId = [NSString stringWithFormat:@"custom-%f", value];
1193
- CGFloat sheetHeight = value * self.screenHeight;
1194
- return [self customDetentWithIdentifier:detentId height:sheetHeight atIndex:index];
1732
+ // Resolve lazily — detents are built before presentation (no window yet), and
1733
+ // UIKit re-resolves on container size changes (rotation, windowed iPad resize).
1734
+ return [self customDetentWithIdentifier:detentId
1735
+ atIndex:index
1736
+ heightBlock:^CGFloat {
1737
+ return value * self.screenHeight;
1738
+ }];
1195
1739
  } else if (value >= 0.5) {
1196
1740
  return [UISheetPresentationControllerDetent largeDetent];
1197
1741
  } else {
@@ -1199,16 +1743,6 @@ static BOOL TrueSheetIsPhoneIdiom(void) {
1199
1743
  }
1200
1744
  }
1201
1745
 
1202
- - (UISheetPresentationControllerDetent *)customDetentWithIdentifier:(NSString *)identifier
1203
- height:(CGFloat)height
1204
- atIndex:(NSInteger)index API_AVAILABLE(ios(16.0)) {
1205
- return [self customDetentWithIdentifier:identifier
1206
- atIndex:index
1207
- heightBlock:^CGFloat {
1208
- return height;
1209
- }];
1210
- }
1211
-
1212
1746
  - (UISheetPresentationControllerDetent *)customDetentWithIdentifier:(NSString *)identifier
1213
1747
  atIndex:(NSInteger)index
1214
1748
  heightBlock:(CGFloat (^)(void))heightBlock
@@ -1226,24 +1760,66 @@ static BOOL TrueSheetIsPhoneIdiom(void) {
1226
1760
  : maxDetentValue;
1227
1761
 
1228
1762
  CGFloat adjustedHeight = height - bottomAdjustment;
1763
+ NSInteger lastIndex = (NSInteger)self.detents.count - 1;
1764
+
1765
+ // Tabletop: the hinge detent rests on the hinge, and 'avoid'/'snap'
1766
+ // cap every detent there.
1767
+ BOOL avoidsHinge = self.foldOptions.hingeBehavior != TrueSheetViewHingeBehavior::None;
1768
+ NSInteger hingeIndex = self.foldOptions.hingeDetentIndex;
1769
+ BOOL hasHingeDetent = hingeIndex >= 0 && hingeIndex <= lastIndex;
1770
+ CGFloat hingeEdge = (hasHingeDetent || avoidsHinge) ? [self tabletopHingeEdge] : 0;
1771
+ CGFloat hingeOffset = 0;
1772
+ CGFloat hingeHeight = 0;
1773
+ if (hingeEdge > 0) {
1774
+ CGFloat panelHeight = self.screenHeight - hingeEdge;
1775
+ hingeOffset = [self hingeOffsetForHeight:panelHeight];
1776
+ hingeHeight = panelHeight - hingeOffset;
1777
+ if (hasHingeDetent) {
1778
+ // Lower detents stay below the hinge detent and higher ones above
1779
+ // it, so the detents keep their order
1780
+ CGFloat hingeDetentHeight =
1781
+ avoidsHinge ? hingeHeight - (lastIndex - hingeIndex) : hingeHeight;
1782
+ if (index == hingeIndex) {
1783
+ adjustedHeight = hingeHeight;
1784
+ } else if (index < hingeIndex) {
1785
+ adjustedHeight = fmin(adjustedHeight, hingeDetentHeight - (hingeIndex - index));
1786
+ } else {
1787
+ adjustedHeight = fmax(adjustedHeight, hingeDetentHeight + (index - hingeIndex));
1788
+ }
1789
+ }
1790
+ if (avoidsHinge) {
1791
+ maxValue = fmin(maxValue, hingeHeight);
1792
+ }
1793
+ }
1229
1794
 
1230
1795
  // Only the last detent may occupy the full maximum. Two detents
1231
1796
  // resolving to the same height (e.g. a capped auto and 1) make
1232
1797
  // UIKit skip the stack's deck animation when presenting a child
1233
1798
  // sheet and snap the sheet behind into place instead.
1234
- NSInteger lastIndex = (NSInteger)self.detents.count - 1;
1235
1799
  if (adjustedHeight >= maxValue && index < lastIndex) {
1236
1800
  maxValue -= lastIndex - index;
1237
1801
  }
1238
1802
 
1239
1803
  CGFloat resolved = fmin(adjustedHeight, maxValue);
1240
1804
 
1805
+ // Detents within the collapse spacing of the hinge are hinge-placed.
1806
+ // They rest with the hinge offset, so position events map them right
1807
+ // before the first settle there.
1808
+ if (hingeHeight > 0 && fabs(resolved - hingeHeight) <= lastIndex + 0.5) {
1809
+ self->_hingeOffsets[@(index)] = @(hingeOffset);
1810
+ [self->_detentCalculator assumeOffset:hingeOffset forIndex:index];
1811
+ } else {
1812
+ [self->_hingeOffsets removeObjectForKey:@(index)];
1813
+ }
1814
+
1241
1815
  NSMutableArray *heights = self->_detentCalculator.resolvedDetentHeights;
1242
1816
  if (heights && index >= 0 && index < (NSInteger)heights.count) {
1243
1817
  heights[index] = @(resolved);
1244
1818
  }
1245
1819
 
1246
- return resolved;
1820
+ // Recorded uncompensated above: UIKit adds the overlap back on
1821
+ // top. A sheet shorter than the overlap still grows by the rest.
1822
+ return fmax(resolved - self.minimizedKeyboardOverlap, 0);
1247
1823
  }];
1248
1824
  }
1249
1825
 
@@ -1298,6 +1874,8 @@ static BOOL TrueSheetIsPhoneIdiom(void) {
1298
1874
 
1299
1875
  - (void)setupActiveDetentWithIndex:(NSInteger)index {
1300
1876
  _activeDetentIndex = index;
1877
+ _displayedBackgroundIndex = index;
1878
+ [self setupBackground];
1301
1879
  [self applyActiveDetent];
1302
1880
  }
1303
1881
 
@@ -1311,34 +1889,72 @@ static BOOL TrueSheetIsPhoneIdiom(void) {
1311
1889
  [self applyActiveDetent];
1312
1890
  }
1313
1891
 
1314
- - (void)setupBackground {
1315
- auto effectiveBackgroundBlur = self.backgroundBlur;
1316
- if (@available(iOS 26.0, *)) {
1317
- // iOS 26+ has default liquid glass effect
1318
- } else if (effectiveBackgroundBlur == TrueSheetViewBackgroundBlur::None && !self.backgroundColor) {
1319
- effectiveBackgroundBlur = TrueSheetViewBackgroundBlur::SystemMaterial;
1892
+ - (BOOL)canUseBackgroundEffects:(NSArray<UISheetPresentationControllerDetent *> *)detents {
1893
+ #if RNTS_IPHONE_OS_VERSION_AVAILABLE(26_1) && !TARGET_OS_MACCATALYST
1894
+ if (@available(iOS 26.1, *)) {
1895
+ // Some 26.1 builds omit setters declared in the SDK.
1896
+ if (self.isDesignCompatibilityMode || ![self.sheet respondsToSelector:@selector(setBackgroundEffect:)]) {
1897
+ return NO;
1898
+ }
1899
+ for (UISheetPresentationControllerDetent *detent in detents) {
1900
+ if (![detent respondsToSelector:@selector(setBackgroundEffect:)]) {
1901
+ return NO;
1902
+ }
1903
+ }
1904
+ return YES;
1320
1905
  }
1906
+ #endif
1907
+ return NO;
1908
+ }
1321
1909
 
1322
- BOOL hasBlur = effectiveBackgroundBlur != TrueSheetViewBackgroundBlur::None;
1910
+ - (NSDictionary *)backgroundForIndex:(NSInteger)index {
1911
+ return index >= 0 && index < self.detentBackgrounds.count ? self.detentBackgrounds[index] : nil;
1912
+ }
1323
1913
 
1324
- _blurView.backgroundBlur = hasBlur ? effectiveBackgroundBlur : TrueSheetViewBackgroundBlur::None;
1325
- _blurView.blurIntensity = self.blurIntensity;
1326
- _blurView.blurInteraction = self.blurInteraction;
1327
- [_blurView applyBlurEffect];
1914
+ #if RNTS_IPHONE_OS_VERSION_AVAILABLE(26_1) && !TARGET_OS_MACCATALYST
1915
+ - (UIVisualEffect *)backgroundEffectForColor:(UIColor *)color
1916
+ blur:(TrueSheetViewBackgroundBlur)blur API_AVAILABLE(ios(26.1)) {
1917
+ if (blur != TrueSheetViewBackgroundBlur::None) {
1918
+ return [UIBlurEffect effectWithStyle:[BlurUtil blurEffectStyleFromEnum:blur]];
1919
+ }
1920
+ return color ? [UIColorEffect effectWithColor:color] : nil;
1921
+ }
1922
+ #endif
1328
1923
 
1924
+ - (void)setupBackground {
1329
1925
  #if RNTS_IPHONE_OS_VERSION_AVAILABLE(26_1) && !TARGET_OS_MACCATALYST
1330
1926
  if (@available(iOS 26.1, *)) {
1331
- if (!self.isDesignCompatibilityMode) {
1332
- if (self.backgroundColor || hasBlur) {
1333
- self.sheet.backgroundEffect = [UIColorEffect effectWithColor:[UIColor clearColor]];
1334
- } else {
1335
- self.sheet.backgroundEffect = nil;
1336
- }
1927
+ if (_usesBackgroundEffects) {
1928
+ self.sheet.backgroundEffect = [self backgroundEffectForColor:self.backgroundColor blur:self.backgroundBlur];
1929
+ self.view.backgroundColor = nil;
1930
+ _blurView.hidden = YES;
1931
+ _blurView.backgroundBlur = TrueSheetViewBackgroundBlur::None;
1932
+ [_blurView applyBlurEffect];
1933
+ return;
1337
1934
  }
1338
1935
  }
1339
1936
  #endif
1340
1937
 
1341
- self.view.backgroundColor = self.backgroundColor;
1938
+ NSDictionary *entry = [self backgroundForIndex:_displayedBackgroundIndex];
1939
+ BOOL hasEntry = entry[@"color"] || entry[@"blur"];
1940
+ UIColor *color = hasEntry ? entry[@"color"] : self.backgroundColor;
1941
+ auto effectiveBackgroundBlur =
1942
+ hasEntry ? (entry[@"blur"] ? static_cast<TrueSheetViewBackgroundBlur>([entry[@"blur"] intValue])
1943
+ : TrueSheetViewBackgroundBlur::None)
1944
+ : self.backgroundBlur;
1945
+ if (@available(iOS 26.0, *)) {
1946
+ // iOS 26+ has default liquid glass effect
1947
+ } else if (effectiveBackgroundBlur == TrueSheetViewBackgroundBlur::None && !color) {
1948
+ effectiveBackgroundBlur = TrueSheetViewBackgroundBlur::SystemMaterial;
1949
+ }
1950
+
1951
+ BOOL hasBlur = effectiveBackgroundBlur != TrueSheetViewBackgroundBlur::None;
1952
+
1953
+ _blurView.hidden = NO;
1954
+ _blurView.backgroundBlur = effectiveBackgroundBlur;
1955
+ [_blurView applyBlurEffect];
1956
+
1957
+ self.view.backgroundColor = hasBlur ? nil : color;
1342
1958
  }
1343
1959
 
1344
1960
  - (void)setupGrabber {
@@ -1385,6 +2001,8 @@ static BOOL TrueSheetIsPhoneIdiom(void) {
1385
2001
  }];
1386
2002
  } else if (strongSelf.dismissible) {
1387
2003
  [strongSelf.presentingViewController dismissViewControllerAnimated:YES completion:nil];
2004
+ } else {
2005
+ [strongSelf.delegate viewControllerDidAttemptDismiss];
1388
2006
  }
1389
2007
  };
1390
2008
 
@@ -1396,6 +2014,8 @@ static BOOL TrueSheetIsPhoneIdiom(void) {
1396
2014
  _grabberView.onIncrement = nil;
1397
2015
  _grabberView.onDecrement = nil;
1398
2016
  }
2017
+
2018
+ [self refreshAccessibilityContainer];
1399
2019
  }
1400
2020
 
1401
2021
  - (void)handleGrabberTap {
@@ -1408,8 +2028,12 @@ static BOOL TrueSheetIsPhoneIdiom(void) {
1408
2028
  return;
1409
2029
 
1410
2030
  NSInteger nextIndex = (currentIndex + 1) % detentCount;
1411
- if (nextIndex == 0 && detentCount == 1 && self.dismissible) {
1412
- [self.presentingViewController dismissViewControllerAnimated:YES completion:nil];
2031
+ if (nextIndex == 0 && detentCount == 1) {
2032
+ if (self.dismissible) {
2033
+ [self.presentingViewController dismissViewControllerAnimated:YES completion:nil];
2034
+ } else {
2035
+ [self.delegate viewControllerDidAttemptDismiss];
2036
+ }
1413
2037
  } else {
1414
2038
  [self.sheet animateChanges:^{
1415
2039
  [self resizeToDetentIndex:nextIndex];
@@ -1418,9 +2042,24 @@ static BOOL TrueSheetIsPhoneIdiom(void) {
1418
2042
  }
1419
2043
 
1420
2044
  - (BOOL)isAnchored {
1421
- return self.anchor == TrueSheetViewAnchor::Left || self.anchor == TrueSheetViewAnchor::Right;
2045
+ return self.placement == TrueSheetViewPlacement::Leading || self.placement == TrueSheetViewPlacement::Trailing;
1422
2046
  }
1423
2047
 
2048
+ #if RNTS_IPHONE_OS_VERSION_AVAILABLE(27_0)
2049
+ - (UISheetPresentationControllerPlacement)preferredPlacement API_AVAILABLE(ios(27.0)) {
2050
+ switch (self.placement) {
2051
+ case TrueSheetViewPlacement::Leading:
2052
+ return UISheetPresentationControllerPlacementLeading;
2053
+ case TrueSheetViewPlacement::Center:
2054
+ return UISheetPresentationControllerPlacementCenter;
2055
+ case TrueSheetViewPlacement::Trailing:
2056
+ return UISheetPresentationControllerPlacementTrailing;
2057
+ case TrueSheetViewPlacement::Automatic:
2058
+ return UISheetPresentationControllerPlacementAutomatic;
2059
+ }
2060
+ }
2061
+ #endif
2062
+
1424
2063
  - (void)setupAnchorViewInView:(UIView *)parentView {
1425
2064
  if (!parentView)
1426
2065
  return;
@@ -1428,6 +2067,18 @@ static BOOL TrueSheetIsPhoneIdiom(void) {
1428
2067
  [_anchorView removeFromSuperview];
1429
2068
  _anchorView = nil;
1430
2069
 
2070
+ #if RNTS_IPHONE_OS_VERSION_AVAILABLE(27_0)
2071
+ // iOS 27 exposes native placement. `sourceView` must stay nil since it overrides `preferredPlacement`.
2072
+ if (@available(iOS 27.0, *)) {
2073
+ if ([self.sheetPresentationController respondsToSelector:@selector(setPreferredPlacement:)]) {
2074
+ self.sheetPresentationController.sourceView = nil;
2075
+ self.sheetPresentationController.preferredPlacement = [self preferredPlacement];
2076
+ return;
2077
+ }
2078
+ }
2079
+ #endif
2080
+
2081
+ // Pre-27 has no explicit center, so `center` falls back to the system default like `automatic`.
1431
2082
  if (!self.isAnchored) {
1432
2083
  self.sheetPresentationController.sourceView = nil;
1433
2084
  return;
@@ -1439,7 +2090,7 @@ static BOOL TrueSheetIsPhoneIdiom(void) {
1439
2090
  [parentView addSubview:_anchorView];
1440
2091
 
1441
2092
  NSLayoutAnchor *horizontalAnchor =
1442
- self.anchor == TrueSheetViewAnchor::Right ? parentView.trailingAnchor : parentView.leadingAnchor;
2093
+ self.placement == TrueSheetViewPlacement::Trailing ? parentView.trailingAnchor : parentView.leadingAnchor;
1443
2094
 
1444
2095
  [NSLayoutConstraint activateConstraints:@[
1445
2096
  [_anchorView.bottomAnchor constraintEqualToAnchor:parentView.bottomAnchor],
@@ -1493,12 +2144,56 @@ static BOOL TrueSheetIsPhoneIdiom(void) {
1493
2144
 
1494
2145
  [self setupBackground];
1495
2146
  [self setupGrabber];
2147
+
2148
+ // Dim props changed while presented. A hidden custom dim stays until dismiss;
2149
+ // at alpha 0 it no longer takes touches.
2150
+ if (_isPresented) {
2151
+ [self setupDimView];
2152
+ _dimView.alpha = [self dimAlphaForIndex:self.currentDetentIndex];
2153
+ }
1496
2154
  }
1497
2155
 
1498
2156
  #pragma mark - UISheetPresentationControllerDelegate
1499
2157
 
1500
2158
  - (BOOL)presentationControllerShouldDismiss:(UIPresentationController *)presentationController {
1501
- return self.dismissible;
2159
+ // A sheet that can't be dragged can't be swiped away either, including by
2160
+ // pulling down its scroll view: refuse a dismissal while any pan is under
2161
+ // way, and keep the one from a tap outside the sheet. Reset on every call:
2162
+ // UIKit skips didAttemptToDismiss for a short pull.
2163
+ _refusedPanDismiss =
2164
+ self.dismissible && !self.draggable && [self hasActivePanInView:presentationController.containerView];
2165
+ return self.dismissible && !_refusedPanDismiss;
2166
+ }
2167
+
2168
+ - (BOOL)hasActivePanInView:(UIView *)view {
2169
+ if (!view) {
2170
+ return NO;
2171
+ }
2172
+ for (UIGestureRecognizer *recognizer in view.gestureRecognizers ?: @[]) {
2173
+ if ([recognizer isKindOfClass:[UIPanGestureRecognizer class]] &&
2174
+ (recognizer.state == UIGestureRecognizerStateBegan || recognizer.state == UIGestureRecognizerStateChanged)) {
2175
+ return YES;
2176
+ }
2177
+ }
2178
+ for (UIView *subview in view.subviews) {
2179
+ if ([self hasActivePanInView:subview]) {
2180
+ return YES;
2181
+ }
2182
+ }
2183
+ return NO;
2184
+ }
2185
+
2186
+ // UIKit calls this after shouldDismiss returns NO — the sheet has already
2187
+ // bounced back, so JS can confirm and dismiss programmatically (Apple's
2188
+ // unsaved-changes pattern).
2189
+ - (void)presentationControllerDidAttemptToDismiss:(UIPresentationController *)presentationController {
2190
+ // A swipe refused only because the sheet can't be dragged isn't an attempt
2191
+ // to dismiss a non-dismissible sheet.
2192
+ if (_refusedPanDismiss) {
2193
+ _refusedPanDismiss = NO;
2194
+ return;
2195
+ }
2196
+ [self.delegate viewControllerDidAttemptDismiss];
1502
2197
  }
1503
2198
 
1504
2199
  - (void)sheetPresentationControllerDidChangeSelectedDetentIdentifier: