@lodev09/react-native-true-sheet 4.0.0-beta.2 → 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 (48) 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 +73 -13
  6. package/android/src/main/java/com/lodev09/truesheet/TrueSheetViewController.kt +101 -18
  7. package/android/src/main/java/com/lodev09/truesheet/TrueSheetViewManager.kt +25 -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/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetFooterViewShadowNode.cpp +7 -0
  16. package/ios/TrueSheetContainerView.h +18 -5
  17. package/ios/TrueSheetContainerView.mm +10 -6
  18. package/ios/TrueSheetContentView.h +31 -10
  19. package/ios/TrueSheetContentView.mm +137 -162
  20. package/ios/TrueSheetFooterView.h +6 -0
  21. package/ios/TrueSheetFooterView.mm +48 -2
  22. package/ios/TrueSheetView.mm +95 -21
  23. package/ios/TrueSheetViewController.h +9 -0
  24. package/ios/TrueSheetViewController.mm +108 -25
  25. package/ios/utils/UIView+ScrollEdgeInteraction.h +1 -0
  26. package/ios/utils/UIView+ScrollEdgeInteraction.mm +24 -2
  27. package/lib/module/TrueSheet.js +77 -11
  28. package/lib/module/TrueSheet.js.map +1 -1
  29. package/lib/module/TrueSheet.web.js +26 -24
  30. package/lib/module/TrueSheet.web.js.map +1 -1
  31. package/lib/module/fabric/TrueSheetContentViewNativeComponent.ts +1 -1
  32. package/lib/module/fabric/TrueSheetViewNativeComponent.ts +4 -0
  33. package/lib/typescript/src/TrueSheet.d.ts +4 -0
  34. package/lib/typescript/src/TrueSheet.d.ts.map +1 -1
  35. package/lib/typescript/src/TrueSheet.types.d.ts +50 -1
  36. package/lib/typescript/src/TrueSheet.types.d.ts.map +1 -1
  37. package/lib/typescript/src/TrueSheet.web.d.ts.map +1 -1
  38. package/lib/typescript/src/fabric/TrueSheetViewNativeComponent.d.ts +3 -0
  39. package/lib/typescript/src/fabric/TrueSheetViewNativeComponent.d.ts.map +1 -1
  40. package/lib/typescript/src/navigation/types.d.ts +1 -1
  41. package/lib/typescript/src/navigation/types.d.ts.map +1 -1
  42. package/package.json +1 -1
  43. package/src/TrueSheet.tsx +78 -9
  44. package/src/TrueSheet.types.ts +53 -1
  45. package/src/TrueSheet.web.tsx +37 -23
  46. package/src/fabric/TrueSheetContentViewNativeComponent.ts +1 -1
  47. package/src/fabric/TrueSheetViewNativeComponent.ts +4 -0
  48. 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
@@ -27,6 +27,12 @@ NS_ASSUME_NONNULL_BEGIN
27
27
  @property (nonatomic, weak, nullable) TrueSheetKeyboardObserver *keyboardObserver;
28
28
  @property (nonatomic, weak, nullable) id<TrueSheetFooterViewDelegate> delegate;
29
29
 
30
+ /**
31
+ * Bottom inset currently baked into the footer's layout height — pairs with
32
+ * the measured frame so detent math can subtract exactly what's baked in.
33
+ */
34
+ @property (nonatomic, readonly) CGFloat appliedBottomInset;
35
+
30
36
  /**
31
37
  * Re-applies the footer's keyboard slide using the current keyboard height.
32
38
  * No-op when the keyboard is hidden. Used to reflect live `keyboardOffset` changes.
@@ -25,6 +25,8 @@ using namespace facebook::react;
25
25
  CGFloat _currentKeyboardOffset;
26
26
  CGFloat _bottomInset;
27
27
  BOOL _keyboardVisible;
28
+ BOOL _didPushBottomInset;
29
+ CGFloat _appliedBottomInset;
28
30
  }
29
31
 
30
32
  + (ComponentDescriptorProvider)componentDescriptorProvider {
@@ -43,6 +45,8 @@ using namespace facebook::react;
43
45
  _currentKeyboardOffset = 0;
44
46
  _bottomInset = 0;
45
47
  _keyboardVisible = NO;
48
+ _didPushBottomInset = NO;
49
+ _appliedBottomInset = 0;
46
50
  }
47
51
  return self;
48
52
  }
@@ -55,7 +59,11 @@ using namespace facebook::react;
55
59
  // bottom safe-area inset — the footer owns the sheet's bottom edge, so it
56
60
  // absorbs the inset and its background fills it.
57
61
  - (void)setBottomInset:(CGFloat)bottomInset {
58
- if (_bottomInset == bottomInset) {
62
+ // The first push must go through even when the value matches the ivar
63
+ // default (0) — it marks the state initialized so the shadow node stops
64
+ // seeding from the app-wide precalculated inset, which may belong to
65
+ // another sheet (e.g. a floating sheet whose real inset is 0).
66
+ if (_didPushBottomInset && _bottomInset == bottomInset) {
59
67
  return;
60
68
  }
61
69
  _bottomInset = bottomInset;
@@ -85,6 +93,14 @@ using namespace facebook::react;
85
93
  // them an inset stale (same reason Android bridges through
86
94
  // TrueSheetStateUpdater).
87
95
  _state->updateState(std::move(newState), EventQueue::UpdateMode::unstable_Immediate);
96
+ _didPushBottomInset = YES;
97
+ }
98
+
99
+ // The inset baked into the current layout by the shadow node (pushed or
100
+ // seeded — see TrueSheetFooterViewShadowNode). Refreshed in
101
+ // updateLayoutMetrics so it always pairs with the measured height.
102
+ - (CGFloat)appliedBottomInset {
103
+ return _appliedBottomInset;
88
104
  }
89
105
 
90
106
  // Height the footer occupies above the keyboard — its layout height minus the
@@ -123,8 +139,19 @@ using namespace facebook::react;
123
139
  oldLayoutMetrics:(const facebook::react::LayoutMetrics &)oldLayoutMetrics {
124
140
  [super updateLayoutMetrics:layoutMetrics oldLayoutMetrics:oldLayoutMetrics];
125
141
 
142
+ // State lands before layout metrics within the same mount transaction, so
143
+ // this reflects the inset the shadow node baked into this frame — seeded
144
+ // values are recorded back into the state at layout time (see
145
+ // TrueSheetFooterViewShadowNode).
146
+ CGFloat previousAppliedBottomInset = _appliedBottomInset;
147
+ if (_state) {
148
+ _appliedBottomInset = _state->getData().bottomInset;
149
+ }
150
+
126
151
  CGFloat height = layoutMetrics.frame.size.height;
127
- if (height != _lastHeight) {
152
+ // An inset change alone (fixed-height footer: padding moves, frame doesn't)
153
+ // still changes what's baked in — report so the controller re-pairs.
154
+ if (height != _lastHeight || _appliedBottomInset != previousAppliedBottomInset) {
128
155
  _lastHeight = height;
129
156
  [self.delegate footerViewDidChangeSize:CGSizeMake(layoutMetrics.frame.size.width, height)];
130
157
  }
@@ -143,6 +170,8 @@ using namespace facebook::react;
143
170
  _currentKeyboardOffset = 0;
144
171
  _bottomInset = 0;
145
172
  _keyboardVisible = NO;
173
+ _didPushBottomInset = NO;
174
+ _appliedBottomInset = 0;
146
175
  }
147
176
 
148
177
  #pragma mark - TrueSheetKeyboardObserverDelegate
@@ -167,6 +196,7 @@ using namespace facebook::react;
167
196
  options:curve | UIViewAnimationOptionBeginFromCurrentState
168
197
  animations:^{
169
198
  self.transform = CGAffineTransformMakeTranslation(0, -slide);
199
+ [self syncEdgeEffect];
170
200
  }
171
201
  completion:nil];
172
202
  }
@@ -180,10 +210,26 @@ using namespace facebook::react;
180
210
  options:curve | UIViewAnimationOptionBeginFromCurrentState
181
211
  animations:^{
182
212
  self.transform = CGAffineTransformIdentity;
213
+ [self syncEdgeEffect];
183
214
  }
184
215
  completion:nil];
185
216
  }
186
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
+
187
233
  - (void)applyKeyboardOffset {
188
234
  CGFloat height = self.keyboardObserver.currentHeight;
189
235
  if (height <= 0 || !self.keyboardObserver.viewController.absoluteFooter) {