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

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 (47) hide show
  1. package/README.md +21 -2
  2. package/android/src/main/java/com/lodev09/truesheet/TrueSheetContainerView.kt +24 -7
  3. package/android/src/main/java/com/lodev09/truesheet/TrueSheetContentView.kt +195 -174
  4. package/android/src/main/java/com/lodev09/truesheet/TrueSheetContentViewManager.kt +5 -1
  5. package/android/src/main/java/com/lodev09/truesheet/TrueSheetFooterView.kt +6 -17
  6. package/android/src/main/java/com/lodev09/truesheet/TrueSheetView.kt +50 -8
  7. package/android/src/main/java/com/lodev09/truesheet/TrueSheetViewController.kt +101 -18
  8. package/android/src/main/java/com/lodev09/truesheet/TrueSheetViewManager.kt +24 -1
  9. package/android/src/main/java/com/lodev09/truesheet/core/TrueSheetCoordinatorLayout.kt +3 -3
  10. package/android/src/main/java/com/lodev09/truesheet/core/TrueSheetDetentCalculator.kt +14 -24
  11. package/android/src/main/java/com/lodev09/truesheet/utils/ViewUtils.kt +0 -7
  12. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetContentViewComponentDescriptor.h +0 -8
  13. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetContentViewShadowNode.cpp +54 -29
  14. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetContentViewShadowNode.h +6 -1
  15. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetContentViewState.cpp +1 -1
  16. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetContentViewState.h +6 -4
  17. package/ios/TrueSheetContainerView.h +9 -7
  18. package/ios/TrueSheetContainerView.mm +6 -7
  19. package/ios/TrueSheetContentView.h +28 -14
  20. package/ios/TrueSheetContentView.mm +122 -179
  21. package/ios/TrueSheetFooterView.mm +17 -0
  22. package/ios/TrueSheetView.mm +13 -10
  23. package/ios/TrueSheetViewController.mm +35 -27
  24. package/ios/utils/UIView+ScrollEdgeInteraction.h +1 -0
  25. package/ios/utils/UIView+ScrollEdgeInteraction.mm +24 -2
  26. package/lib/module/TrueSheet.js +47 -14
  27. package/lib/module/TrueSheet.js.map +1 -1
  28. package/lib/module/TrueSheet.web.js +26 -24
  29. package/lib/module/TrueSheet.web.js.map +1 -1
  30. package/lib/module/fabric/TrueSheetContentViewNativeComponent.ts +1 -1
  31. package/lib/module/fabric/TrueSheetViewNativeComponent.ts +4 -0
  32. package/lib/typescript/src/TrueSheet.d.ts +2 -0
  33. package/lib/typescript/src/TrueSheet.d.ts.map +1 -1
  34. package/lib/typescript/src/TrueSheet.types.d.ts +64 -1
  35. package/lib/typescript/src/TrueSheet.types.d.ts.map +1 -1
  36. package/lib/typescript/src/TrueSheet.web.d.ts.map +1 -1
  37. package/lib/typescript/src/fabric/TrueSheetViewNativeComponent.d.ts +3 -0
  38. package/lib/typescript/src/fabric/TrueSheetViewNativeComponent.d.ts.map +1 -1
  39. package/lib/typescript/src/navigation/types.d.ts +1 -1
  40. package/lib/typescript/src/navigation/types.d.ts.map +1 -1
  41. package/package.json +1 -1
  42. package/src/TrueSheet.tsx +48 -13
  43. package/src/TrueSheet.types.ts +68 -1
  44. package/src/TrueSheet.web.tsx +39 -24
  45. package/src/fabric/TrueSheetContentViewNativeComponent.ts +1 -1
  46. package/src/fabric/TrueSheetViewNativeComponent.ts +4 -0
  47. package/src/navigation/types.ts +1 -0
@@ -17,25 +17,20 @@
17
17
  #import <react/renderer/components/TrueSheetSpec/TrueSheetContentViewShadowNode.h>
18
18
  #import "TrueSheetContainerView.h"
19
19
  #import "TrueSheetFooterView.h"
20
- #import "TrueSheetView.h"
21
20
  #import "TrueSheetViewController.h"
22
21
  #import "utils/PlatformUtil.h"
23
22
  #import "utils/UIView+FirstResponder.h"
24
23
 
25
24
  using namespace facebook::react;
26
25
 
27
- static void *TrueSheetContentSizeContext = &TrueSheetContentSizeContext;
28
-
29
26
  @implementation TrueSheetContentView {
30
27
  TrueSheetContentViewShadowNode::ConcreteState::Shared _state;
31
- RCTScrollViewComponentView *_pinnedScrollView;
32
- UIScrollView *_observedScrollView;
33
- CGSize _lastSize;
34
- CGFloat _bottomInset;
35
- CGFloat _originalIndicatorBottomInset;
28
+ RCTScrollViewComponentView *_detectedScrollView;
29
+ CGFloat _lastReportedNaturalHeight;
30
+ CGFloat _appliedKeyboardOffset;
36
31
  BOOL _observingTextChanges;
37
- BOOL _scrollableBounded;
38
- BOOL _isReportPending;
32
+ UIScrollViewContentInsetAdjustmentBehavior _originalInsetAdjustmentBehavior;
33
+ BOOL _appliedContentInsetAdjustment;
39
34
  }
40
35
 
41
36
  + (ComponentDescriptorProvider)componentDescriptorProvider {
@@ -52,37 +47,51 @@ static void *TrueSheetContentSizeContext = &TrueSheetContentSizeContext;
52
47
 
53
48
  - (void)dealloc {
54
49
  [self stopObservingTextChanges];
55
- [self unobserveScrollViewContentSize];
56
50
  }
57
51
 
58
52
  - (void)updateState:(const State::Shared &)state oldState:(const State::Shared &)oldState {
59
53
  _state = std::static_pointer_cast<TrueSheetContentViewShadowNode::ConcreteState const>(state);
54
+
55
+ CGFloat naturalHeight = _state->getData().naturalHeight;
56
+ if (naturalHeight != _lastReportedNaturalHeight) {
57
+ _lastReportedNaturalHeight = naturalHeight;
58
+ [self.delegate contentViewDidChangeSize:CGSizeMake(self.frame.size.width, naturalHeight)];
59
+ }
60
60
  }
61
61
 
62
- // Tells the shadow node to fill the container (flexGrow/flexShrink) so the
63
- // pinned ScrollView's viewport is bounded to the visible space. Only applied
64
- // for auto detents — otherwise content lays out naturally like a regular view.
65
- - (void)setScrollableBounded:(BOOL)bounded {
66
- if (_scrollableBounded == bounded) {
62
+ - (void)setScrollableHandle:(NSInteger)scrollableHandle {
63
+ if (_scrollableHandle == scrollableHandle) {
67
64
  return;
68
65
  }
69
- _scrollableBounded = bounded;
66
+ _scrollableHandle = scrollableHandle;
70
67
 
71
- if (_state) {
72
- TrueSheetContentViewState newState;
73
- newState.scrollableBounded = bounded;
74
- _state->updateState(std::move(newState));
68
+ // Release the previously resolved ScrollView — setupScrollable re-resolves
69
+ [self clearScrollable];
70
+ }
71
+
72
+ - (void)setContentInsetAdjustment:(BOOL)contentInsetAdjustment {
73
+ if (_contentInsetAdjustment == contentInsetAdjustment) {
74
+ return;
75
75
  }
76
+ _contentInsetAdjustment = contentInsetAdjustment;
77
+ [self applyContentInsetAdjustment];
76
78
  }
77
79
 
78
- - (void)setHasAutoDetent:(BOOL)hasAutoDetent {
79
- if (_hasAutoDetent == hasAutoDetent) {
80
+ // Hands the bottom safe-area inset to UIKit — `automatic` only insets while
81
+ // the content can scroll, which the Android counterpart mirrors manually.
82
+ - (void)applyContentInsetAdjustment {
83
+ if (!_detectedScrollView) {
80
84
  return;
81
85
  }
82
- _hasAutoDetent = hasAutoDetent;
83
86
 
84
- if (_pinnedScrollView) {
85
- [self setScrollableBounded:hasAutoDetent];
87
+ UIScrollView *scrollView = _detectedScrollView.scrollView;
88
+ if (_contentInsetAdjustment && !_appliedContentInsetAdjustment) {
89
+ _originalInsetAdjustmentBehavior = scrollView.contentInsetAdjustmentBehavior;
90
+ scrollView.contentInsetAdjustmentBehavior = UIScrollViewContentInsetAdjustmentAutomatic;
91
+ _appliedContentInsetAdjustment = YES;
92
+ } else if (!_contentInsetAdjustment && _appliedContentInsetAdjustment) {
93
+ scrollView.contentInsetAdjustmentBehavior = _originalInsetAdjustmentBehavior;
94
+ _appliedContentInsetAdjustment = NO;
86
95
  }
87
96
  }
88
97
 
@@ -111,58 +120,25 @@ static void *TrueSheetContentSizeContext = &TrueSheetContentSizeContext;
111
120
  #pragma mark - Layout
112
121
 
113
122
  - (CGFloat)naturalHeight {
114
- CGFloat height = self.frame.size.height;
115
- if (_pinnedScrollView) {
116
- height += _pinnedScrollView.scrollView.contentSize.height - _pinnedScrollView.frame.size.height;
117
- }
118
- return MAX(0, height);
119
- }
120
-
121
- - (void)reportSizeIfChanged {
122
- // A deep ScrollView unmount doesn't pass through this view's mount hooks —
123
- // detect the stale pin here so it (and its contentSize observation) is
124
- // released instead of lingering until the next explicit setup or recycle.
125
- if (_pinnedScrollView && ![_pinnedScrollView isDescendantOfView:self]) {
126
- [self.delegate contentViewScrollViewDidChange];
127
- }
128
-
129
- // naturalHeight mixes frames from different views; mid-transaction they're
130
- // momentarily inconsistent (parent updates before the ScrollView), which
131
- // feeds back into the auto detent and oscillates. Coalesce to the next
132
- // main-queue tick so frames are settled before measuring.
133
- if (_pinnedScrollView) {
134
- if (_isReportPending) {
135
- return;
123
+ if (_state) {
124
+ CGFloat height = _state->getData().naturalHeight;
125
+ if (height > 0) {
126
+ return height;
136
127
  }
137
- _isReportPending = YES;
138
-
139
- __weak __typeof(self) weakSelf = self;
140
- dispatch_async(dispatch_get_main_queue(), ^{
141
- __typeof(self) strongSelf = weakSelf;
142
- if (!strongSelf) {
143
- return;
144
- }
145
- strongSelf->_isReportPending = NO;
146
- [strongSelf reportSizeNow];
147
- });
148
- return;
149
- }
150
-
151
- [self reportSizeNow];
152
- }
153
-
154
- - (void)reportSizeNow {
155
- CGSize newSize = CGSizeMake(self.frame.size.width, self.naturalHeight);
156
- if (!CGSizeEqualToSize(newSize, _lastSize)) {
157
- _lastSize = newSize;
158
- [self.delegate contentViewDidChangeSize:newSize];
159
128
  }
129
+ return self.frame.size.height;
160
130
  }
161
131
 
162
132
  - (void)updateLayoutMetrics:(const LayoutMetrics &)layoutMetrics
163
133
  oldLayoutMetrics:(const LayoutMetrics &)oldLayoutMetrics {
164
134
  [super updateLayoutMetrics:layoutMetrics oldLayoutMetrics:oldLayoutMetrics];
165
- [self reportSizeIfChanged];
135
+
136
+ // A deep ScrollView unmount doesn't pass through this view's mount hooks —
137
+ // detect the stale reference here so it's released instead of lingering until the
138
+ // next explicit setup or recycle.
139
+ if (_detectedScrollView && ![_detectedScrollView isDescendantOfView:self]) {
140
+ [self.delegate contentViewScrollViewDidChange];
141
+ }
166
142
  }
167
143
 
168
144
  #pragma mark - Child Mounting
@@ -178,46 +154,41 @@ static void *TrueSheetContentSizeContext = &TrueSheetContentSizeContext;
178
154
  }
179
155
 
180
156
  - (void)checkScrollViewChanged {
181
- if (!_pinnedScrollView || ![_pinnedScrollView isDescendantOfView:self]) {
157
+ if (!_detectedScrollView || ![_detectedScrollView isDescendantOfView:self]) {
182
158
  [self.delegate contentViewScrollViewDidChange];
183
159
  }
184
160
  }
185
161
 
186
162
  #pragma mark - Scrollable
187
163
 
188
- - (void)setScrollViewContentInset:(CGFloat)contentBottom indicatorInset:(CGFloat)indicatorBottom {
189
- if (!_pinnedScrollView)
164
+ // The scroll indicator follows automatically — UIKit derives its insets from
165
+ // the content inset while `automaticallyAdjustsScrollIndicatorInsets` is set.
166
+ - (void)setKeyboardInset:(CGFloat)inset {
167
+ if (!_detectedScrollView)
190
168
  return;
191
169
 
192
- UIEdgeInsets contentInset = _pinnedScrollView.scrollView.contentInset;
193
- contentInset.bottom = contentBottom;
194
- _pinnedScrollView.scrollView.contentInset = contentInset;
195
-
196
- UIEdgeInsets indicatorInsets = _pinnedScrollView.scrollView.verticalScrollIndicatorInsets;
197
- indicatorInsets.bottom = indicatorBottom;
198
- _pinnedScrollView.scrollView.verticalScrollIndicatorInsets = indicatorInsets;
170
+ UIEdgeInsets contentInset = _detectedScrollView.scrollView.contentInset;
171
+ contentInset.bottom = inset;
172
+ _detectedScrollView.scrollView.contentInset = contentInset;
199
173
  }
200
174
 
201
175
  - (void)clearScrollable {
202
- if (_pinnedScrollView) {
203
- [self setScrollViewContentInset:0 indicatorInset:_originalIndicatorBottomInset];
204
- }
205
- [self unobserveScrollViewContentSize];
206
- [self setScrollableBounded:NO];
207
- _pinnedScrollView = nil;
208
- _bottomInset = 0;
209
- _originalIndicatorBottomInset = 0;
210
- [self reportSizeIfChanged];
176
+ [self setKeyboardInset:0];
177
+ _appliedKeyboardOffset = 0;
178
+ if (_appliedContentInsetAdjustment) {
179
+ _detectedScrollView.scrollView.contentInsetAdjustmentBehavior = _originalInsetAdjustmentBehavior;
180
+ _appliedContentInsetAdjustment = NO;
181
+ }
182
+ _detectedScrollView = nil;
211
183
  }
212
184
 
213
- - (void)setupScrollableWithBottomInset:(CGFloat)bottomInset {
214
- // Check if pinned scroll view is still valid (still in view hierarchy)
215
- if (_pinnedScrollView && ![_pinnedScrollView isDescendantOfView:self]) {
185
+ - (void)setupScrollable {
186
+ // Check if the detected scroll view is still valid (still in view hierarchy)
187
+ if (_detectedScrollView && ![_detectedScrollView isDescendantOfView:self]) {
216
188
  [self clearScrollable];
217
189
  }
218
190
 
219
- // Already set up with same inset and valid scroll view
220
- if (_pinnedScrollView && _bottomInset == bottomInset) {
191
+ if (_detectedScrollView) {
221
192
  return;
222
193
  }
223
194
 
@@ -226,104 +197,74 @@ static void *TrueSheetContentSizeContext = &TrueSheetContentSizeContext;
226
197
  return;
227
198
  }
228
199
 
229
- // Only capture originals on first pin
230
- if (!_pinnedScrollView) {
231
- _originalIndicatorBottomInset = scrollView.scrollView.verticalScrollIndicatorInsets.bottom;
232
- _pinnedScrollView = scrollView;
200
+ _detectedScrollView = scrollView;
233
201
 
234
- [self observeScrollViewContentSize];
235
- [self setScrollableBounded:_hasAutoDetent];
236
- [self reportSizeIfChanged];
237
- }
238
-
239
- _bottomInset = bottomInset;
240
-
241
- [self setScrollViewContentInset:_bottomInset indicatorInset:_originalIndicatorBottomInset];
202
+ [self applyContentInsetAdjustment];
242
203
 
243
204
  // If keyboard is currently showing, re-apply the keyboard inset to the new ScrollView
244
205
  CGFloat keyboardHeight = _keyboardObserver ? _keyboardObserver.currentHeight : 0;
245
206
  if (keyboardHeight > 0) {
246
- CGFloat inset = [self keyboardInsetWithHeight:keyboardHeight];
247
- [self setScrollViewContentInset:inset indicatorInset:_originalIndicatorBottomInset + inset];
207
+ [self setKeyboardInset:[self keyboardInsetWithHeight:keyboardHeight]];
248
208
  }
249
209
  }
250
210
 
251
- // An absolute footer rises above the keyboard and covers the content's bottom
252
- // edge — include it so the caret clears the footer, not just the keyboard.
253
211
  // A relative footer stays behind the keyboard below the content, so its
254
- // height shields that much of the keyboard's overlap.
212
+ // height shields that much of the keyboard's overlap. An absolute footer
213
+ // floats within the viewport — its clearance is the content padding's job
214
+ // (the caret reveal accounts for it, see footerOcclusion).
255
215
  - (CGFloat)keyboardInsetWithHeight:(CGFloat)height {
256
- if (!self.footerView) {
257
- return height;
216
+ CGFloat inset = height;
217
+ if (self.footerView && !_keyboardObserver.viewController.absoluteFooter) {
218
+ inset = MAX(0, height - self.footerView.frame.size.height);
258
219
  }
259
- if (_keyboardObserver.viewController.absoluteFooter) {
260
- return height + [self.footerView keyboardOcclusionHeight];
261
- }
262
- return MAX(0, height - self.footerView.frame.size.height);
263
- }
264
220
 
265
- // Content growth is invisible to layout once the viewport is bounded, so track
266
- // the scroll content size directly to keep the auto detent height in sync.
267
- - (void)observeScrollViewContentSize {
268
- UIScrollView *scrollView = _pinnedScrollView.scrollView;
269
- if (_observedScrollView == scrollView) {
270
- return;
221
+ // UIKit stacks the safe area on top of contentInset while the adjustment
222
+ // behavior is automatic take it out so the total lands on the keyboard edge.
223
+ if (_appliedContentInsetAdjustment) {
224
+ inset = MAX(0, inset - _detectedScrollView.scrollView.safeAreaInsets.bottom);
271
225
  }
272
- [self unobserveScrollViewContentSize];
273
- _observedScrollView = scrollView;
274
- [scrollView addObserver:self
275
- forKeyPath:@"contentSize"
276
- options:NSKeyValueObservingOptionNew
277
- context:TrueSheetContentSizeContext];
278
- }
279
226
 
280
- - (void)unobserveScrollViewContentSize {
281
- if (_observedScrollView) {
282
- [_observedScrollView removeObserver:self forKeyPath:@"contentSize" context:TrueSheetContentSizeContext];
283
- _observedScrollView = nil;
227
+ // Track how much of keyboardOffset actually lands in the inset so the caret
228
+ // reveal can compensate — the offset shifts the inset, not the keyboard edge.
229
+ CGFloat adjustedInset = MAX(0, inset + self.keyboardOffset);
230
+ _appliedKeyboardOffset = adjustedInset - inset;
231
+
232
+ // Content that already fits above the keyboard has nothing to reveal — the
233
+ // inset would only open blank scroll range below it (huge gap at the bottom).
234
+ UIScrollView *scrollView = _detectedScrollView.scrollView;
235
+ if (scrollView.contentSize.height <= scrollView.bounds.size.height - adjustedInset) {
236
+ _appliedKeyboardOffset = 0;
237
+ return 0;
284
238
  }
239
+
240
+ return adjustedInset;
285
241
  }
286
242
 
287
- - (void)observeValueForKeyPath:(NSString *)keyPath
288
- ofObject:(id)object
289
- change:(NSDictionary *)change
290
- context:(void *)context {
291
- if (context == TrueSheetContentSizeContext) {
292
- [self reportSizeIfChanged];
293
- return;
243
+ // An absolute footer floats over the viewport's bottom edge — extend the
244
+ // caret target so it clears the footer, not just the keyboard.
245
+ - (CGFloat)footerOcclusion {
246
+ if (self.footerView && _keyboardObserver.viewController.absoluteFooter) {
247
+ return [self.footerView keyboardOcclusionHeight];
294
248
  }
295
- [super observeValueForKeyPath:keyPath ofObject:object change:change context:context];
249
+ return 0;
296
250
  }
297
251
 
252
+ // Resolves the user-provided `scrollableHandle` within the content subtree —
253
+ // Fabric component views carry their React tag as `UIView.tag`.
298
254
  - (RCTScrollViewComponentView *)findScrollView {
299
- if (_pinnedScrollView) {
300
- return _pinnedScrollView;
255
+ if (_detectedScrollView) {
256
+ return _detectedScrollView;
301
257
  }
302
258
 
303
- if (self.subviews.count == 0) {
259
+ if (_scrollableHandle <= 0) {
304
260
  return nil;
305
261
  }
306
262
 
307
- RCTScrollViewComponentView *scrollView = [self findScrollViewInSubviews:self.subviews];
308
-
309
- if (!scrollView) {
310
- for (UIView *subview in self.subviews) {
311
- scrollView = [self findScrollViewInSubviews:subview.subviews];
312
- if (scrollView) {
313
- break;
314
- }
315
- }
263
+ UIView *view = [self viewWithTag:_scrollableHandle];
264
+ if ([view isKindOfClass:RCTScrollViewComponentView.class]) {
265
+ return (RCTScrollViewComponentView *)view;
316
266
  }
317
267
 
318
- return scrollView;
319
- }
320
-
321
- - (RCTScrollViewComponentView *)findScrollViewInSubviews:(NSArray<UIView *> *)subviews {
322
- for (UIView *subview in subviews) {
323
- if ([subview isKindOfClass:RCTScrollViewComponentView.class] && ![subview isKindOfClass:TrueSheetView.class]) {
324
- return (RCTScrollViewComponentView *)subview;
325
- }
326
- }
327
268
  return nil;
328
269
  }
329
270
 
@@ -331,11 +272,11 @@ static void *TrueSheetContentSizeContext = &TrueSheetContentSizeContext;
331
272
 
332
273
  - (void)applyScrollEdgeEffects:(nullable ScrollableOptions *)options {
333
274
  #if RNTS_IPHONE_OS_VERSION_AVAILABLE(26_0)
334
- if (!_pinnedScrollView)
275
+ if (!_detectedScrollView)
335
276
  return;
336
277
 
337
278
  if (@available(iOS 26.0, *)) {
338
- UIScrollView *scrollView = _pinnedScrollView.scrollView;
279
+ UIScrollView *scrollView = _detectedScrollView.scrollView;
339
280
  auto topEffect = options ? options.topScrollEdgeEffect : TrueSheetViewTopScrollEdgeEffect::Hidden;
340
281
  auto bottomEffect = options ? options.bottomScrollEdgeEffect : TrueSheetViewBottomScrollEdgeEffect::Hidden;
341
282
 
@@ -371,7 +312,7 @@ static void *TrueSheetContentSizeContext = &TrueSheetContentSizeContext;
371
312
  #pragma mark - TrueSheetKeyboardObserverDelegate
372
313
 
373
314
  - (void)keyboardWillShow:(CGFloat)height duration:(NSTimeInterval)duration curve:(UIViewAnimationOptions)curve {
374
- if (!_pinnedScrollView) {
315
+ if (!_detectedScrollView) {
375
316
  return;
376
317
  }
377
318
 
@@ -385,7 +326,7 @@ static void *TrueSheetContentSizeContext = &TrueSheetContentSizeContext;
385
326
  delay:0
386
327
  options:curve | UIViewAnimationOptionBeginFromCurrentState
387
328
  animations:^{
388
- [self setScrollViewContentInset:inset indicatorInset:self->_originalIndicatorBottomInset + inset];
329
+ [self setKeyboardInset:inset];
389
330
  }
390
331
  completion:nil];
391
332
 
@@ -398,7 +339,7 @@ static void *TrueSheetContentSizeContext = &TrueSheetContentSizeContext;
398
339
  }
399
340
 
400
341
  - (void)focusedInputTextDidChange:(NSNotification *)notification {
401
- if (!_pinnedScrollView || !_keyboardObserver || _keyboardObserver.currentHeight <= 0) {
342
+ if (!_detectedScrollView || !_keyboardObserver || _keyboardObserver.currentHeight <= 0) {
402
343
  return;
403
344
  }
404
345
 
@@ -409,12 +350,15 @@ static void *TrueSheetContentSizeContext = &TrueSheetContentSizeContext;
409
350
  }
410
351
 
411
352
  dispatch_async(dispatch_get_main_queue(), ^{
353
+ // Typing can grow the content (e.g. a multiline input) past the fits-above-
354
+ // the-keyboard threshold — keep the inset in sync before revealing the caret.
355
+ [self setKeyboardInset:[self keyboardInsetWithHeight:self->_keyboardObserver.currentHeight]];
412
356
  [self scrollToFocusedCaretAnimated:YES];
413
357
  });
414
358
  }
415
359
 
416
360
  - (void)scrollToFocusedCaretAnimated:(BOOL)animated {
417
- if (!_pinnedScrollView) {
361
+ if (!_detectedScrollView) {
418
362
  return;
419
363
  }
420
364
 
@@ -424,7 +368,7 @@ static void *TrueSheetContentSizeContext = &TrueSheetContentSizeContext;
424
368
  return;
425
369
  }
426
370
 
427
- UIScrollView *scrollView = _pinnedScrollView.scrollView;
371
+ UIScrollView *scrollView = _detectedScrollView.scrollView;
428
372
  CGRect targetRect = [firstResponder convertRect:firstResponder.bounds toView:scrollView];
429
373
 
430
374
  if ([firstResponder conformsToProtocol:@protocol(UITextInput)]) {
@@ -442,14 +386,14 @@ static void *TrueSheetContentSizeContext = &TrueSheetContentSizeContext;
442
386
  }
443
387
  }
444
388
 
445
- targetRect.size.height += self.keyboardScrollOffset;
389
+ targetRect.size.height += self.keyboardScrollOffset + [self footerOcclusion] - _appliedKeyboardOffset;
446
390
  [scrollView scrollRectToVisible:targetRect animated:animated];
447
391
  }
448
392
 
449
393
  - (void)keyboardWillHide:(NSTimeInterval)duration curve:(UIViewAnimationOptions)curve {
450
394
  [self stopObservingTextChanges];
451
395
 
452
- if (!_pinnedScrollView) {
396
+ if (!_detectedScrollView) {
453
397
  return;
454
398
  }
455
399
 
@@ -457,8 +401,7 @@ static void *TrueSheetContentSizeContext = &TrueSheetContentSizeContext;
457
401
  delay:0
458
402
  options:curve | UIViewAnimationOptionBeginFromCurrentState
459
403
  animations:^{
460
- [self setScrollViewContentInset:self->_bottomInset
461
- indicatorInset:self->_originalIndicatorBottomInset];
404
+ [self setKeyboardInset:0];
462
405
  }
463
406
  completion:nil];
464
407
  }
@@ -470,9 +413,9 @@ static void *TrueSheetContentSizeContext = &TrueSheetContentSizeContext;
470
413
  [self stopObservingTextChanges];
471
414
  [self clearScrollable];
472
415
  _state.reset();
473
- _scrollableBounded = NO;
474
- _hasAutoDetent = NO;
475
- _lastSize = CGSizeZero;
416
+ _scrollableHandle = 0;
417
+ _contentInsetAdjustment = NO;
418
+ _lastReportedNaturalHeight = 0;
476
419
  }
477
420
 
478
421
  @end
@@ -196,6 +196,7 @@ using namespace facebook::react;
196
196
  options:curve | UIViewAnimationOptionBeginFromCurrentState
197
197
  animations:^{
198
198
  self.transform = CGAffineTransformMakeTranslation(0, -slide);
199
+ [self syncEdgeEffect];
199
200
  }
200
201
  completion:nil];
201
202
  }
@@ -209,10 +210,26 @@ using namespace facebook::react;
209
210
  options:curve | UIViewAnimationOptionBeginFromCurrentState
210
211
  animations:^{
211
212
  self.transform = CGAffineTransformIdentity;
213
+ [self syncEdgeEffect];
212
214
  }
213
215
  completion:nil];
214
216
  }
215
217
 
218
+ // UIKit derives the edge effect region from model geometry at commit time,
219
+ // outside our animation context — the blur snaps to the footer's final
220
+ // position while the footer is still animating. Re-evaluating the scroll
221
+ // view's layout inside the animation block makes the region change inherit
222
+ // the keyboard curve.
223
+ - (void)syncEdgeEffect {
224
+ if (@available(iOS 26.0, *)) {
225
+ UIScrollView *scrollView = [self edgeInteractionScrollView];
226
+ if (scrollView) {
227
+ [scrollView setNeedsLayout];
228
+ [scrollView layoutIfNeeded];
229
+ }
230
+ }
231
+ }
232
+
216
233
  - (void)applyKeyboardOffset {
217
234
  CGFloat height = self.keyboardObserver.currentHeight;
218
235
  if (height <= 0 || !self.keyboardObserver.viewController.absoluteFooter) {
@@ -53,7 +53,7 @@ using namespace facebook::react;
53
53
  NSInteger _initialDetentIndex;
54
54
  TrueSheetViewInsetAdjustment _insetAdjustment;
55
55
  ScrollableOptions *_scrollableOptions;
56
- BOOL _hasAutoDetent;
56
+ NSInteger _scrollableHandle;
57
57
  BOOL _initialDetentAnimated;
58
58
  BOOL _isSheetUpdatePending;
59
59
  BOOL _pendingLayoutUpdate;
@@ -192,12 +192,8 @@ using namespace facebook::react;
192
192
 
193
193
  // Detents (-1 represents "auto")
194
194
  NSMutableArray *detents = [NSMutableArray new];
195
- _hasAutoDetent = NO;
196
195
  for (const auto &detent : newProps.detents) {
197
196
  [detents addObject:@(detent)];
198
- if (detent == -1) {
199
- _hasAutoDetent = YES;
200
- }
201
197
  }
202
198
 
203
199
  if (oldProps) {
@@ -277,17 +273,23 @@ using namespace facebook::react;
277
273
  _initialDetentIndex = newProps.initialDetentIndex;
278
274
  _initialDetentAnimated = newProps.initialDetentAnimated;
279
275
 
276
+ _scrollableHandle = newProps.scrollableHandle;
277
+
280
278
  const auto &scrollableOpts = newProps.scrollableOptions;
281
279
  BOOL scrollingExpandsSheet = scrollableOpts.scrollingExpandsSheet;
280
+ BOOL contentInsetAdjustment = scrollableOpts.contentInsetAdjustmentBehavior;
282
281
  auto topEdgeEffect = scrollableOpts.topScrollEdgeEffect;
283
282
  auto bottomEdgeEffect = scrollableOpts.bottomScrollEdgeEffect;
284
- BOOL hasScrollableOptions = scrollableOpts.keyboardScrollOffset > 0 || !scrollingExpandsSheet ||
283
+ BOOL hasScrollableOptions = scrollableOpts.keyboardScrollOffset > 0 || scrollableOpts.keyboardOffset != 0 ||
284
+ !scrollingExpandsSheet || !contentInsetAdjustment ||
285
285
  topEdgeEffect != TrueSheetViewTopScrollEdgeEffect::Hidden ||
286
286
  bottomEdgeEffect != TrueSheetViewBottomScrollEdgeEffect::Hidden;
287
287
 
288
288
  if (hasScrollableOptions) {
289
289
  ScrollableOptions *options = [[ScrollableOptions alloc] init];
290
+ options.contentInsetAdjustment = contentInsetAdjustment;
290
291
  options.keyboardScrollOffset = scrollableOpts.keyboardScrollOffset;
292
+ options.keyboardOffset = scrollableOpts.keyboardOffset;
291
293
  options.scrollingExpandsSheet = scrollingExpandsSheet;
292
294
  options.topScrollEdgeEffect = topEdgeEffect;
293
295
  options.bottomScrollEdgeEffect = bottomEdgeEffect;
@@ -502,7 +504,7 @@ using namespace facebook::react;
502
504
  [_controller setupSheetSizing];
503
505
  [_controller setupSheetProps];
504
506
 
505
- // Pin before measuring so a first-time pin doesn't shift sizes mid-animation.
507
+ // Detect before measuring so a first-time detection doesn't shift sizes mid-animation.
506
508
  [self setupScrollable];
507
509
 
508
510
  // Measure synchronously so the presentation starts at the right height —
@@ -671,7 +673,7 @@ using namespace facebook::react;
671
673
  [self setupSheetDetentsForSizeChange];
672
674
  }
673
675
 
674
- // When the ScrollView changes (e.g. conditional remount), re-pin the new ScrollView.
676
+ // When the ScrollView changes (e.g. conditional remount), re-detect the new ScrollView.
675
677
  - (void)containerViewScrollViewDidChange {
676
678
  [self setupScrollable];
677
679
  }
@@ -822,9 +824,10 @@ using namespace facebook::react;
822
824
  if (!_containerView)
823
825
  return;
824
826
 
825
- _containerView.insetAdjustment = _insetAdjustment;
826
827
  _containerView.scrollableOptions = _scrollableOptions;
827
- _containerView.hasAutoDetent = _hasAutoDetent;
828
+ _containerView.scrollableHandle = _scrollableHandle;
829
+ _containerView.contentInsetAdjustment = (_scrollableOptions ? _scrollableOptions.contentInsetAdjustment : YES) &&
830
+ _insetAdjustment == TrueSheetViewInsetAdjustment::Automatic;
828
831
  [self refreshFooterBottomInset];
829
832
  [_containerView setupScrollable];
830
833
  }