@lodev09/react-native-true-sheet 4.0.0-beta.14 → 4.0.0-beta.15

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 (54) hide show
  1. package/android/src/main/java/com/lodev09/truesheet/TrueSheetContainerView.kt +28 -6
  2. package/android/src/main/java/com/lodev09/truesheet/TrueSheetContentView.kt +99 -7
  3. package/android/src/main/java/com/lodev09/truesheet/TrueSheetFooterView.kt +12 -0
  4. package/android/src/main/java/com/lodev09/truesheet/TrueSheetView.kt +18 -7
  5. package/android/src/main/java/com/lodev09/truesheet/TrueSheetViewController.kt +40 -39
  6. package/android/src/main/java/com/lodev09/truesheet/TrueSheetViewManager.kt +18 -8
  7. package/android/src/main/java/com/lodev09/truesheet/core/TrueSheetBottomSheetView.kt +14 -8
  8. package/android/src/main/java/com/lodev09/truesheet/core/TrueSheetCoordinatorLayout.kt +8 -1
  9. package/android/src/main/java/com/lodev09/truesheet/core/TrueSheetDetentCalculator.kt +32 -5
  10. package/ios/TrueSheetContainerView.h +17 -4
  11. package/ios/TrueSheetContainerView.mm +23 -6
  12. package/ios/TrueSheetContentView.h +13 -1
  13. package/ios/TrueSheetContentView.mm +93 -35
  14. package/ios/TrueSheetFooterView.mm +19 -5
  15. package/ios/TrueSheetView.mm +29 -28
  16. package/ios/TrueSheetViewController.h +21 -1
  17. package/ios/TrueSheetViewController.mm +54 -16
  18. package/ios/core/TrueSheetDetentCalculator.h +6 -1
  19. package/ios/core/TrueSheetDetentCalculator.mm +9 -4
  20. package/ios/core/TrueSheetKeyboardObserver.mm +2 -0
  21. package/lib/module/TrueSheet.js +8 -5
  22. package/lib/module/TrueSheet.js.map +1 -1
  23. package/lib/module/TrueSheet.web.js +135 -32
  24. package/lib/module/TrueSheet.web.js.map +1 -1
  25. package/lib/module/TrueSheetPeek.web.js +20 -15
  26. package/lib/module/TrueSheetPeek.web.js.map +1 -1
  27. package/lib/module/fabric/TrueSheetViewNativeComponent.ts +5 -3
  28. package/lib/module/navigation/expo-router/index.js +5 -4
  29. package/lib/module/navigation/expo-router/index.js.map +1 -1
  30. package/lib/module/web/constants.js +1 -1
  31. package/lib/module/web/constants.js.map +1 -1
  32. package/lib/typescript/src/TrueSheet.d.ts.map +1 -1
  33. package/lib/typescript/src/TrueSheet.types.d.ts +64 -25
  34. package/lib/typescript/src/TrueSheet.types.d.ts.map +1 -1
  35. package/lib/typescript/src/TrueSheet.web.d.ts.map +1 -1
  36. package/lib/typescript/src/TrueSheetPeek.web.d.ts +8 -4
  37. package/lib/typescript/src/TrueSheetPeek.web.d.ts.map +1 -1
  38. package/lib/typescript/src/fabric/TrueSheetViewNativeComponent.d.ts +5 -3
  39. package/lib/typescript/src/fabric/TrueSheetViewNativeComponent.d.ts.map +1 -1
  40. package/lib/typescript/src/navigation/expo-router/index.d.ts +5 -3
  41. package/lib/typescript/src/navigation/expo-router/index.d.ts.map +1 -1
  42. package/lib/typescript/src/navigation/types.d.ts +1 -1
  43. package/lib/typescript/src/navigation/types.d.ts.map +1 -1
  44. package/lib/typescript/src/web/constants.d.ts +1 -1
  45. package/lib/typescript/src/web/constants.d.ts.map +1 -1
  46. package/package.json +7 -6
  47. package/src/TrueSheet.tsx +7 -4
  48. package/src/TrueSheet.types.ts +67 -25
  49. package/src/TrueSheet.web.tsx +154 -30
  50. package/src/TrueSheetPeek.web.tsx +25 -23
  51. package/src/fabric/TrueSheetViewNativeComponent.ts +5 -3
  52. package/src/navigation/expo-router/index.ts +8 -11
  53. package/src/navigation/types.ts +3 -2
  54. package/src/web/constants.ts +1 -1
@@ -72,6 +72,7 @@ class TrueSheetCoordinatorLayout(context: Context) :
72
72
  // manually.
73
73
  private var streamSheetDraggable = false
74
74
  private var streamDraggableEditText = false
75
+ private var streamScrollsIndependently = false
75
76
 
76
77
  // Pull-down tracking. BottomSheetBehavior gives no callback when a downward drag
77
78
  // is clamped by `isHideable = false` — the sheet stays put (or the scrollable
@@ -196,6 +197,7 @@ class TrueSheetCoordinatorLayout(context: Context) :
196
197
  private fun updateStreamTargetFlags(ev: MotionEvent) {
197
198
  streamSheetDraggable = false
198
199
  streamDraggableEditText = false
200
+ streamScrollsIndependently = false
199
201
 
200
202
  val sheet = delegate?.findSheetView() ?: return
201
203
  val target = TouchTargetHelper.findTargetPathAndCoordinatesForTouch(ev.x, ev.y, this, FloatArray(2))
@@ -215,7 +217,10 @@ class TrueSheetCoordinatorLayout(context: Context) :
215
217
 
216
218
  var view: View? = target
217
219
  while (view != null && view !== this) {
218
- if (view.canScrollVertically(1) || view.canScrollVertically(-1)) return
220
+ if (view.canScrollVertically(1) || view.canScrollVertically(-1)) {
221
+ streamScrollsIndependently = !view.isNestedScrollingEnabled
222
+ return
223
+ }
219
224
  view = view.parent as? View
220
225
  }
221
226
 
@@ -297,6 +302,8 @@ class TrueSheetCoordinatorLayout(context: Context) :
297
302
  return false
298
303
  }
299
304
 
305
+ if (streamScrollsIndependently) return false
306
+
300
307
  if (streamGestureClaimed) {
301
308
  if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_CANCEL) {
302
309
  streamGestureClaimed = false
@@ -18,6 +18,15 @@ interface TrueSheetDetentCalculatorDelegate {
18
18
  val absoluteHeader: Boolean
19
19
  val footerHeight: Int
20
20
  val absoluteFooter: Boolean
21
+
22
+ /**
23
+ * True while the plugged scrollable is padded by the absolute footer's
24
+ * height (see ScrollableOptions.footerInsetAdjustment) — the footer then
25
+ * counts toward the auto detent like a relative one, so the content ends
26
+ * above it.
27
+ */
28
+ val footerInsetAdjustment: Boolean
29
+ val footerAvoidsKeyboard: Boolean
21
30
  val peekContentHeight: Int
22
31
  val contentBottomInset: Int
23
32
  val maxContentHeight: Int?
@@ -53,6 +62,14 @@ class TrueSheetDetentCalculator(private val reactContext: ThemedReactContext) {
53
62
  private val topInset: Int get() = delegate?.topInset ?: 0
54
63
  private val hasKeyboardFloor: Boolean get() = delegate?.hasKeyboardFloor == true
55
64
 
65
+ /**
66
+ * An absolute footer floats over the content and contributes no height to
67
+ * the auto detent — unless it pads the scrollable, then the content ends
68
+ * above it like a relative footer.
69
+ */
70
+ private val footerInAuto: Boolean
71
+ get() = delegate?.absoluteFooter == false || delegate?.footerInsetAdjustment == true
72
+
56
73
  /**
57
74
  * Height for auto (-1.0) detents: content + header + footer height.
58
75
  * An absolute (floating) header or footer overlaps the content, so it contributes no height.
@@ -60,14 +77,14 @@ class TrueSheetDetentCalculator(private val reactContext: ThemedReactContext) {
60
77
  private val autoDetentHeight: Int
61
78
  get() = contentHeight +
62
79
  (if (delegate?.absoluteHeader == true) 0 else headerHeight) +
63
- (if (delegate?.absoluteFooter == true) 0 else footerHeight)
80
+ (if (footerInAuto) footerHeight else 0)
64
81
 
65
82
  /**
66
- * Bottom inset for auto detents. A relative footer owns the sheet's bottom
67
- * edge, so it absorbs the inset instead of adding it to the auto height.
83
+ * Bottom inset for auto detents. A footer counted in the auto height owns
84
+ * the sheet's bottom edge, so it absorbs the inset instead of adding it.
68
85
  */
69
86
  private val autoDetentBottomInset: Int
70
- get() = if (footerHeight > 0 && delegate?.absoluteFooter == false) 0 else contentBottomInset
87
+ get() = if (footerHeight > 0 && footerInAuto) 0 else contentBottomInset
71
88
 
72
89
  /**
73
90
  * Bottom inset for peek detents. An absolute footer counts toward the peek
@@ -105,7 +122,17 @@ class TrueSheetDetentCalculator(private val reactContext: ThemedReactContext) {
105
122
  (detent * screenHeight).toInt() + contentBottomInset
106
123
  }
107
124
 
108
- val height = if (includeKeyboard) baseHeight + keyboardInset else baseHeight
125
+ val keyboardHeight = if (includeKeyboard) keyboardInset else 0
126
+ val footerKeyboardOverlap = if (detent == -1.0 &&
127
+ delegate?.absoluteFooter == true &&
128
+ footerInAuto &&
129
+ delegate?.footerAvoidsKeyboard == false
130
+ ) {
131
+ minOf(keyboardHeight, maxOf(0, footerHeight - contentBottomInset))
132
+ } else {
133
+ 0
134
+ }
135
+ val height = baseHeight + keyboardHeight - footerKeyboardOverlap
109
136
  val maxAllowedHeight = screenHeight + contentBottomInset
110
137
  return maxContentHeight?.let { minOf(height, it, maxAllowedHeight) } ?: minOf(height, maxAllowedHeight)
111
138
  }
@@ -16,13 +16,19 @@ NS_ASSUME_NONNULL_BEGIN
16
16
 
17
17
  @interface ScrollableOptions : NSObject
18
18
 
19
- @property (nonatomic, assign) BOOL contentInsetAdjustment;
19
+ @property (nonatomic, assign) facebook::react::TrueSheetViewContentInsetAdjustment contentInsetAdjustment;
20
20
  @property (nonatomic, assign) CGFloat keyboardScrollOffset;
21
21
  @property (nonatomic, assign) CGFloat keyboardOffset;
22
22
  @property (nonatomic, assign) BOOL scrollingExpandsSheet;
23
23
  @property (nonatomic, assign) facebook::react::TrueSheetViewTopScrollEdgeEffect topScrollEdgeEffect;
24
24
  @property (nonatomic, assign) facebook::react::TrueSheetViewBottomScrollEdgeEffect bottomScrollEdgeEffect;
25
25
 
26
+ /**
27
+ * Derived from `contentInsetAdjustment` — which of the two bottom insets apply
28
+ */
29
+ @property (nonatomic, readonly) BOOL safeAreaInsetAdjustment;
30
+ @property (nonatomic, readonly) BOOL footerInsetAdjustment;
31
+
26
32
  @end
27
33
 
28
34
  @class TrueSheetPeekView;
@@ -58,10 +64,17 @@ NS_ASSUME_NONNULL_BEGIN
58
64
  @property (nonatomic, assign) NSInteger scrollableHandle;
59
65
 
60
66
  /**
61
- * Whether the resolved scrollable's `contentInsetAdjustmentBehavior` is set to
62
- * `automatic` — already gated by the sheet's `insetAdjustment`
67
+ * Whether the resolved scrollable gets the bottom safe-area inset (UIKit's
68
+ * `contentInsetAdjustmentBehavior` set to `automatic`) — already gated by the
69
+ * sheet's `insetAdjustment`
70
+ */
71
+ @property (nonatomic, assign) BOOL safeAreaInsetAdjustment;
72
+
73
+ /**
74
+ * Whether the resolved scrollable is padded by the absolute footer's height —
75
+ * already gated by the footer position and a plugged scrollable
63
76
  */
64
- @property (nonatomic, assign) BOOL contentInsetAdjustment;
77
+ @property (nonatomic, assign) BOOL footerInsetAdjustment;
65
78
 
66
79
  /**
67
80
  * Bottom safe-area inset the footer absorbs as padding — the footer
@@ -32,7 +32,7 @@ using namespace facebook::react;
32
32
 
33
33
  - (instancetype)init {
34
34
  if (self = [super init]) {
35
- _contentInsetAdjustment = YES;
35
+ _contentInsetAdjustment = TrueSheetViewContentInsetAdjustment::Automatic;
36
36
  _keyboardScrollOffset = 0;
37
37
  _keyboardOffset = 0;
38
38
  _scrollingExpandsSheet = YES;
@@ -42,6 +42,16 @@ using namespace facebook::react;
42
42
  return self;
43
43
  }
44
44
 
45
+ - (BOOL)safeAreaInsetAdjustment {
46
+ return _contentInsetAdjustment == TrueSheetViewContentInsetAdjustment::Automatic ||
47
+ _contentInsetAdjustment == TrueSheetViewContentInsetAdjustment::SafeArea;
48
+ }
49
+
50
+ - (BOOL)footerInsetAdjustment {
51
+ return _contentInsetAdjustment == TrueSheetViewContentInsetAdjustment::Automatic ||
52
+ _contentInsetAdjustment == TrueSheetViewContentInsetAdjustment::Footer;
53
+ }
54
+
45
55
  @end
46
56
 
47
57
  @interface TrueSheetContainerView () <TrueSheetContentViewDelegate,
@@ -153,10 +163,16 @@ using namespace facebook::react;
153
163
  _contentView.keyboardOffset = scrollableOptions ? scrollableOptions.keyboardOffset : 0;
154
164
  }
155
165
 
166
+ - (void)setFooterInsetAdjustment:(BOOL)footerInsetAdjustment {
167
+ _footerInsetAdjustment = footerInsetAdjustment;
168
+ _contentView.footerInsetAdjustment = footerInsetAdjustment;
169
+ }
170
+
156
171
  - (void)setupScrollable {
157
172
  if (_contentView) {
158
173
  _contentView.scrollableHandle = _scrollableHandle;
159
- _contentView.contentInsetAdjustment = _contentInsetAdjustment;
174
+ _contentView.safeAreaInsetAdjustment = _safeAreaInsetAdjustment;
175
+ _contentView.footerInsetAdjustment = _footerInsetAdjustment;
160
176
  [_contentView setupScrollable];
161
177
  [_contentView applyScrollEdgeEffects:_scrollableOptions];
162
178
  if (@available(iOS 26.0, *)) {
@@ -202,6 +218,7 @@ using namespace facebook::react;
202
218
  _contentView = (TrueSheetContentView *)childComponentView;
203
219
  _contentView.delegate = self;
204
220
  _contentView.footerView = _footerView;
221
+ _contentView.footerInsetAdjustment = _footerInsetAdjustment;
205
222
 
206
223
  // Children mount bottom-up, so the content subtree is complete here.
207
224
  // Late-mounted peek views attach themselves instead (see TrueSheetPeekView).
@@ -283,6 +300,7 @@ using namespace facebook::react;
283
300
 
284
301
  - (void)footerViewDidChangeSize:(CGSize)newSize {
285
302
  [self.delegate containerViewFooterDidChangeSize:newSize];
303
+ [_contentView updateBottomInset];
286
304
  if (@available(iOS 26.0, *)) {
287
305
  [self setupEdgeInteractions];
288
306
  }
@@ -310,10 +328,9 @@ using namespace facebook::react;
310
328
  return;
311
329
  }
312
330
 
313
- if (_peekView != nil) {
314
- RCTLogWarn(@"TrueSheet: Sheet can only have one peek component.");
315
- return;
316
- }
331
+ // Last-attached wins. During a screen transition the incoming peek mounts
332
+ // before the outgoing one detaches, and the outgoing detach is a no-op.
333
+ _peekView.delegate = nil;
317
334
 
318
335
  _peekView = peekView;
319
336
  peekView.delegate = self;
@@ -69,10 +69,22 @@ NS_ASSUME_NONNULL_BEGIN
69
69
  * `automatic` while plugged so UIKit applies the bottom safe-area inset to
70
70
  * the scroll content. The previous behavior is restored on clear.
71
71
  */
72
- @property (nonatomic, assign) BOOL contentInsetAdjustment;
72
+ @property (nonatomic, assign) BOOL safeAreaInsetAdjustment;
73
+
74
+ /**
75
+ * Pads the resolved ScrollView's bottom content inset by the absolute
76
+ * footer's height so the last item scrolls into view above it. Stacks with
77
+ * the keyboard inset.
78
+ */
79
+ @property (nonatomic, assign) BOOL footerInsetAdjustment;
73
80
 
74
81
  - (RCTScrollViewComponentView *_Nullable)findScrollView;
75
82
 
83
+ /**
84
+ * Re-applies the footer and keyboard insets — call when the footer's size changes
85
+ */
86
+ - (void)updateBottomInset;
87
+
76
88
  /**
77
89
  * Resolve the ScrollView from `scrollableHandle`, wiring keyboard handling
78
90
  */
@@ -30,7 +30,7 @@ using namespace facebook::react;
30
30
  CGFloat _appliedKeyboardOffset;
31
31
  BOOL _observingTextChanges;
32
32
  UIScrollViewContentInsetAdjustmentBehavior _originalInsetAdjustmentBehavior;
33
- BOOL _appliedContentInsetAdjustment;
33
+ BOOL _appliedSafeAreaInsetAdjustment;
34
34
  }
35
35
 
36
36
  + (ComponentDescriptorProvider)componentDescriptorProvider {
@@ -69,29 +69,38 @@ using namespace facebook::react;
69
69
  [self clearScrollable];
70
70
  }
71
71
 
72
- - (void)setContentInsetAdjustment:(BOOL)contentInsetAdjustment {
73
- if (_contentInsetAdjustment == contentInsetAdjustment) {
72
+ - (void)setSafeAreaInsetAdjustment:(BOOL)safeAreaInsetAdjustment {
73
+ if (_safeAreaInsetAdjustment == safeAreaInsetAdjustment) {
74
74
  return;
75
75
  }
76
- _contentInsetAdjustment = contentInsetAdjustment;
77
- [self applyContentInsetAdjustment];
76
+ _safeAreaInsetAdjustment = safeAreaInsetAdjustment;
77
+ [self applySafeAreaInsetAdjustment];
78
+ [self updateBottomInset];
79
+ }
80
+
81
+ - (void)setFooterInsetAdjustment:(BOOL)footerInsetAdjustment {
82
+ if (_footerInsetAdjustment == footerInsetAdjustment) {
83
+ return;
84
+ }
85
+ _footerInsetAdjustment = footerInsetAdjustment;
86
+ [self updateBottomInset];
78
87
  }
79
88
 
80
89
  // Hands the bottom safe-area inset to UIKit — `automatic` only insets while
81
90
  // the content can scroll, which the Android counterpart mirrors manually.
82
- - (void)applyContentInsetAdjustment {
91
+ - (void)applySafeAreaInsetAdjustment {
83
92
  if (!_detectedScrollView) {
84
93
  return;
85
94
  }
86
95
 
87
96
  UIScrollView *scrollView = _detectedScrollView.scrollView;
88
- if (_contentInsetAdjustment && !_appliedContentInsetAdjustment) {
97
+ if (_safeAreaInsetAdjustment && !_appliedSafeAreaInsetAdjustment) {
89
98
  _originalInsetAdjustmentBehavior = scrollView.contentInsetAdjustmentBehavior;
90
99
  scrollView.contentInsetAdjustmentBehavior = UIScrollViewContentInsetAdjustmentAutomatic;
91
- _appliedContentInsetAdjustment = YES;
92
- } else if (!_contentInsetAdjustment && _appliedContentInsetAdjustment) {
100
+ _appliedSafeAreaInsetAdjustment = YES;
101
+ } else if (!_safeAreaInsetAdjustment && _appliedSafeAreaInsetAdjustment) {
93
102
  scrollView.contentInsetAdjustmentBehavior = _originalInsetAdjustmentBehavior;
94
- _appliedContentInsetAdjustment = NO;
103
+ _appliedSafeAreaInsetAdjustment = NO;
95
104
  }
96
105
  }
97
106
 
@@ -169,14 +178,16 @@ using namespace facebook::react;
169
178
  [self.delegate contentViewScrollViewDidChange];
170
179
  }
171
180
 
172
- [self updateKeyboardInset];
181
+ // Layout has settled for the whole tree — the footer overlap (sheet height,
182
+ // scroll view frame) and the keyboard inset are current here
183
+ [self updateBottomInset];
173
184
  }
174
185
 
175
186
  #pragma mark - Scrollable
176
187
 
177
188
  // The scroll indicator follows automatically — UIKit derives its insets from
178
189
  // the content inset while `automaticallyAdjustsScrollIndicatorInsets` is set.
179
- - (void)setKeyboardInset:(CGFloat)inset {
190
+ - (void)setBottomInset:(CGFloat)inset {
180
191
  if (!_detectedScrollView)
181
192
  return;
182
193
 
@@ -189,19 +200,57 @@ using namespace facebook::react;
189
200
  _detectedScrollView.scrollView.contentInset = contentInset;
190
201
  }
191
202
 
192
- - (void)updateKeyboardInset {
203
+ // How much of the absolute footer covers the scroll view — measured from the
204
+ // footer's layout position (pinned to the container's bottom edge; its
205
+ // keyboard rise is a transform) so a short scroll view ending above the footer
206
+ // gets no inset. UIKit stacks the safe area on top of contentInset while the
207
+ // adjustment behavior is automatic — take out the inset baked into the
208
+ // footer's height so the total lands on the footer's top edge. Stays constant
209
+ // across keyboard transitions: the footer drops its inset as it rises, and
210
+ // the baked value drops with it. A pinned footer adds only the overlap not
211
+ // already covered by the keyboard inset.
212
+ - (CGFloat)footerInsetAbove:(CGFloat)keyboardInset {
213
+ if (!_footerInsetAdjustment || !self.footerView || !_detectedScrollView) {
214
+ return 0;
215
+ }
216
+
217
+ UIView *container = self.footerView.superview;
218
+ UIScrollView *scrollView = _detectedScrollView.scrollView;
219
+ CGRect scrollFrame = [scrollView convertRect:scrollView.bounds toView:container];
220
+ CGFloat footerTop = container.bounds.size.height - self.footerView.bounds.size.height;
221
+
222
+ CGFloat inset = CGRectGetMaxY(scrollFrame) - footerTop;
223
+ if (_appliedSafeAreaInsetAdjustment) {
224
+ inset -= self.footerView.appliedBottomInset;
225
+ }
226
+ if (!_keyboardObserver.viewController.footerAvoidsKeyboard) {
227
+ inset -= keyboardInset;
228
+ }
229
+ return MAX(0, inset);
230
+ }
231
+
232
+ - (CGFloat)keyboardInset {
193
233
  CGFloat keyboardHeight = _keyboardObserver.currentHeight;
194
- if (_detectedScrollView && keyboardHeight > 0) {
195
- [self setKeyboardInset:[self keyboardInsetWithHeight:keyboardHeight]];
234
+ return keyboardHeight > 0 ? [self keyboardInsetWithHeight:keyboardHeight] : 0;
235
+ }
236
+
237
+ // Safe to call mid keyboard animation: the target matches the animation's
238
+ // model value (the footer inset doesn't change with the keyboard), so the
239
+ // early-out in setBottomInset: leaves the animation alone.
240
+ - (void)updateBottomInset {
241
+ if (!_detectedScrollView) {
242
+ return;
196
243
  }
244
+ CGFloat keyboardInset = [self keyboardInset];
245
+ [self setBottomInset:[self footerInsetAbove:keyboardInset] + keyboardInset];
197
246
  }
198
247
 
199
248
  - (void)clearScrollable {
200
- [self setKeyboardInset:0];
249
+ [self setBottomInset:0];
201
250
  _appliedKeyboardOffset = 0;
202
- if (_appliedContentInsetAdjustment) {
251
+ if (_appliedSafeAreaInsetAdjustment) {
203
252
  _detectedScrollView.scrollView.contentInsetAdjustmentBehavior = _originalInsetAdjustmentBehavior;
204
- _appliedContentInsetAdjustment = NO;
253
+ _appliedSafeAreaInsetAdjustment = NO;
205
254
  }
206
255
  _detectedScrollView = nil;
207
256
  }
@@ -223,10 +272,10 @@ using namespace facebook::react;
223
272
 
224
273
  _detectedScrollView = scrollView;
225
274
 
226
- [self applyContentInsetAdjustment];
275
+ [self applySafeAreaInsetAdjustment];
227
276
 
228
- // If keyboard is currently showing, re-apply the keyboard inset to the new ScrollView
229
- [self updateKeyboardInset];
277
+ // Apply the footer inset, and the keyboard inset if it's currently showing
278
+ [self updateBottomInset];
230
279
  }
231
280
 
232
281
  // Short or nested scroll views can end above the sheet's bottom edge.
@@ -240,18 +289,21 @@ using namespace facebook::react;
240
289
 
241
290
  // UIKit stacks the safe area on top of contentInset while the adjustment
242
291
  // behavior is automatic — take it out so the total lands on the keyboard edge.
243
- if (_appliedContentInsetAdjustment) {
292
+ if (_appliedSafeAreaInsetAdjustment) {
244
293
  inset = MAX(0, inset - scrollView.safeAreaInsets.bottom);
245
294
  }
246
295
 
247
296
  // Track how much of keyboardOffset actually lands in the inset so the caret
248
297
  // reveal can compensate — the offset shifts the inset, not the keyboard edge.
249
298
  CGFloat adjustedInset = MAX(0, inset + self.keyboardOffset);
250
- _appliedKeyboardOffset = adjustedInset - inset;
251
-
252
- // Content that already fits above the keyboard has nothing to reveal — the
253
- // inset would only open blank scroll range below it (huge gap at the bottom).
254
- if (scrollView.contentSize.height <= scrollView.bounds.size.height - adjustedInset) {
299
+ _appliedKeyboardOffset =
300
+ adjustedInset + [self footerInsetAbove:adjustedInset] - (inset + [self footerInsetAbove:inset]);
301
+
302
+ // Content that already fits above the keyboard (and footer) has nothing to
303
+ // reveal — the inset would only open blank scroll range below it (huge gap
304
+ // at the bottom).
305
+ if (scrollView.contentSize.height + [self footerInsetAbove:adjustedInset] <=
306
+ scrollView.bounds.size.height - adjustedInset) {
255
307
  _appliedKeyboardOffset = 0;
256
308
  return 0;
257
309
  }
@@ -260,9 +312,13 @@ using namespace facebook::react;
260
312
  }
261
313
 
262
314
  // An absolute footer floats over the viewport's bottom edge — extend the
263
- // caret target so it clears the footer, not just the keyboard.
315
+ // caret target so it clears the footer, not just the keyboard. Not needed
316
+ // while the footer inset is applied (the visible rect already ends above it)
317
+ // or when the footer stays behind the keyboard.
264
318
  - (CGFloat)footerOcclusion {
265
- if (self.footerView && _keyboardObserver.viewController.absoluteFooter) {
319
+ TrueSheetViewController *controller = _keyboardObserver.viewController;
320
+ if (self.footerView && controller.absoluteFooter && controller.footerAvoidsKeyboard &&
321
+ [self footerInsetAbove:0] <= 0) {
266
322
  return [self.footerView keyboardOcclusionHeight];
267
323
  }
268
324
  return 0;
@@ -340,15 +396,16 @@ using namespace facebook::react;
340
396
  TrueSheetViewController *sheetController = _keyboardObserver.viewController;
341
397
  UIView *firstResponder = sheetController ? [sheetController.view findFirstResponder] : nil;
342
398
 
343
- CGFloat inset = [self keyboardInsetWithHeight:height];
399
+ CGFloat keyboardInset = [self keyboardInsetWithHeight:height];
400
+ CGFloat inset = [self footerInsetAbove:keyboardInset] + keyboardInset;
344
401
  [UIView animateWithDuration:duration
345
402
  delay:0
346
403
  options:curve | UIViewAnimationOptionBeginFromCurrentState
347
404
  animations:^{
348
- [self setKeyboardInset:inset];
405
+ [self setBottomInset:inset];
349
406
  }
350
407
  completion:^(BOOL finished) {
351
- [self updateKeyboardInset];
408
+ [self updateBottomInset];
352
409
  }];
353
410
 
354
411
  // Defer scroll until the next run loop so content insets are applied first
@@ -373,7 +430,7 @@ using namespace facebook::react;
373
430
  dispatch_async(dispatch_get_main_queue(), ^{
374
431
  // Typing can grow the content (e.g. a multiline input) past the fits-above-
375
432
  // the-keyboard threshold — keep the inset in sync before revealing the caret.
376
- [self updateKeyboardInset];
433
+ [self updateBottomInset];
377
434
  [self scrollToFocusedCaretAnimated:YES];
378
435
  });
379
436
  }
@@ -422,7 +479,7 @@ using namespace facebook::react;
422
479
  delay:0
423
480
  options:curve | UIViewAnimationOptionBeginFromCurrentState
424
481
  animations:^{
425
- [self setKeyboardInset:0];
482
+ [self setBottomInset:[self footerInsetAbove:0]];
426
483
  }
427
484
  completion:nil];
428
485
  }
@@ -435,7 +492,8 @@ using namespace facebook::react;
435
492
  [self clearScrollable];
436
493
  _state.reset();
437
494
  _scrollableHandle = 0;
438
- _contentInsetAdjustment = NO;
495
+ _safeAreaInsetAdjustment = NO;
496
+ _footerInsetAdjustment = NO;
439
497
  _lastReportedNaturalHeight = 0;
440
498
  }
441
499
 
@@ -176,12 +176,18 @@ using namespace facebook::react;
176
176
 
177
177
  #pragma mark - TrueSheetKeyboardObserverDelegate
178
178
 
179
+ // Only an absolute footer floats above the keyboard — a relative footer stays
180
+ // in the layout flow behind it, and an absolute one opts out via
181
+ // footerOptions.avoidKeyboard.
182
+ - (BOOL)floatsAboveKeyboard {
183
+ TrueSheetViewController *controller = self.keyboardObserver.viewController;
184
+ return controller.absoluteFooter && controller.footerAvoidsKeyboard;
185
+ }
186
+
179
187
  // Yoga owns the footer's frame (pinned to the container's bottom edge), so the
180
- // keyboard slide is carried by a transform instead of layout. Only an absolute
181
- // footer floats above the keyboard — a relative footer stays in the layout
182
- // flow behind it.
188
+ // keyboard slide is carried by a transform instead of layout.
183
189
  - (void)keyboardWillShow:(CGFloat)height duration:(NSTimeInterval)duration curve:(UIViewAnimationOptions)curve {
184
- if (!self.keyboardObserver.viewController.absoluteFooter) {
190
+ if (![self floatsAboveKeyboard]) {
185
191
  return;
186
192
  }
187
193
 
@@ -232,7 +238,15 @@ using namespace facebook::react;
232
238
 
233
239
  - (void)applyKeyboardOffset {
234
240
  CGFloat height = self.keyboardObserver.currentHeight;
235
- if (height <= 0 || !self.keyboardObserver.viewController.absoluteFooter) {
241
+ if (height <= 0) {
242
+ return;
243
+ }
244
+
245
+ // avoidKeyboard flipped off mid-keyboard — drop the slide already applied
246
+ if (![self floatsAboveKeyboard]) {
247
+ [self setKeyboardVisible:NO];
248
+ _currentKeyboardOffset = 0;
249
+ self.transform = CGAffineTransformIdentity;
236
250
  return;
237
251
  }
238
252
 
@@ -244,6 +244,8 @@ using namespace facebook::react;
244
244
  _controller.blurIntensity = blurOpts.intensity >= 0 ? @(blurOpts.intensity) : nil;
245
245
  _controller.blurInteraction = blurOpts.interaction;
246
246
 
247
+ _controller.glass = newProps.glass;
248
+
247
249
  // Corner radius
248
250
  _controller.cornerRadius = newProps.cornerRadius < 0 ? nil : @(newProps.cornerRadius);
249
251
 
@@ -253,8 +255,7 @@ using namespace facebook::react;
253
255
  // Content width
254
256
  _controller.maxContentWidth = newProps.maxContentWidth != 0.0 ? @(newProps.maxContentWidth) : nil;
255
257
 
256
- // Anchor
257
- _controller.anchor = newProps.anchor;
258
+ _controller.placement = newProps.placement;
258
259
 
259
260
  _controller.grabber = newProps.grabber;
260
261
 
@@ -300,36 +301,35 @@ using namespace facebook::react;
300
301
  _scrollableHandle = newProps.scrollableHandle;
301
302
 
302
303
  const auto &scrollableOpts = newProps.scrollableOptions;
303
- BOOL scrollingExpandsSheet = scrollableOpts.scrollingExpandsSheet;
304
- BOOL contentInsetAdjustment = scrollableOpts.contentInsetAdjustmentBehavior;
305
- auto topEdgeEffect = scrollableOpts.topScrollEdgeEffect;
306
- auto bottomEdgeEffect = scrollableOpts.bottomScrollEdgeEffect;
307
- BOOL hasScrollableOptions = scrollableOpts.keyboardScrollOffset > 0 || scrollableOpts.keyboardOffset != 0 ||
308
- !scrollingExpandsSheet || !contentInsetAdjustment ||
309
- topEdgeEffect != TrueSheetViewTopScrollEdgeEffect::Hidden ||
310
- bottomEdgeEffect != TrueSheetViewBottomScrollEdgeEffect::Hidden;
311
-
312
- if (hasScrollableOptions) {
313
- ScrollableOptions *options = [[ScrollableOptions alloc] init];
314
- options.contentInsetAdjustment = contentInsetAdjustment;
315
- options.keyboardScrollOffset = scrollableOpts.keyboardScrollOffset;
316
- options.keyboardOffset = scrollableOpts.keyboardOffset;
317
- options.scrollingExpandsSheet = scrollingExpandsSheet;
318
- options.topScrollEdgeEffect = topEdgeEffect;
319
- options.bottomScrollEdgeEffect = bottomEdgeEffect;
320
- _scrollableOptions = options;
321
- } else {
322
- _scrollableOptions = nil;
323
- }
324
-
325
- _controller.scrollingExpandsSheet = scrollingExpandsSheet;
304
+ ScrollableOptions *options = [[ScrollableOptions alloc] init];
305
+ options.contentInsetAdjustment = scrollableOpts.contentInsetAdjustment;
306
+ options.keyboardScrollOffset = scrollableOpts.keyboardScrollOffset;
307
+ options.keyboardOffset = scrollableOpts.keyboardOffset;
308
+ options.scrollingExpandsSheet = scrollableOpts.scrollingExpandsSheet;
309
+ options.topScrollEdgeEffect = scrollableOpts.topScrollEdgeEffect;
310
+ options.bottomScrollEdgeEffect = scrollableOpts.bottomScrollEdgeEffect;
311
+
312
+ // nil while every option is at its default
313
+ BOOL hasScrollableOptions = options.keyboardScrollOffset > 0 || options.keyboardOffset != 0 ||
314
+ !options.scrollingExpandsSheet ||
315
+ options.contentInsetAdjustment != TrueSheetViewContentInsetAdjustment::Automatic ||
316
+ options.topScrollEdgeEffect != TrueSheetViewTopScrollEdgeEffect::Hidden ||
317
+ options.bottomScrollEdgeEffect != TrueSheetViewBottomScrollEdgeEffect::Hidden;
318
+ _scrollableOptions = hasScrollableOptions ? options : nil;
319
+
320
+ _controller.scrollingExpandsSheet = options.scrollingExpandsSheet;
326
321
 
327
322
  _controller.absoluteHeader = newProps.headerOptions.position == TrueSheetViewPosition::Absolute;
328
323
  _controller.absoluteFooter = newProps.footerOptions.footerPosition == TrueSheetViewFooterPosition::Absolute;
324
+ _controller.footerInsetAdjustment =
325
+ options.footerInsetAdjustment && _controller.absoluteFooter && _scrollableHandle > 0;
329
326
 
330
327
  CGFloat footerKeyboardOffset = newProps.footerOptions.keyboardOffset;
331
- if (_controller.footerKeyboardOffset != footerKeyboardOffset) {
328
+ BOOL footerAvoidsKeyboard = newProps.footerOptions.avoidKeyboard;
329
+ if (_controller.footerKeyboardOffset != footerKeyboardOffset ||
330
+ _controller.footerAvoidsKeyboard != footerAvoidsKeyboard) {
332
331
  _controller.footerKeyboardOffset = footerKeyboardOffset;
332
+ _controller.footerAvoidsKeyboard = footerAvoidsKeyboard;
333
333
  [_containerView updateFooterKeyboardOffset];
334
334
  }
335
335
 
@@ -900,8 +900,9 @@ using namespace facebook::react;
900
900
 
901
901
  _containerView.scrollableOptions = _scrollableOptions;
902
902
  _containerView.scrollableHandle = _scrollableHandle;
903
- _containerView.contentInsetAdjustment = (_scrollableOptions ? _scrollableOptions.contentInsetAdjustment : YES) &&
904
- _insetAdjustment == TrueSheetViewInsetAdjustment::Automatic;
903
+ _containerView.safeAreaInsetAdjustment = (_scrollableOptions ? _scrollableOptions.safeAreaInsetAdjustment : YES) &&
904
+ _insetAdjustment == TrueSheetViewInsetAdjustment::Automatic;
905
+ _containerView.footerInsetAdjustment = _controller.footerInsetAdjustment;
905
906
  [self refreshFooterBottomInset];
906
907
  [_containerView setupScrollable];
907
908
  }
@@ -62,6 +62,13 @@ NS_ASSUME_NONNULL_BEGIN
62
62
  @property (nonatomic, assign) BOOL absoluteHeader;
63
63
  @property (nonatomic, strong, nullable) NSNumber *footerHeight;
64
64
  @property (nonatomic, assign) BOOL absoluteFooter;
65
+
66
+ /**
67
+ * YES while the plugged scrollable is padded by the absolute footer's height
68
+ * (see ScrollableOptions.contentInsetAdjustment) — the footer then counts
69
+ * toward the auto detent like a relative one, so the content ends above it.
70
+ */
71
+ @property (nonatomic, assign) BOOL footerInsetAdjustment;
65
72
  @property (nonatomic, strong, nullable) NSNumber *peekContentHeight;
66
73
  @property (nonatomic, strong, nullable) UIColor *backgroundColor;
67
74
  @property (nonatomic, strong, nullable) NSNumber *cornerRadius;
@@ -74,11 +81,18 @@ NS_ASSUME_NONNULL_BEGIN
74
81
  @property (nonatomic, assign) facebook::react::TrueSheetViewBackgroundBlur backgroundBlur;
75
82
  @property (nonatomic, strong, nullable) NSNumber *blurIntensity;
76
83
  @property (nonatomic, assign) BOOL blurInteraction;
84
+ @property (nonatomic, assign) BOOL glass;
77
85
  @property (nonatomic, assign) facebook::react::TrueSheetViewPresentation presentation;
78
- @property (nonatomic, assign) facebook::react::TrueSheetViewAnchor anchor;
86
+ @property (nonatomic, assign) facebook::react::TrueSheetViewPlacement placement;
79
87
  @property (nonatomic, assign) facebook::react::TrueSheetViewInsetAdjustment insetAdjustment;
80
88
  @property (nonatomic, assign) BOOL scrollingExpandsSheet;
81
89
  @property (nonatomic, assign) CGFloat footerKeyboardOffset;
90
+
91
+ /**
92
+ * NO keeps an absolute footer pinned to the sheet's bottom edge behind the
93
+ * keyboard instead of rising above it.
94
+ */
95
+ @property (nonatomic, assign) BOOL footerAvoidsKeyboard;
82
96
  @property (nonatomic, assign) BOOL dismissible;
83
97
  @property (nonatomic, assign) BOOL isPresented;
84
98
  @property (nonatomic, assign) NSInteger activeDetentIndex;
@@ -89,6 +103,12 @@ NS_ASSUME_NONNULL_BEGIN
89
103
  * ~keyboardHeight into the learned offset.
90
104
  */
91
105
  @property (nonatomic, assign) BOOL keyboardSheetGrown;
106
+
107
+ /**
108
+ * Target keyboard height, set as soon as the keyboard frame change is
109
+ * announced — ahead of the grow/shrink animation, unlike keyboardSheetGrown.
110
+ */
111
+ @property (nonatomic, assign) CGFloat keyboardHeight;
92
112
  @property (nonatomic, readonly) BOOL isTopmostPresentedController;
93
113
  @property (nonatomic, readonly) BOOL isStackedBehindChild;
94
114
  @property (nonatomic, weak, nullable) UIView *accessibilityContentView;