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

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