@lodev09/react-native-true-sheet 4.0.0-beta.2 → 4.0.0-beta.20

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 (203) hide show
  1. package/README.md +25 -4
  2. package/android/build.gradle +22 -1
  3. package/android/src/main/java/com/lodev09/truesheet/TrueSheetContainerView.kt +50 -16
  4. package/android/src/main/java/com/lodev09/truesheet/TrueSheetContentView.kt +302 -184
  5. package/android/src/main/java/com/lodev09/truesheet/TrueSheetContentViewManager.kt +5 -1
  6. package/android/src/main/java/com/lodev09/truesheet/TrueSheetFooterView.kt +21 -110
  7. package/android/src/main/java/com/lodev09/truesheet/TrueSheetOverlayView.kt +116 -0
  8. package/android/src/main/java/com/lodev09/truesheet/TrueSheetOverlayViewManager.kt +45 -0
  9. package/android/src/main/java/com/lodev09/truesheet/TrueSheetPackage.kt +2 -1
  10. package/android/src/main/java/com/lodev09/truesheet/TrueSheetStateUpdater.kt +18 -0
  11. package/android/src/main/java/com/lodev09/truesheet/TrueSheetView.kt +119 -50
  12. package/android/src/main/java/com/lodev09/truesheet/TrueSheetViewController.kt +360 -163
  13. package/android/src/main/java/com/lodev09/truesheet/TrueSheetViewManager.kt +54 -12
  14. package/android/src/main/java/com/lodev09/truesheet/core/TrueSheetBottomSheetView.kt +22 -9
  15. package/android/src/main/java/com/lodev09/truesheet/core/TrueSheetCoordinatorLayout.kt +84 -5
  16. package/android/src/main/java/com/lodev09/truesheet/core/TrueSheetDetentCalculator.kt +104 -39
  17. package/android/src/main/java/com/lodev09/truesheet/core/TrueSheetDimView.kt +3 -0
  18. package/android/src/main/java/com/lodev09/truesheet/core/TrueSheetOverlayRootView.kt +137 -0
  19. package/android/src/main/java/com/lodev09/truesheet/events/TrueSheetLifecycleEvents.kt +15 -0
  20. package/android/src/main/java/com/lodev09/truesheet/utils/KeyboardUtils.kt +15 -2
  21. package/android/src/main/java/com/lodev09/truesheet/utils/ScreenUtils.kt +0 -12
  22. package/android/src/main/java/com/lodev09/truesheet/utils/ViewUtils.kt +19 -7
  23. package/android/src/main/jni/TrueSheetSpec.h +1 -0
  24. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetContentViewComponentDescriptor.h +1 -9
  25. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetContentViewShadowNode.cpp +110 -30
  26. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetContentViewShadowNode.h +30 -7
  27. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetContentViewState.cpp +2 -2
  28. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetContentViewState.h +9 -8
  29. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetFooterViewComponentDescriptor.h +3 -5
  30. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetFooterViewShadowNode.cpp +9 -3
  31. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetFooterViewShadowNode.h +3 -6
  32. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetFooterViewState.cpp +1 -1
  33. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetFooterViewState.h +4 -6
  34. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetInsets.cpp +1 -1
  35. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetInsets.h +1 -1
  36. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetLayoutUtils.h +43 -0
  37. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetOverlayViewComponentDescriptor.h +21 -0
  38. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetOverlayViewShadowNode.cpp +17 -0
  39. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetOverlayViewShadowNode.h +33 -0
  40. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetViewComponentDescriptor.h +3 -5
  41. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetViewShadowNode.cpp +7 -40
  42. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetViewShadowNode.h +3 -7
  43. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetViewState.cpp +3 -5
  44. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetViewState.h +4 -5
  45. package/ios/TrueSheetContainerView.h +27 -7
  46. package/ios/TrueSheetContainerView.mm +31 -11
  47. package/ios/TrueSheetContentView.h +43 -15
  48. package/ios/TrueSheetContentView.mm +211 -189
  49. package/ios/TrueSheetFooterView.h +6 -0
  50. package/ios/TrueSheetFooterView.mm +93 -17
  51. package/ios/TrueSheetModule.h +2 -7
  52. package/ios/TrueSheetModule.mm +7 -16
  53. package/ios/TrueSheetOverlayView.h +32 -0
  54. package/ios/TrueSheetOverlayView.mm +161 -0
  55. package/ios/TrueSheetView.mm +212 -55
  56. package/ios/TrueSheetViewController.h +31 -3
  57. package/ios/TrueSheetViewController.mm +393 -94
  58. package/ios/core/TrueSheetBlurView.h +0 -2
  59. package/ios/core/TrueSheetBlurView.mm +3 -36
  60. package/ios/core/TrueSheetDetentCalculator.h +7 -1
  61. package/ios/core/TrueSheetDetentCalculator.mm +27 -4
  62. package/ios/core/TrueSheetGrabberView.mm +13 -3
  63. package/ios/core/TrueSheetKeyboardObserver.mm +2 -0
  64. package/ios/core/TrueSheetOverlayContainerView.h +31 -0
  65. package/ios/core/TrueSheetOverlayContainerView.mm +35 -0
  66. package/ios/events/TrueSheetLifecycleEvents.h +2 -0
  67. package/ios/events/TrueSheetLifecycleEvents.mm +8 -0
  68. package/ios/tvos/TrueSheetStubs.mm +12 -0
  69. package/ios/utils/UIView+ScrollEdgeInteraction.h +1 -0
  70. package/ios/utils/UIView+ScrollEdgeInteraction.mm +24 -2
  71. package/lib/module/TrueSheet.js +173 -39
  72. package/lib/module/TrueSheet.js.map +1 -1
  73. package/lib/module/TrueSheet.web.js +218 -243
  74. package/lib/module/TrueSheet.web.js.map +1 -1
  75. package/lib/module/TrueSheetBackground.js +15 -0
  76. package/lib/module/TrueSheetBackground.js.map +1 -0
  77. package/lib/module/TrueSheetOverlay.js +30 -0
  78. package/lib/module/TrueSheetOverlay.js.map +1 -0
  79. package/lib/module/TrueSheetOverlay.web.js +35 -0
  80. package/lib/module/TrueSheetOverlay.web.js.map +1 -0
  81. package/lib/module/TrueSheetPeek.web.js +20 -15
  82. package/lib/module/TrueSheetPeek.web.js.map +1 -1
  83. package/lib/module/detentBackgrounds.js +21 -0
  84. package/lib/module/detentBackgrounds.js.map +1 -0
  85. package/lib/module/fabric/TrueSheetContentViewNativeComponent.ts +1 -1
  86. package/lib/module/fabric/TrueSheetOverlayViewNativeComponent.ts +12 -0
  87. package/lib/module/fabric/TrueSheetViewNativeComponent.ts +14 -10
  88. package/lib/module/index.js +1 -0
  89. package/lib/module/index.js.map +1 -1
  90. package/lib/module/mocks/index.js +18 -2
  91. package/lib/module/mocks/index.js.map +1 -1
  92. package/lib/module/mocks/navigationExpoRouter.js.map +1 -1
  93. package/lib/module/navigation/base-types.js +2 -0
  94. package/lib/module/navigation/base-types.js.map +1 -0
  95. package/lib/module/navigation/createTrueSheetRouter.js +0 -9
  96. package/lib/module/navigation/createTrueSheetRouter.js.map +1 -1
  97. package/lib/module/navigation/expo-router/base-types.js +2 -0
  98. package/lib/module/navigation/expo-router/base-types.js.map +1 -0
  99. package/lib/module/navigation/expo-router/index.js +10 -7
  100. package/lib/module/navigation/expo-router/index.js.map +1 -1
  101. package/lib/module/navigation/expo-router/types.js +4 -0
  102. package/lib/module/navigation/expo-router/types.js.map +1 -0
  103. package/lib/module/navigation/index.js.map +1 -1
  104. package/lib/module/navigation/screen/useSheetScreenState.js +1 -0
  105. package/lib/module/navigation/screen/useSheetScreenState.js.map +1 -1
  106. package/lib/module/web/constants.js +1 -1
  107. package/lib/module/web/constants.js.map +1 -1
  108. package/lib/module/web/layout.js +168 -0
  109. package/lib/module/web/layout.js.map +1 -0
  110. package/lib/module/web/vaul/constants.js +4 -0
  111. package/lib/module/web/vaul/constants.js.map +1 -1
  112. package/lib/module/web/vaul/context.js.map +1 -1
  113. package/lib/module/web/vaul/helpers.js +1 -0
  114. package/lib/module/web/vaul/helpers.js.map +1 -1
  115. package/lib/module/web/vaul/index.js +44 -6
  116. package/lib/module/web/vaul/index.js.map +1 -1
  117. package/lib/module/web/vaul/use-snap-points.js +6 -8
  118. package/lib/module/web/vaul/use-snap-points.js.map +1 -1
  119. package/lib/typescript/src/TrueSheet.d.ts +6 -0
  120. package/lib/typescript/src/TrueSheet.d.ts.map +1 -1
  121. package/lib/typescript/src/TrueSheet.types.d.ts +135 -34
  122. package/lib/typescript/src/TrueSheet.types.d.ts.map +1 -1
  123. package/lib/typescript/src/TrueSheet.web.d.ts.map +1 -1
  124. package/lib/typescript/src/TrueSheetBackground.d.ts +3 -0
  125. package/lib/typescript/src/TrueSheetBackground.d.ts.map +1 -0
  126. package/lib/typescript/src/TrueSheetOverlay.d.ts +8 -0
  127. package/lib/typescript/src/TrueSheetOverlay.d.ts.map +1 -0
  128. package/lib/typescript/src/TrueSheetOverlay.web.d.ts +8 -0
  129. package/lib/typescript/src/TrueSheetOverlay.web.d.ts.map +1 -0
  130. package/lib/typescript/src/TrueSheetPeek.web.d.ts +8 -4
  131. package/lib/typescript/src/TrueSheetPeek.web.d.ts.map +1 -1
  132. package/lib/typescript/src/detentBackgrounds.d.ts +10 -0
  133. package/lib/typescript/src/detentBackgrounds.d.ts.map +1 -0
  134. package/lib/typescript/src/fabric/TrueSheetOverlayViewNativeComponent.d.ts +6 -0
  135. package/lib/typescript/src/fabric/TrueSheetOverlayViewNativeComponent.d.ts.map +1 -0
  136. package/lib/typescript/src/fabric/TrueSheetViewNativeComponent.d.ts +11 -7
  137. package/lib/typescript/src/fabric/TrueSheetViewNativeComponent.d.ts.map +1 -1
  138. package/lib/typescript/src/index.d.ts +1 -0
  139. package/lib/typescript/src/index.d.ts.map +1 -1
  140. package/lib/typescript/src/mocks/index.d.ts +4 -0
  141. package/lib/typescript/src/mocks/index.d.ts.map +1 -1
  142. package/lib/typescript/src/mocks/navigationExpoRouter.d.ts +3 -3
  143. package/lib/typescript/src/mocks/navigationExpoRouter.d.ts.map +1 -1
  144. package/lib/typescript/src/navigation/base-types.d.ts +9 -0
  145. package/lib/typescript/src/navigation/base-types.d.ts.map +1 -0
  146. package/lib/typescript/src/navigation/createTrueSheetRouter.d.ts +2 -13
  147. package/lib/typescript/src/navigation/createTrueSheetRouter.d.ts.map +1 -1
  148. package/lib/typescript/src/navigation/expo-router/base-types.d.ts +8 -0
  149. package/lib/typescript/src/navigation/expo-router/base-types.d.ts.map +1 -0
  150. package/lib/typescript/src/navigation/expo-router/index.d.ts +8 -6
  151. package/lib/typescript/src/navigation/expo-router/index.d.ts.map +1 -1
  152. package/lib/typescript/src/navigation/expo-router/types.d.ts +44 -0
  153. package/lib/typescript/src/navigation/expo-router/types.d.ts.map +1 -0
  154. package/lib/typescript/src/navigation/index.d.ts +3 -4
  155. package/lib/typescript/src/navigation/index.d.ts.map +1 -1
  156. package/lib/typescript/src/navigation/screen/useSheetScreenState.d.ts +2 -1
  157. package/lib/typescript/src/navigation/screen/useSheetScreenState.d.ts.map +1 -1
  158. package/lib/typescript/src/navigation/types.d.ts +41 -5
  159. package/lib/typescript/src/navigation/types.d.ts.map +1 -1
  160. package/lib/typescript/src/web/constants.d.ts +1 -1
  161. package/lib/typescript/src/web/constants.d.ts.map +1 -1
  162. package/lib/typescript/src/web/layout.d.ts +19 -0
  163. package/lib/typescript/src/web/layout.d.ts.map +1 -0
  164. package/lib/typescript/src/web/vaul/constants.d.ts +1 -0
  165. package/lib/typescript/src/web/vaul/constants.d.ts.map +1 -1
  166. package/lib/typescript/src/web/vaul/context.d.ts +1 -0
  167. package/lib/typescript/src/web/vaul/context.d.ts.map +1 -1
  168. package/lib/typescript/src/web/vaul/helpers.d.ts +1 -0
  169. package/lib/typescript/src/web/vaul/helpers.d.ts.map +1 -1
  170. package/lib/typescript/src/web/vaul/index.d.ts +7 -1
  171. package/lib/typescript/src/web/vaul/index.d.ts.map +1 -1
  172. package/lib/typescript/src/web/vaul/use-snap-points.d.ts.map +1 -1
  173. package/package.json +17 -8
  174. package/react-native.config.js +1 -0
  175. package/src/TrueSheet.tsx +206 -48
  176. package/src/TrueSheet.types.ts +142 -35
  177. package/src/TrueSheet.web.tsx +270 -290
  178. package/src/TrueSheetBackground.tsx +17 -0
  179. package/src/TrueSheetOverlay.tsx +24 -0
  180. package/src/TrueSheetOverlay.web.tsx +33 -0
  181. package/src/TrueSheetPeek.web.tsx +25 -23
  182. package/src/detentBackgrounds.ts +24 -0
  183. package/src/fabric/TrueSheetContentViewNativeComponent.ts +1 -1
  184. package/src/fabric/TrueSheetOverlayViewNativeComponent.ts +12 -0
  185. package/src/fabric/TrueSheetViewNativeComponent.ts +14 -10
  186. package/src/index.ts +1 -0
  187. package/src/mocks/index.ts +17 -2
  188. package/src/mocks/navigationExpoRouter.ts +6 -5
  189. package/src/navigation/base-types.ts +20 -0
  190. package/src/navigation/createTrueSheetRouter.ts +5 -33
  191. package/src/navigation/expo-router/base-types.ts +18 -0
  192. package/src/navigation/expo-router/index.ts +28 -30
  193. package/src/navigation/expo-router/types.ts +116 -0
  194. package/src/navigation/index.ts +6 -7
  195. package/src/navigation/screen/useSheetScreenState.ts +2 -0
  196. package/src/navigation/types.ts +75 -22
  197. package/src/web/constants.ts +1 -1
  198. package/src/web/layout.ts +196 -0
  199. package/src/web/vaul/constants.ts +4 -0
  200. package/src/web/vaul/context.ts +1 -0
  201. package/src/web/vaul/helpers.ts +5 -0
  202. package/src/web/vaul/index.tsx +67 -5
  203. package/src/web/vaul/use-snap-points.ts +6 -14
@@ -45,6 +45,7 @@ static char TrueSheetAccessibilityWindowPreviousElementsKey;
45
45
  - (void)restoreWindowAccessibilityElements;
46
46
  - (void)setSheetAccessibilityElementsHidden:(BOOL)hidden;
47
47
  - (void)setAccessibilityContentElement:(UIView *)contentView;
48
+ - (NSArray *)accessibilityGrabberElements;
48
49
  - (void)endInteractiveDismissState;
49
50
  - (void)animateInteractiveContainerToTransform:(CGAffineTransform)transform
50
51
  duration:(NSTimeInterval)duration
@@ -53,12 +54,28 @@ static char TrueSheetAccessibilityWindowPreviousElementsKey;
53
54
 
54
55
  @end
55
56
 
57
+ // VoiceOver delivers the two-finger Z (escape) gesture by walking up the view
58
+ // hierarchy from the focused element, so the sheet's root view handles it.
59
+ @interface TrueSheetControllerView : UIView
60
+ @property (nonatomic, weak) TrueSheetViewController *controller;
61
+ @end
62
+
63
+ @implementation TrueSheetControllerView
64
+
65
+ - (BOOL)accessibilityPerformEscape {
66
+ return [self.controller accessibilityPerformEscape];
67
+ }
68
+
69
+ @end
70
+
56
71
  @implementation TrueSheetViewController {
57
72
  TrueSheetPositionState _lastEmittedPositionState;
58
73
  CGSize _lastReportedSize;
59
74
  CGFloat _autoDetentHeight;
60
75
  CGFloat _peekDetentHeight;
61
76
  NSInteger _pendingDetentIndex;
77
+ NSInteger _displayedBackgroundIndex;
78
+ BOOL _usesBackgroundEffects;
62
79
 
63
80
  // Present/dismiss tracking: a display link scoped to the transition
64
81
  // coordinator — started alongside the transition, stopped in its completion.
@@ -70,6 +87,11 @@ static char TrueSheetAccessibilityWindowPreviousElementsKey;
70
87
  BOOL _isDragging;
71
88
  BOOL _isWillDismissEmitted;
72
89
 
90
+ // Last bottom safe area observed while presented — reused as the estimate
91
+ // for the next present (beats the float-threshold guess for re-presents).
92
+ BOOL _hasObservedBottomInset;
93
+ CGFloat _observedBottomInset;
94
+
73
95
  BOOL _isInteractiveDismiss;
74
96
  CGFloat _interactiveStartPosition;
75
97
  UIView *_interactiveContainerView;
@@ -92,6 +114,8 @@ static char TrueSheetAccessibilityWindowPreviousElementsKey;
92
114
  - (instancetype)init {
93
115
  if (self = [super initWithNibName:nil bundle:nil]) {
94
116
  _detents = @[ @0.5, @1 ];
117
+ _detentBackgrounds = @[];
118
+ _displayedBackgroundIndex = 0;
95
119
  _contentHeight = @(0);
96
120
  _headerHeight = @(0);
97
121
  _footerHeight = @(0);
@@ -99,6 +123,7 @@ static char TrueSheetAccessibilityWindowPreviousElementsKey;
99
123
  _grabber = YES;
100
124
  _draggable = YES;
101
125
  _scrollingExpandsSheet = YES;
126
+ _footerAvoidsKeyboard = YES;
102
127
  _dismissible = YES;
103
128
  _dimmed = YES;
104
129
  _dimmedDetentIndex = @(0);
@@ -113,7 +138,6 @@ static char TrueSheetAccessibilityWindowPreviousElementsKey;
113
138
  _activeDetentIndex = -1;
114
139
  _pendingDetentIndex = -1;
115
140
 
116
- _blurInteraction = YES;
117
141
  _insetAdjustment = TrueSheetViewInsetAdjustment::Automatic;
118
142
  _detentCalculator = [[TrueSheetDetentCalculator alloc] init];
119
143
  _detentCalculator.delegate = self;
@@ -202,10 +226,19 @@ static char TrueSheetAccessibilityWindowPreviousElementsKey;
202
226
  }
203
227
 
204
228
  - (CGFloat)screenHeight {
205
- UIWindow *window = self.view.window;
229
+ // In a windowed iPad app (Stage Manager, iPadOS 26 windowing) the window can be shorter than
230
+ // UIScreen.mainScreen, so prefer the window's height. Some callers run before this controller's
231
+ // view is attached (e.g. mid-presentation detent resolution), so fall back to the presenter's.
232
+ UIWindow *window = self.view.window ?: self.presentingViewController.view.window;
206
233
  return window ? window.bounds.size.height : UIScreen.mainScreen.bounds.size.height;
207
234
  }
208
235
 
236
+ // Only phone sheets absorb the bottom safe-area inset — non-phone idioms
237
+ // (iPad form sheets) are inset from the screen edge by UIKit.
238
+ static BOOL TrueSheetIsPhoneIdiom(void) {
239
+ return UIDevice.currentDevice.userInterfaceIdiom == UIUserInterfaceIdiomPhone;
240
+ }
241
+
209
242
  - (CGFloat)detentBottomAdjustmentForHeight:(CGFloat)height {
210
243
  if (_insetAdjustment == TrueSheetViewInsetAdjustment::Automatic) {
211
244
  return 0;
@@ -215,7 +248,7 @@ static char TrueSheetAccessibilityWindowPreviousElementsKey;
215
248
  }
216
249
 
217
250
  - (CGFloat)bottomSafeAreaForHeight:(CGFloat)height {
218
- if (UIDevice.currentDevice.userInterfaceIdiom != UIUserInterfaceIdiomPhone) {
251
+ if (!TrueSheetIsPhoneIdiom()) {
219
252
  return 0;
220
253
  }
221
254
 
@@ -232,52 +265,117 @@ static char TrueSheetAccessibilityWindowPreviousElementsKey;
232
265
  }
233
266
 
234
267
  /**
235
- * Bottom inset excluded from the auto detent. A relative footer owns the
236
- * sheet's bottom edge, so it absorbs the inset — UIKit lays custom detents out
237
- * above the safe area, which would otherwise gap the content from the footer.
268
+ * Largest height the sheet can reach across its configured detents, excluding
269
+ * the inset currently baked into the footer's height. Basis for the footer's
270
+ * inset absorption so it follows the same rule as bottomSafeAreaForHeight: —
271
+ * a sheet that only ever floats (small detents on iOS 26) keeps its footer
272
+ * unpadded.
238
273
  */
239
- - (CGFloat)autoDetentBottomInsetForHeight:(CGFloat)height {
240
- if (_insetAdjustment != TrueSheetViewInsetAdjustment::Automatic) {
241
- // 'none' already excludes the inset for every detent
242
- return 0;
274
+ - (CGFloat)maxNaturalDetentHeight {
275
+ CGFloat maxHeight = 0;
276
+
277
+ for (id detent in self.detents) {
278
+ if (![detent isKindOfClass:[NSNumber class]]) {
279
+ continue;
280
+ }
281
+
282
+ CGFloat value = [detent doubleValue];
283
+ CGFloat height = 0;
284
+ if (value == -1) {
285
+ height = [_detentCalculator autoHeightForKeyboardHeight:0] - [self autoDetentBottomInset];
286
+ } else if (value == -2) {
287
+ height = [_detentCalculator peekHeight] - [self peekDetentBottomInset];
288
+ } else if (value > 0 && value <= 1) {
289
+ height = value * self.screenHeight;
290
+ }
291
+ maxHeight = fmax(maxHeight, height);
243
292
  }
244
293
 
245
- if (_absoluteFooter || [self.footerHeight floatValue] <= 0) {
246
- return 0;
294
+ if (self.maxContentHeight) {
295
+ maxHeight = fmin(maxHeight, [self.maxContentHeight floatValue]);
247
296
  }
248
297
 
249
- return [self bottomSafeAreaForHeight:height];
298
+ return maxHeight > 0 ? maxHeight : self.screenHeight;
250
299
  }
251
300
 
252
- // The inset the footer absorbs as padding. Uses the auto detent height when
253
- // available so it matches the auto detent's inset exclusion above.
301
+ // The inset the footer absorbs as padding.
254
302
  - (CGFloat)footerBottomInset {
255
303
  if (_insetAdjustment != TrueSheetViewInsetAdjustment::Automatic) {
256
304
  return 0;
257
305
  }
258
306
 
259
- return [self bottomSafeAreaForHeight:_autoDetentHeight > 0 ? _autoDetentHeight : self.screenHeight];
307
+ // Early out so the maxNaturalDetentHeight fallback below doesn't run per
308
+ // layout pass on iPad only to be zeroed by bottomSafeAreaForHeight:.
309
+ if (!TrueSheetIsPhoneIdiom()) {
310
+ return 0;
311
+ }
312
+
313
+ // Once laid out, the sheet's own safe area is the authority — it reflects
314
+ // UIKit's actual float/anchored decision (a floating sheet gets 0), where
315
+ // bottomSafeAreaForHeight: can only guess the float threshold. Guessing
316
+ // wrong leaves the safe-area region unfilled (gap) or double-padded.
317
+ UIView *view = self.viewIfLoaded;
318
+ if (view.window) {
319
+ _hasObservedBottomInset = YES;
320
+ _observedBottomInset = view.safeAreaInsets.bottom;
321
+ return _observedBottomInset;
322
+ }
323
+
324
+ // Off-window while presented (e.g. hidden behind a native screen): the last
325
+ // observed value is this sheet's own truth — prefer it over the
326
+ // float-threshold guess. Cleared on dismiss so a re-present observes fresh
327
+ // geometry (rotation or detent changes while dismissed would stale it).
328
+ if (_hasObservedBottomInset) {
329
+ return _observedBottomInset;
330
+ }
331
+
332
+ return [self bottomSafeAreaForHeight:[self maxNaturalDetentHeight]];
333
+ }
334
+
335
+ // Re-resolve alongside the keyboard animation as its overlap with the pinned
336
+ // footer changes, including keyboard height changes while already visible.
337
+ - (void)setKeyboardHeight:(CGFloat)keyboardHeight {
338
+ if (_keyboardHeight == keyboardHeight) {
339
+ return;
340
+ }
341
+ _keyboardHeight = keyboardHeight;
342
+
343
+ if (_footerInsetAdjustment && !_footerAvoidsKeyboard && self.isPresented) {
344
+ [self setupSheetDetentsForSizeChange];
345
+ }
260
346
  }
261
347
 
262
348
  /**
263
- * Bottom inset excluded from the peek detent. An absolute footer counts
264
- * toward the peek height and absorbs the inset as padding, but UIKit already
265
- * lays custom detents out above the safe area — subtract it so the inset
266
- * isn't doubled.
349
+ * Bottom inset excluded from the auto detent. A relative footer (or an
350
+ * absolute one padding the scrollable) owns the sheet's bottom edge and
351
+ * carries the inset in its height (see footerBottomInset) — subtract exactly
352
+ * what was baked in so UIKit's own safe-area layout doesn't double it.
267
353
  */
268
- - (CGFloat)peekDetentBottomInsetForHeight:(CGFloat)height {
269
- if (_insetAdjustment != TrueSheetViewInsetAdjustment::Automatic) {
354
+ - (CGFloat)autoDetentBottomInset {
355
+ if ((_absoluteFooter && !self.footerInsetAdjustment) || [self.footerHeight floatValue] <= 0) {
270
356
  return 0;
271
357
  }
272
358
 
359
+ return _appliedFooterBottomInset;
360
+ }
361
+
362
+ /**
363
+ * Bottom inset excluded from the peek detent. An absolute footer counts
364
+ * toward the peek height and carries the inset in its height — subtract
365
+ * exactly what was baked in so it isn't doubled.
366
+ */
367
+ - (CGFloat)peekDetentBottomInset {
273
368
  if (!_absoluteFooter || [self.footerHeight floatValue] <= 0) {
274
369
  return 0;
275
370
  }
276
371
 
277
- return [self bottomSafeAreaForHeight:height];
372
+ return _appliedFooterBottomInset;
278
373
  }
279
374
 
280
375
  - (BOOL)isDesignCompatibilityMode {
376
+ if (@available(iOS 27.0, *)) {
377
+ return NO;
378
+ }
281
379
  if (@available(iOS 26.0, *)) {
282
380
  NSNumber *value = [[NSBundle mainBundle] objectForInfoDictionaryKey:@"UIDesignRequiresCompatibility"];
283
381
  return value.boolValue;
@@ -314,6 +412,12 @@ static char TrueSheetAccessibilityWindowPreviousElementsKey;
314
412
 
315
413
  #pragma mark - View Lifecycle
316
414
 
415
+ - (void)loadView {
416
+ TrueSheetControllerView *view = [[TrueSheetControllerView alloc] init];
417
+ view.controller = self;
418
+ self.view = view;
419
+ }
420
+
317
421
  - (void)viewDidLoad {
318
422
  [super viewDidLoad];
319
423
  self.view.autoresizingMask = UIViewAutoresizingFlexibleHeight | UIViewAutoresizingFlexibleWidth;
@@ -421,6 +525,37 @@ static char TrueSheetAccessibilityWindowPreviousElementsKey;
421
525
  }
422
526
  }
423
527
 
528
+ - (NSArray *)accessibilityGrabberElements {
529
+ if (_grabberView && !_grabberView.hidden) {
530
+ return @[ _grabberView ];
531
+ }
532
+
533
+ // The system grabber is an accessibility element UIKit adds to the presented view
534
+ // (a sibling of our root view). Expose it so VoiceOver can resize or dismiss with it.
535
+ // UIKit also keeps a transparent bottom grabber there, so skip invisible views.
536
+ NSMutableArray *elements = [NSMutableArray array];
537
+ for (UIView *subview in self.presentationController.presentedView.subviews) {
538
+ if (subview != self.view && subview.isAccessibilityElement && !subview.hidden && subview.alpha > 0) {
539
+ [elements addObject:subview];
540
+ }
541
+ }
542
+ return elements;
543
+ }
544
+
545
+ - (BOOL)accessibilityPerformEscape {
546
+ if (!_isPresented) {
547
+ return NO;
548
+ }
549
+
550
+ if (!self.dismissible) {
551
+ [self.delegate viewControllerDidAttemptDismiss];
552
+ return NO;
553
+ }
554
+
555
+ [self.presentingViewController dismissViewControllerAnimated:YES completion:nil];
556
+ return YES;
557
+ }
558
+
424
559
  - (void)setAccessibilityContentElement:(UIView *)contentView {
425
560
  BOOL hasAccessibilityFooterElements = [contentView isKindOfClass:[TrueSheetContainerView class]] &&
426
561
  [(TrueSheetContainerView *)contentView hasAccessibilityFooterElements];
@@ -433,12 +568,12 @@ static char TrueSheetAccessibilityWindowPreviousElementsKey;
433
568
  // its accessibilityElements getter recomputes live, so a footer/content subtree
434
569
  // that remounts (e.g. swapping a footer button on a state change) stays
435
570
  // discoverable instead of leaving this snapshot pointing at destroyed views.
436
- NSArray *accessibilityElements;
571
+ NSMutableArray *accessibilityElements = [[self accessibilityGrabberElements] mutableCopy];
437
572
  if (isAccessibilityModal) {
438
573
  NSArray *contentElements = contentView.accessibilityElements;
439
- accessibilityElements = contentElements.count > 0 ? contentElements : @[ contentView ];
574
+ [accessibilityElements addObjectsFromArray:contentElements.count > 0 ? contentElements : @[ contentView ]];
440
575
  } else {
441
- accessibilityElements = @[ contentView ];
576
+ [accessibilityElements addObject:contentView];
442
577
  }
443
578
 
444
579
  self.view.isAccessibilityElement = NO;
@@ -451,6 +586,15 @@ static char TrueSheetAccessibilityWindowPreviousElementsKey;
451
586
  presentedView.accessibilityViewIsModal = isAccessibilityModal;
452
587
  presentedView.accessibilityElements = accessibilityElements;
453
588
 
589
+ // The system grabber is a sibling of our root view, so an escape performed while it
590
+ // is focused walks presentedView → window and never reaches TrueSheetControllerView.
591
+ if (@available(iOS 17.0, *)) {
592
+ __weak __typeof(self) weakSelf = self;
593
+ presentedView.accessibilityPerformEscapeBlock = ^BOOL {
594
+ return [weakSelf accessibilityPerformEscape];
595
+ };
596
+ }
597
+
454
598
  UIWindow *window = self.view.window;
455
599
  UIViewController *presentingViewController = [self accessibilityPresentingViewController];
456
600
  if (window && presentingViewController.view) {
@@ -520,6 +664,7 @@ static char TrueSheetAccessibilityWindowPreviousElementsKey;
520
664
  [self restoreWindowAccessibilityElements];
521
665
  _isPresented = NO;
522
666
  _isWillDismissEmitted = NO;
667
+ _hasObservedBottomInset = NO;
523
668
 
524
669
  [_anchorView removeFromSuperview];
525
670
  _anchorView = nil;
@@ -561,8 +706,10 @@ static char TrueSheetAccessibilityWindowPreviousElementsKey;
561
706
  }
562
707
 
563
708
  // Dismissing with the keyboard up skips the hide notification — don't let
564
- // the stale flag block learning on the next present.
709
+ // the stale flag block learning on the next present, or a stale height keep
710
+ // the footer out of the auto detent.
565
711
  _keyboardSheetGrown = NO;
712
+ _keyboardHeight = 0;
566
713
 
567
714
  [self emitDidDismissEvents];
568
715
  }
@@ -575,24 +722,26 @@ static char TrueSheetAccessibilityWindowPreviousElementsKey;
575
722
  return;
576
723
  }
577
724
 
578
- if (_pendingContentSizeChange || _pendingDetentsChange) {
725
+ // Mid-drag the frame tracks the finger — never settle or learn from it;
726
+ // pending changes keep their flags and settle on the first post-drag layout.
727
+ if ((_pendingContentSizeChange || _pendingDetentsChange) && !_isDragging) {
579
728
  _pendingContentSizeChange = NO;
580
729
  _pendingDetentsChange = NO;
581
730
  [self settleAtDetentIndex:self.currentDetentIndex debug:@"layout"];
582
- } else if (!_isDragging) {
583
- // Drags emit from the pan handler. A child on top moves this sheet two
584
- // ways: an animated collapse/restore step (presenting/dismissing) — emit
585
- // the target non-realtime so JS animates to it — or direct per-frame
586
- // re-layouts while the child drags, which stay realtime.
731
+ } else {
732
+ // A child on top moves this sheet two ways: an animated collapse/restore
733
+ // step (presenting/dismissing) — emit the target non-realtime so JS
734
+ // animates to it — or direct per-frame re-layouts while the child drags,
735
+ // which stay realtime. Mid-drag no child is on top, so drags emit realtime.
587
736
  BOOL realtime = self.presentedViewController == nil || !self.isPresentedViewAnimating;
588
737
 
589
738
  // A layout pass at rest can nudge the frame by a sub-pixel amount
590
739
  // (pixel-grid snapping after present), leaving the interpolated index
591
740
  // slightly off the whole number. Re-learn when the frame is effectively
592
741
  // at the current detent so the drift is absorbed instead of emitted.
593
- // The tight threshold keeps real movement (e.g. scroll-driven expansion)
594
- // from being absorbed.
595
- if (self.presentedViewController == nil && !self.isPresentedViewAnimating) {
742
+ // The tight threshold keeps real movement (e.g. scroll-driven expansion
743
+ // or a drag hovering near the detent) from being absorbed.
744
+ if (!_isDragging && self.presentedViewController == nil && !self.isPresentedViewAnimating) {
596
745
  NSInteger index = self.currentDetentIndex;
597
746
  CGFloat expectedHeight = [_detentCalculator resolvedHeightForIndex:index];
598
747
  CGFloat actualHeight = self.screenHeight - self.currentPosition;
@@ -605,9 +754,46 @@ static char TrueSheetAccessibilityWindowPreviousElementsKey;
605
754
  }
606
755
  }
607
756
 
757
+ // Rotation or an iPad window resize changes the height fractional detents
758
+ // resolve against — re-resolve alongside the transition so the sheet lands on
759
+ // the new height instead of waiting for a content-driven rebuild.
760
+ - (void)viewWillTransitionToSize:(CGSize)size
761
+ withTransitionCoordinator:(id<UIViewControllerTransitionCoordinator>)coordinator {
762
+ [super viewWillTransitionToSize:size withTransitionCoordinator:coordinator];
763
+
764
+ if (@available(iOS 16.0, *)) {
765
+ [coordinator
766
+ animateAlongsideTransition:^(id<UIViewControllerTransitionCoordinatorContext> context) {
767
+ [self.sheet invalidateDetents];
768
+ }
769
+ completion:nil];
770
+ }
771
+ }
772
+
773
+ // Fires when UIKit resolves the sheet's float/anchored placement (present,
774
+ // detent resize crossing the float threshold, rotation). The footer's
775
+ // absorbed inset follows the observed safe area — repush it right away so
776
+ // the footer pads/unpads in the same layout pass.
777
+ - (void)viewSafeAreaInsetsDidChange {
778
+ [super viewSafeAreaInsetsDidChange];
779
+
780
+ // Mid-dismiss the frame moves off-screen and reports transient insets —
781
+ // don't let them pollute the observed value (matches viewDidLayoutSubviews).
782
+ if (!self.isBeingDismissed) {
783
+ [self.delegate viewControllerSafeAreaInsetsDidChange];
784
+ }
785
+ }
786
+
608
787
  - (void)viewDidLayoutSubviews {
609
788
  [super viewDidLayoutSubviews];
610
789
 
790
+ // viewSafeAreaInsetsDidChange doesn't fire on re-present when the observed
791
+ // inset matches the previous presentation's — refresh every layout pass
792
+ // instead (deduped downstream, so unchanged values are free).
793
+ if (!self.isBeingDismissed) {
794
+ [self.delegate viewControllerSafeAreaInsetsDidChange];
795
+ }
796
+
611
797
  // Skip size reports while backgrounded behind a stacked child — the push-back
612
798
  // scaling changes the frame and would needlessly resize content.
613
799
  // _lastReportedSize stays stale so the restore pass re-reports any real change.
@@ -695,7 +881,14 @@ static char TrueSheetAccessibilityWindowPreviousElementsKey;
695
881
  _isDragging = YES;
696
882
  break;
697
883
  case UIGestureRecognizerStateChanged:
698
- [self emitChangePositionDelegateWithPosition:self.currentPosition realtime:YES debug:@"drag change"];
884
+ // Layout owns the emit while the sheet resizes with the finger (a
885
+ // layout pass is already queued), and the transition tick owns it
886
+ // during an interactive dismiss. Translation-only movement (e.g.
887
+ // dragging down to the first detent) triggers neither — emit here as
888
+ // the backup.
889
+ if (!_isTransitioning && !self.presentedView.layer.needsLayout && !self.viewIfLoaded.layer.needsLayout) {
890
+ [self emitChangePositionDelegateWithPosition:self.currentPosition realtime:YES debug:@"drag change"];
891
+ }
699
892
  break;
700
893
  case UIGestureRecognizerStateEnded:
701
894
  case UIGestureRecognizerStateCancelled: {
@@ -780,27 +973,26 @@ static char TrueSheetAccessibilityWindowPreviousElementsKey;
780
973
  }
781
974
 
782
975
  - (void)handleTransitionTick {
783
- // Interactive dismiss phase (finger down) — the pan handler emits.
784
- if (!_isDragging) {
785
- CGFloat position = self.livePosition;
786
-
787
- if (self.isBeingDismissed) {
788
- // Emit only once the dismiss is committed: on release UIKit moves the
789
- // model frame off-screen, while a cancelled drag rewinds it back to the
790
- // detent (isBeingDismissed stays YES during the rewind).
791
- if (self.currentPosition >= self.screenHeight) {
792
- [self emitWillDismissEvents];
793
- }
976
+ // Also covers the interactive dismiss phase (finger down) — layout passes
977
+ // are skipped while transitioning, so the tick owns position there too.
978
+ CGFloat position = self.livePosition;
794
979
 
795
- // Hide blur at the end of dismiss to prevent UIVisualEffectView
796
- // from causing a flicker/flash at the bottom edge of the sheet.
797
- if (self.screenHeight - position < 1) {
798
- _blurView.alpha = 0;
799
- }
980
+ if (self.isBeingDismissed) {
981
+ // Emit only once the dismiss is committed: on release UIKit moves the
982
+ // model frame off-screen, while a cancelled drag rewinds it back to the
983
+ // detent (isBeingDismissed stays YES during the rewind).
984
+ if (self.currentPosition >= self.screenHeight) {
985
+ [self emitWillDismissEvents];
800
986
  }
801
987
 
802
- [self emitChangePositionDelegateWithPosition:position realtime:YES debug:@"transition"];
988
+ // Hide blur at the end of dismiss to prevent UIVisualEffectView
989
+ // from causing a flicker/flash at the bottom edge of the sheet.
990
+ if (self.screenHeight - position < 1) {
991
+ _blurView.alpha = 0;
992
+ }
803
993
  }
994
+
995
+ [self emitChangePositionDelegateWithPosition:position realtime:YES debug:@"transition"];
804
996
  }
805
997
 
806
998
  /**
@@ -933,6 +1125,20 @@ static char TrueSheetAccessibilityWindowPreviousElementsKey;
933
1125
  }
934
1126
  }
935
1127
 
1128
+ if (self.isPresented && !self.isBeingDismissed && !_isWillDismissEmitted && !_usesBackgroundEffects) {
1129
+ NSInteger index = [_detentCalculator backgroundIndexForPosition:position displayedIndex:_displayedBackgroundIndex];
1130
+ if (index != _displayedBackgroundIndex) {
1131
+ _displayedBackgroundIndex = index;
1132
+ [UIView animateWithDuration:0.2
1133
+ delay:0
1134
+ options:UIViewAnimationOptionBeginFromCurrentState | UIViewAnimationOptionAllowUserInteraction
1135
+ animations:^{
1136
+ [self setupBackground];
1137
+ }
1138
+ completion:nil];
1139
+ }
1140
+ }
1141
+
936
1142
  TrueSheetPositionState state = {
937
1143
  .position = position,
938
1144
  .detent = [self interpolatedDetentForPosition:position],
@@ -998,7 +1204,7 @@ static char TrueSheetAccessibilityWindowPreviousElementsKey;
998
1204
  */
999
1205
  - (void)setupSheetDetentsForSizeChange {
1000
1206
  if (@available(iOS 16.0, *)) {
1001
- CGFloat autoHeight = [_detentCalculator autoHeight];
1207
+ CGFloat autoHeight = [_detentCalculator autoHeightForKeyboardHeight:_keyboardHeight];
1002
1208
  CGFloat peekHeight = [_detentCalculator peekHeight];
1003
1209
 
1004
1210
  if (fabs(autoHeight - _autoDetentHeight) < 0.5 && fabs(peekHeight - _peekDetentHeight) < 0.5) {
@@ -1031,7 +1237,7 @@ static char TrueSheetAccessibilityWindowPreviousElementsKey;
1031
1237
  NSMutableArray<UISheetPresentationControllerDetent *> *detents = [NSMutableArray array];
1032
1238
  [_detentCalculator clearResolvedHeights];
1033
1239
 
1034
- _autoDetentHeight = [_detentCalculator autoHeight];
1240
+ _autoDetentHeight = [_detentCalculator autoHeightForKeyboardHeight:_keyboardHeight];
1035
1241
  _peekDetentHeight = [_detentCalculator peekHeight];
1036
1242
 
1037
1243
  for (NSInteger index = 0; index < self.detents.count; index++) {
@@ -1041,6 +1247,20 @@ static char TrueSheetAccessibilityWindowPreviousElementsKey;
1041
1247
  }
1042
1248
 
1043
1249
  [_detentCalculator setDetentCount:self.detents.count];
1250
+ _usesBackgroundEffects = [self canUseBackgroundEffects:detents];
1251
+ #if RNTS_IPHONE_OS_VERSION_AVAILABLE(26_1) && !TARGET_OS_MACCATALYST
1252
+ if (@available(iOS 26.1, *)) {
1253
+ if (_usesBackgroundEffects) {
1254
+ for (NSInteger index = 0; index < detents.count; index++) {
1255
+ NSDictionary *entry = [self backgroundForIndex:index];
1256
+ auto blur = entry[@"blur"] ? static_cast<TrueSheetViewBackgroundBlur>([entry[@"blur"] intValue])
1257
+ : TrueSheetViewBackgroundBlur::None;
1258
+ detents[index].backgroundEffect = [self backgroundEffectForColor:entry[@"color"] blur:blur];
1259
+ }
1260
+ }
1261
+ }
1262
+ #endif
1263
+
1044
1264
  sheet.detents = detents;
1045
1265
 
1046
1266
  if (self.dimmed && [self.dimmedDetentIndex integerValue] == 0) {
@@ -1079,8 +1299,7 @@ static char TrueSheetAccessibilityWindowPreviousElementsKey;
1079
1299
  return [self customDetentWithIdentifier:@"custom-auto"
1080
1300
  atIndex:index
1081
1301
  heightBlock:^CGFloat {
1082
- CGFloat height = self->_autoDetentHeight;
1083
- return height - [self autoDetentBottomInsetForHeight:height];
1302
+ return self->_autoDetentHeight - [self autoDetentBottomInset];
1084
1303
  }];
1085
1304
  } else {
1086
1305
  return [UISheetPresentationControllerDetent mediumDetent];
@@ -1092,8 +1311,7 @@ static char TrueSheetAccessibilityWindowPreviousElementsKey;
1092
1311
  return [self customDetentWithIdentifier:@"custom-peek"
1093
1312
  atIndex:index
1094
1313
  heightBlock:^CGFloat {
1095
- CGFloat height = self->_peekDetentHeight;
1096
- return height - [self peekDetentBottomInsetForHeight:height];
1314
+ return self->_peekDetentHeight - [self peekDetentBottomInset];
1097
1315
  }];
1098
1316
  } else {
1099
1317
  return [UISheetPresentationControllerDetent mediumDetent];
@@ -1107,8 +1325,13 @@ static char TrueSheetAccessibilityWindowPreviousElementsKey;
1107
1325
 
1108
1326
  if (@available(iOS 16.0, *)) {
1109
1327
  NSString *detentId = [NSString stringWithFormat:@"custom-%f", value];
1110
- CGFloat sheetHeight = value * self.screenHeight;
1111
- return [self customDetentWithIdentifier:detentId height:sheetHeight atIndex:index];
1328
+ // Resolve lazily — detents are built before presentation (no window yet), and
1329
+ // UIKit re-resolves on container size changes (rotation, windowed iPad resize).
1330
+ return [self customDetentWithIdentifier:detentId
1331
+ atIndex:index
1332
+ heightBlock:^CGFloat {
1333
+ return value * self.screenHeight;
1334
+ }];
1112
1335
  } else if (value >= 0.5) {
1113
1336
  return [UISheetPresentationControllerDetent largeDetent];
1114
1337
  } else {
@@ -1116,16 +1339,6 @@ static char TrueSheetAccessibilityWindowPreviousElementsKey;
1116
1339
  }
1117
1340
  }
1118
1341
 
1119
- - (UISheetPresentationControllerDetent *)customDetentWithIdentifier:(NSString *)identifier
1120
- height:(CGFloat)height
1121
- atIndex:(NSInteger)index API_AVAILABLE(ios(16.0)) {
1122
- return [self customDetentWithIdentifier:identifier
1123
- atIndex:index
1124
- heightBlock:^CGFloat {
1125
- return height;
1126
- }];
1127
- }
1128
-
1129
1342
  - (UISheetPresentationControllerDetent *)customDetentWithIdentifier:(NSString *)identifier
1130
1343
  atIndex:(NSInteger)index
1131
1344
  heightBlock:(CGFloat (^)(void))heightBlock
@@ -1215,6 +1428,8 @@ static char TrueSheetAccessibilityWindowPreviousElementsKey;
1215
1428
 
1216
1429
  - (void)setupActiveDetentWithIndex:(NSInteger)index {
1217
1430
  _activeDetentIndex = index;
1431
+ _displayedBackgroundIndex = index;
1432
+ [self setupBackground];
1218
1433
  [self applyActiveDetent];
1219
1434
  }
1220
1435
 
@@ -1228,34 +1443,72 @@ static char TrueSheetAccessibilityWindowPreviousElementsKey;
1228
1443
  [self applyActiveDetent];
1229
1444
  }
1230
1445
 
1231
- - (void)setupBackground {
1232
- auto effectiveBackgroundBlur = self.backgroundBlur;
1233
- if (@available(iOS 26.0, *)) {
1234
- // iOS 26+ has default liquid glass effect
1235
- } else if (effectiveBackgroundBlur == TrueSheetViewBackgroundBlur::None && !self.backgroundColor) {
1236
- effectiveBackgroundBlur = TrueSheetViewBackgroundBlur::SystemMaterial;
1446
+ - (BOOL)canUseBackgroundEffects:(NSArray<UISheetPresentationControllerDetent *> *)detents {
1447
+ #if RNTS_IPHONE_OS_VERSION_AVAILABLE(26_1) && !TARGET_OS_MACCATALYST
1448
+ if (@available(iOS 26.1, *)) {
1449
+ // Some 26.1 builds omit setters declared in the SDK.
1450
+ if (self.isDesignCompatibilityMode || ![self.sheet respondsToSelector:@selector(setBackgroundEffect:)]) {
1451
+ return NO;
1452
+ }
1453
+ for (UISheetPresentationControllerDetent *detent in detents) {
1454
+ if (![detent respondsToSelector:@selector(setBackgroundEffect:)]) {
1455
+ return NO;
1456
+ }
1457
+ }
1458
+ return YES;
1237
1459
  }
1460
+ #endif
1461
+ return NO;
1462
+ }
1238
1463
 
1239
- BOOL hasBlur = effectiveBackgroundBlur != TrueSheetViewBackgroundBlur::None;
1464
+ - (NSDictionary *)backgroundForIndex:(NSInteger)index {
1465
+ return index >= 0 && index < self.detentBackgrounds.count ? self.detentBackgrounds[index] : nil;
1466
+ }
1240
1467
 
1241
- _blurView.backgroundBlur = hasBlur ? effectiveBackgroundBlur : TrueSheetViewBackgroundBlur::None;
1242
- _blurView.blurIntensity = self.blurIntensity;
1243
- _blurView.blurInteraction = self.blurInteraction;
1244
- [_blurView applyBlurEffect];
1468
+ #if RNTS_IPHONE_OS_VERSION_AVAILABLE(26_1) && !TARGET_OS_MACCATALYST
1469
+ - (UIVisualEffect *)backgroundEffectForColor:(UIColor *)color
1470
+ blur:(TrueSheetViewBackgroundBlur)blur API_AVAILABLE(ios(26.1)) {
1471
+ if (blur != TrueSheetViewBackgroundBlur::None) {
1472
+ return [UIBlurEffect effectWithStyle:[BlurUtil blurEffectStyleFromEnum:blur]];
1473
+ }
1474
+ return color ? [UIColorEffect effectWithColor:color] : nil;
1475
+ }
1476
+ #endif
1245
1477
 
1478
+ - (void)setupBackground {
1246
1479
  #if RNTS_IPHONE_OS_VERSION_AVAILABLE(26_1) && !TARGET_OS_MACCATALYST
1247
1480
  if (@available(iOS 26.1, *)) {
1248
- if (!self.isDesignCompatibilityMode) {
1249
- if (self.backgroundColor || hasBlur) {
1250
- self.sheet.backgroundEffect = [UIColorEffect effectWithColor:[UIColor clearColor]];
1251
- } else {
1252
- self.sheet.backgroundEffect = nil;
1253
- }
1481
+ if (_usesBackgroundEffects) {
1482
+ self.sheet.backgroundEffect = [self backgroundEffectForColor:self.backgroundColor blur:self.backgroundBlur];
1483
+ self.view.backgroundColor = nil;
1484
+ _blurView.hidden = YES;
1485
+ _blurView.backgroundBlur = TrueSheetViewBackgroundBlur::None;
1486
+ [_blurView applyBlurEffect];
1487
+ return;
1254
1488
  }
1255
1489
  }
1256
1490
  #endif
1257
1491
 
1258
- self.view.backgroundColor = self.backgroundColor;
1492
+ NSDictionary *entry = [self backgroundForIndex:_displayedBackgroundIndex];
1493
+ BOOL hasEntry = entry[@"color"] || entry[@"blur"];
1494
+ UIColor *color = hasEntry ? entry[@"color"] : self.backgroundColor;
1495
+ auto effectiveBackgroundBlur =
1496
+ hasEntry ? (entry[@"blur"] ? static_cast<TrueSheetViewBackgroundBlur>([entry[@"blur"] intValue])
1497
+ : TrueSheetViewBackgroundBlur::None)
1498
+ : self.backgroundBlur;
1499
+ if (@available(iOS 26.0, *)) {
1500
+ // iOS 26+ has default liquid glass effect
1501
+ } else if (effectiveBackgroundBlur == TrueSheetViewBackgroundBlur::None && !color) {
1502
+ effectiveBackgroundBlur = TrueSheetViewBackgroundBlur::SystemMaterial;
1503
+ }
1504
+
1505
+ BOOL hasBlur = effectiveBackgroundBlur != TrueSheetViewBackgroundBlur::None;
1506
+
1507
+ _blurView.hidden = NO;
1508
+ _blurView.backgroundBlur = effectiveBackgroundBlur;
1509
+ [_blurView applyBlurEffect];
1510
+
1511
+ self.view.backgroundColor = hasBlur ? nil : color;
1259
1512
  }
1260
1513
 
1261
1514
  - (void)setupGrabber {
@@ -1302,6 +1555,8 @@ static char TrueSheetAccessibilityWindowPreviousElementsKey;
1302
1555
  }];
1303
1556
  } else if (strongSelf.dismissible) {
1304
1557
  [strongSelf.presentingViewController dismissViewControllerAnimated:YES completion:nil];
1558
+ } else {
1559
+ [strongSelf.delegate viewControllerDidAttemptDismiss];
1305
1560
  }
1306
1561
  };
1307
1562
 
@@ -1313,6 +1568,12 @@ static char TrueSheetAccessibilityWindowPreviousElementsKey;
1313
1568
  _grabberView.onIncrement = nil;
1314
1569
  _grabberView.onDecrement = nil;
1315
1570
  }
1571
+
1572
+ // Only the topmost sheet may refresh, or a parent's prop update would steal the
1573
+ // window accessibility override from the child presented over it.
1574
+ if (_isPresented && self.isTopmostPresentedController) {
1575
+ [self setupAccessibilityContainer];
1576
+ }
1316
1577
  }
1317
1578
 
1318
1579
  - (void)handleGrabberTap {
@@ -1325,8 +1586,12 @@ static char TrueSheetAccessibilityWindowPreviousElementsKey;
1325
1586
  return;
1326
1587
 
1327
1588
  NSInteger nextIndex = (currentIndex + 1) % detentCount;
1328
- if (nextIndex == 0 && detentCount == 1 && self.dismissible) {
1329
- [self.presentingViewController dismissViewControllerAnimated:YES completion:nil];
1589
+ if (nextIndex == 0 && detentCount == 1) {
1590
+ if (self.dismissible) {
1591
+ [self.presentingViewController dismissViewControllerAnimated:YES completion:nil];
1592
+ } else {
1593
+ [self.delegate viewControllerDidAttemptDismiss];
1594
+ }
1330
1595
  } else {
1331
1596
  [self.sheet animateChanges:^{
1332
1597
  [self resizeToDetentIndex:nextIndex];
@@ -1335,8 +1600,23 @@ static char TrueSheetAccessibilityWindowPreviousElementsKey;
1335
1600
  }
1336
1601
 
1337
1602
  - (BOOL)isAnchored {
1338
- return self.anchor == TrueSheetViewAnchor::Left || self.anchor == TrueSheetViewAnchor::Right;
1603
+ return self.placement == TrueSheetViewPlacement::Leading || self.placement == TrueSheetViewPlacement::Trailing;
1604
+ }
1605
+
1606
+ #if RNTS_IPHONE_OS_VERSION_AVAILABLE(27_0)
1607
+ - (UISheetPresentationControllerPlacement)preferredPlacement API_AVAILABLE(ios(27.0)) {
1608
+ switch (self.placement) {
1609
+ case TrueSheetViewPlacement::Leading:
1610
+ return UISheetPresentationControllerPlacementLeading;
1611
+ case TrueSheetViewPlacement::Center:
1612
+ return UISheetPresentationControllerPlacementCenter;
1613
+ case TrueSheetViewPlacement::Trailing:
1614
+ return UISheetPresentationControllerPlacementTrailing;
1615
+ case TrueSheetViewPlacement::Automatic:
1616
+ return UISheetPresentationControllerPlacementAutomatic;
1617
+ }
1339
1618
  }
1619
+ #endif
1340
1620
 
1341
1621
  - (void)setupAnchorViewInView:(UIView *)parentView {
1342
1622
  if (!parentView)
@@ -1345,6 +1625,18 @@ static char TrueSheetAccessibilityWindowPreviousElementsKey;
1345
1625
  [_anchorView removeFromSuperview];
1346
1626
  _anchorView = nil;
1347
1627
 
1628
+ #if RNTS_IPHONE_OS_VERSION_AVAILABLE(27_0)
1629
+ // iOS 27 exposes native placement. `sourceView` must stay nil since it overrides `preferredPlacement`.
1630
+ if (@available(iOS 27.0, *)) {
1631
+ if ([self.sheetPresentationController respondsToSelector:@selector(setPreferredPlacement:)]) {
1632
+ self.sheetPresentationController.sourceView = nil;
1633
+ self.sheetPresentationController.preferredPlacement = [self preferredPlacement];
1634
+ return;
1635
+ }
1636
+ }
1637
+ #endif
1638
+
1639
+ // Pre-27 has no explicit center, so `center` falls back to the system default like `automatic`.
1348
1640
  if (!self.isAnchored) {
1349
1641
  self.sheetPresentationController.sourceView = nil;
1350
1642
  return;
@@ -1356,7 +1648,7 @@ static char TrueSheetAccessibilityWindowPreviousElementsKey;
1356
1648
  [parentView addSubview:_anchorView];
1357
1649
 
1358
1650
  NSLayoutAnchor *horizontalAnchor =
1359
- self.anchor == TrueSheetViewAnchor::Right ? parentView.trailingAnchor : parentView.leadingAnchor;
1651
+ self.placement == TrueSheetViewPlacement::Trailing ? parentView.trailingAnchor : parentView.leadingAnchor;
1360
1652
 
1361
1653
  [NSLayoutConstraint activateConstraints:@[
1362
1654
  [_anchorView.bottomAnchor constraintEqualToAnchor:parentView.bottomAnchor],
@@ -1418,6 +1710,13 @@ static char TrueSheetAccessibilityWindowPreviousElementsKey;
1418
1710
  return self.dismissible;
1419
1711
  }
1420
1712
 
1713
+ // UIKit calls this after shouldDismiss returns NO — the sheet has already
1714
+ // bounced back, so JS can confirm and dismiss programmatically (Apple's
1715
+ // unsaved-changes pattern).
1716
+ - (void)presentationControllerDidAttemptToDismiss:(UIPresentationController *)presentationController {
1717
+ [self.delegate viewControllerDidAttemptDismiss];
1718
+ }
1719
+
1421
1720
  - (void)sheetPresentationControllerDidChangeSelectedDetentIdentifier:
1422
1721
  (UISheetPresentationController *)sheetPresentationController {
1423
1722
  dispatch_async(dispatch_get_main_queue(), ^{