@lodev09/react-native-true-sheet 3.11.10 → 4.0.0-beta.1

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 (73) hide show
  1. package/README.md +6 -5
  2. package/android/src/main/java/com/lodev09/truesheet/TrueSheetContainerView.kt +23 -2
  3. package/android/src/main/java/com/lodev09/truesheet/TrueSheetContentView.kt +116 -26
  4. package/android/src/main/java/com/lodev09/truesheet/TrueSheetContentViewManager.kt +7 -0
  5. package/android/src/main/java/com/lodev09/truesheet/TrueSheetFooterView.kt +48 -0
  6. package/android/src/main/java/com/lodev09/truesheet/TrueSheetFooterViewManager.kt +7 -0
  7. package/android/src/main/java/com/lodev09/truesheet/TrueSheetStateUpdater.kt +45 -0
  8. package/android/src/main/java/com/lodev09/truesheet/TrueSheetView.kt +28 -12
  9. package/android/src/main/java/com/lodev09/truesheet/TrueSheetViewController.kt +99 -34
  10. package/android/src/main/java/com/lodev09/truesheet/TrueSheetViewManager.kt +9 -5
  11. package/android/src/main/java/com/lodev09/truesheet/core/TrueSheetCoordinatorLayout.kt +12 -3
  12. package/android/src/main/java/com/lodev09/truesheet/core/TrueSheetDetentCalculator.kt +33 -4
  13. package/android/src/main/jni/TrueSheetSpec.h +2 -0
  14. package/android/src/main/jni/TrueSheetStateUpdater.cpp +96 -0
  15. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetContentViewComponentDescriptor.h +24 -0
  16. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetContentViewShadowNode.cpp +43 -0
  17. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetContentViewShadowNode.h +28 -0
  18. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetContentViewState.cpp +11 -0
  19. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetContentViewState.h +37 -0
  20. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetFooterViewComponentDescriptor.h +24 -0
  21. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetFooterViewShadowNode.cpp +56 -0
  22. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetFooterViewShadowNode.h +28 -0
  23. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetFooterViewState.cpp +11 -0
  24. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetFooterViewState.h +41 -0
  25. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetInsets.cpp +18 -0
  26. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetInsets.h +16 -0
  27. package/ios/TrueSheetContainerView.h +12 -10
  28. package/ios/TrueSheetContainerView.mm +11 -29
  29. package/ios/TrueSheetContentView.h +22 -7
  30. package/ios/TrueSheetContentView.mm +143 -41
  31. package/ios/TrueSheetFooterView.h +12 -2
  32. package/ios/TrueSheetFooterView.mm +74 -71
  33. package/ios/TrueSheetHeaderView.mm +0 -1
  34. package/ios/TrueSheetView.mm +60 -27
  35. package/ios/TrueSheetViewController.h +9 -0
  36. package/ios/TrueSheetViewController.mm +288 -122
  37. package/ios/core/TrueSheetDetentCalculator.h +9 -0
  38. package/ios/core/TrueSheetDetentCalculator.mm +13 -4
  39. package/ios/tvos/TrueSheetStubs.mm +2 -0
  40. package/lib/module/TrueSheet.js +32 -13
  41. package/lib/module/TrueSheet.js.map +1 -1
  42. package/lib/module/TrueSheet.web.js +262 -38
  43. package/lib/module/TrueSheet.web.js.map +1 -1
  44. package/lib/module/fabric/TrueSheetContentViewNativeComponent.ts +5 -1
  45. package/lib/module/fabric/TrueSheetFooterViewNativeComponent.ts +10 -2
  46. package/lib/module/fabric/TrueSheetViewNativeComponent.ts +8 -1
  47. package/lib/module/web/vaul/index.js +17 -6
  48. package/lib/module/web/vaul/index.js.map +1 -1
  49. package/lib/typescript/src/TrueSheet.d.ts.map +1 -1
  50. package/lib/typescript/src/TrueSheet.types.d.ts +35 -11
  51. package/lib/typescript/src/TrueSheet.types.d.ts.map +1 -1
  52. package/lib/typescript/src/TrueSheet.web.d.ts.map +1 -1
  53. package/lib/typescript/src/fabric/TrueSheetContentViewNativeComponent.d.ts.map +1 -1
  54. package/lib/typescript/src/fabric/TrueSheetFooterViewNativeComponent.d.ts +2 -0
  55. package/lib/typescript/src/fabric/TrueSheetFooterViewNativeComponent.d.ts.map +1 -1
  56. package/lib/typescript/src/fabric/TrueSheetViewNativeComponent.d.ts +5 -1
  57. package/lib/typescript/src/fabric/TrueSheetViewNativeComponent.d.ts.map +1 -1
  58. package/lib/typescript/src/navigation/types.d.ts +1 -1
  59. package/lib/typescript/src/navigation/types.d.ts.map +1 -1
  60. package/lib/typescript/src/web/vaul/index.d.ts +8 -1
  61. package/lib/typescript/src/web/vaul/index.d.ts.map +1 -1
  62. package/package.json +10 -10
  63. package/react-native.config.js +5 -1
  64. package/src/TrueSheet.tsx +45 -14
  65. package/src/TrueSheet.types.ts +38 -12
  66. package/src/TrueSheet.web.tsx +314 -44
  67. package/src/fabric/TrueSheetContentViewNativeComponent.ts +5 -1
  68. package/src/fabric/TrueSheetFooterViewNativeComponent.ts +10 -2
  69. package/src/fabric/TrueSheetViewNativeComponent.ts +8 -1
  70. package/src/navigation/types.ts +3 -1
  71. package/src/web/vaul/index.tsx +23 -5
  72. package/ios/utils/LayoutUtil.h +0 -77
  73. package/ios/utils/LayoutUtil.mm +0 -106
@@ -10,11 +10,13 @@
10
10
 
11
11
  #import "TrueSheetContentView.h"
12
12
  #import <React/RCTScrollViewComponentView.h>
13
- #import <react/renderer/components/TrueSheetSpec/ComponentDescriptors.h>
14
13
  #import <react/renderer/components/TrueSheetSpec/EventEmitters.h>
15
14
  #import <react/renderer/components/TrueSheetSpec/Props.h>
16
15
  #import <react/renderer/components/TrueSheetSpec/RCTComponentViewHelpers.h>
16
+ #import <react/renderer/components/TrueSheetSpec/TrueSheetContentViewComponentDescriptor.h>
17
+ #import <react/renderer/components/TrueSheetSpec/TrueSheetContentViewShadowNode.h>
17
18
  #import "TrueSheetContainerView.h"
19
+ #import "TrueSheetFooterView.h"
18
20
  #import "TrueSheetView.h"
19
21
  #import "TrueSheetViewController.h"
20
22
  #import "utils/PlatformUtil.h"
@@ -22,13 +24,18 @@
22
24
 
23
25
  using namespace facebook::react;
24
26
 
27
+ static void *TrueSheetContentSizeContext = &TrueSheetContentSizeContext;
28
+
25
29
  @implementation TrueSheetContentView {
30
+ TrueSheetContentViewShadowNode::ConcreteState::Shared _state;
26
31
  RCTScrollViewComponentView *_pinnedScrollView;
32
+ UIScrollView *_observedScrollView;
27
33
  CGSize _lastSize;
28
34
  CGFloat _bottomInset;
29
- CGFloat _originalScrollViewHeight;
30
35
  CGFloat _originalIndicatorBottomInset;
31
36
  BOOL _observingTextChanges;
37
+ BOOL _scrollableBounded;
38
+ BOOL _isReportPending;
32
39
  }
33
40
 
34
41
  + (ComponentDescriptorProvider)componentDescriptorProvider {
@@ -45,6 +52,38 @@ using namespace facebook::react;
45
52
 
46
53
  - (void)dealloc {
47
54
  [self stopObservingTextChanges];
55
+ [self unobserveScrollViewContentSize];
56
+ }
57
+
58
+ - (void)updateState:(const State::Shared &)state oldState:(const State::Shared &)oldState {
59
+ _state = std::static_pointer_cast<TrueSheetContentViewShadowNode::ConcreteState const>(state);
60
+ }
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) {
67
+ return;
68
+ }
69
+ _scrollableBounded = bounded;
70
+
71
+ if (_state) {
72
+ TrueSheetContentViewState newState;
73
+ newState.scrollableBounded = bounded;
74
+ _state->updateState(std::move(newState));
75
+ }
76
+ }
77
+
78
+ - (void)setHasAutoDetent:(BOOL)hasAutoDetent {
79
+ if (_hasAutoDetent == hasAutoDetent) {
80
+ return;
81
+ }
82
+ _hasAutoDetent = hasAutoDetent;
83
+
84
+ if (_pinnedScrollView) {
85
+ [self setScrollableBounded:hasAutoDetent];
86
+ }
48
87
  }
49
88
 
50
89
  #pragma mark - Text Change Observing
@@ -71,17 +110,61 @@ using namespace facebook::react;
71
110
 
72
111
  #pragma mark - Layout
73
112
 
74
- - (void)updateLayoutMetrics:(const LayoutMetrics &)layoutMetrics
75
- oldLayoutMetrics:(const LayoutMetrics &)oldLayoutMetrics {
76
- [super updateLayoutMetrics:layoutMetrics oldLayoutMetrics:oldLayoutMetrics];
113
+ - (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;
136
+ }
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
+ }
77
153
 
78
- CGSize newSize = CGSizeMake(layoutMetrics.frame.size.width, layoutMetrics.frame.size.height);
154
+ - (void)reportSizeNow {
155
+ CGSize newSize = CGSizeMake(self.frame.size.width, self.naturalHeight);
79
156
  if (!CGSizeEqualToSize(newSize, _lastSize)) {
80
157
  _lastSize = newSize;
81
158
  [self.delegate contentViewDidChangeSize:newSize];
82
159
  }
83
160
  }
84
161
 
162
+ - (void)updateLayoutMetrics:(const LayoutMetrics &)layoutMetrics
163
+ oldLayoutMetrics:(const LayoutMetrics &)oldLayoutMetrics {
164
+ [super updateLayoutMetrics:layoutMetrics oldLayoutMetrics:oldLayoutMetrics];
165
+ [self reportSizeIfChanged];
166
+ }
167
+
85
168
  #pragma mark - Child Mounting
86
169
 
87
170
  - (void)mountChildComponentView:(UIView<RCTComponentViewProtocol> *)childComponentView index:(NSInteger)index {
@@ -117,24 +200,17 @@ using namespace facebook::react;
117
200
 
118
201
  - (void)clearScrollable {
119
202
  if (_pinnedScrollView) {
120
- CGRect frame = _pinnedScrollView.frame;
121
- frame.size.height = _originalScrollViewHeight;
122
- _pinnedScrollView.frame = frame;
123
-
124
203
  [self setScrollViewContentInset:0 indicatorInset:_originalIndicatorBottomInset];
125
204
  }
205
+ [self unobserveScrollViewContentSize];
206
+ [self setScrollableBounded:NO];
126
207
  _pinnedScrollView = nil;
127
208
  _bottomInset = 0;
128
- _originalScrollViewHeight = 0;
129
209
  _originalIndicatorBottomInset = 0;
210
+ [self reportSizeIfChanged];
130
211
  }
131
212
 
132
- - (void)setupScrollable:(BOOL)enabled bottomInset:(CGFloat)bottomInset {
133
- if (!enabled) {
134
- [self clearScrollable];
135
- return;
136
- }
137
-
213
+ - (void)setupScrollableWithBottomInset:(CGFloat)bottomInset {
138
214
  // Check if pinned scroll view is still valid (still in view hierarchy)
139
215
  if (_pinnedScrollView && ![_pinnedScrollView isDescendantOfView:self]) {
140
216
  [self clearScrollable];
@@ -152,49 +228,71 @@ using namespace facebook::react;
152
228
 
153
229
  // Only capture originals on first pin
154
230
  if (!_pinnedScrollView) {
155
- _originalScrollViewHeight = scrollView.frame.size.height;
156
231
  _originalIndicatorBottomInset = scrollView.scrollView.verticalScrollIndicatorInsets.bottom;
157
232
  _pinnedScrollView = scrollView;
233
+
234
+ [self observeScrollViewContentSize];
235
+ [self setScrollableBounded:_hasAutoDetent];
236
+ [self reportSizeIfChanged];
158
237
  }
159
238
 
160
239
  _bottomInset = bottomInset;
161
240
 
162
- [self updateScrollViewHeight];
163
-
164
241
  [self setScrollViewContentInset:_bottomInset indicatorInset:_originalIndicatorBottomInset];
165
242
 
166
243
  // If keyboard is currently showing, re-apply the keyboard inset to the new ScrollView
167
244
  CGFloat keyboardHeight = _keyboardObserver ? _keyboardObserver.currentHeight : 0;
168
245
  if (keyboardHeight > 0) {
169
- [self setScrollViewContentInset:keyboardHeight indicatorInset:_originalIndicatorBottomInset + keyboardHeight];
246
+ CGFloat inset = [self keyboardInsetWithHeight:keyboardHeight];
247
+ [self setScrollViewContentInset:inset indicatorInset:_originalIndicatorBottomInset + inset];
170
248
  }
171
249
  }
172
250
 
173
- - (void)updateScrollViewHeight {
174
- if (!_pinnedScrollView) {
175
- return;
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
+ // A relative footer stays behind the keyboard below the content, so its
254
+ // height shields that much of the keyboard's overlap.
255
+ - (CGFloat)keyboardInsetWithHeight:(CGFloat)height {
256
+ if (!self.footerView) {
257
+ return height;
176
258
  }
259
+ if (_keyboardObserver.viewController.absoluteFooter) {
260
+ return height + [self.footerView keyboardOcclusionHeight];
261
+ }
262
+ return MAX(0, height - self.footerView.frame.size.height);
263
+ }
177
264
 
178
- UIView *containerView = self.superview;
179
- if (!containerView) {
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) {
180
270
  return;
181
271
  }
272
+ [self unobserveScrollViewContentSize];
273
+ _observedScrollView = scrollView;
274
+ [scrollView addObserver:self
275
+ forKeyPath:@"contentSize"
276
+ options:NSKeyValueObservingOptionNew
277
+ context:TrueSheetContentSizeContext];
278
+ }
182
279
 
183
- CGRect scrollViewFrameInContainer = [_pinnedScrollView.superview convertRect:_pinnedScrollView.frame
184
- toView:containerView];
185
- CGFloat newHeight = containerView.bounds.size.height - scrollViewFrameInContainer.origin.y;
186
-
187
- if (newHeight > 0) {
188
- // Preserve contentOffset before changing frame to prevent scroll jump
189
- CGPoint savedContentOffset = _pinnedScrollView.scrollView.contentOffset;
190
-
191
- CGRect frame = _pinnedScrollView.frame;
192
- frame.size.height = newHeight;
193
- _pinnedScrollView.frame = frame;
280
+ - (void)unobserveScrollViewContentSize {
281
+ if (_observedScrollView) {
282
+ [_observedScrollView removeObserver:self forKeyPath:@"contentSize" context:TrueSheetContentSizeContext];
283
+ _observedScrollView = nil;
284
+ }
285
+ }
194
286
 
195
- // Restore contentOffset after frame change
196
- _pinnedScrollView.scrollView.contentOffset = savedContentOffset;
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;
197
294
  }
295
+ [super observeValueForKeyPath:keyPath ofObject:object change:change context:context];
198
296
  }
199
297
 
200
298
  - (RCTScrollViewComponentView *)findScrollView {
@@ -282,12 +380,12 @@ using namespace facebook::react;
282
380
  TrueSheetViewController *sheetController = _keyboardObserver.viewController;
283
381
  UIView *firstResponder = sheetController ? [sheetController.view findFirstResponder] : nil;
284
382
 
383
+ CGFloat inset = [self keyboardInsetWithHeight:height];
285
384
  [UIView animateWithDuration:duration
286
385
  delay:0
287
386
  options:curve | UIViewAnimationOptionBeginFromCurrentState
288
387
  animations:^{
289
- [self setScrollViewContentInset:height
290
- indicatorInset:self->_originalIndicatorBottomInset + height];
388
+ [self setScrollViewContentInset:inset indicatorInset:self->_originalIndicatorBottomInset + inset];
291
389
  }
292
390
  completion:nil];
293
391
 
@@ -371,6 +469,10 @@ using namespace facebook::react;
371
469
  [super prepareForRecycle];
372
470
  [self stopObservingTextChanges];
373
471
  [self clearScrollable];
472
+ _state.reset();
473
+ _scrollableBounded = NO;
474
+ _hasAutoDetent = NO;
475
+ _lastSize = CGSizeZero;
374
476
  }
375
477
 
376
478
  @end
@@ -27,14 +27,24 @@ NS_ASSUME_NONNULL_BEGIN
27
27
  @property (nonatomic, weak, nullable) TrueSheetKeyboardObserver *keyboardObserver;
28
28
  @property (nonatomic, weak, nullable) id<TrueSheetFooterViewDelegate> delegate;
29
29
 
30
- - (void)setupConstraintsWithHeight:(CGFloat)height;
31
-
32
30
  /**
33
31
  * Re-applies the footer's keyboard slide using the current keyboard height.
34
32
  * No-op when the keyboard is hidden. Used to reflect live `keyboardOffset` changes.
35
33
  */
36
34
  - (void)applyKeyboardOffset;
37
35
 
36
+ /**
37
+ * Bottom safe-area inset the footer absorbs as padding (via the shadow node)
38
+ * so its content clears the home indicator and the background fills the inset.
39
+ */
40
+ - (void)setBottomInset:(CGFloat)bottomInset;
41
+
42
+ /**
43
+ * Height the footer occupies above the keyboard — its layout height minus
44
+ * the safe-area inset it drops while the keyboard is open.
45
+ */
46
+ - (CGFloat)keyboardOcclusionHeight;
47
+
38
48
  @end
39
49
 
40
50
  NS_ASSUME_NONNULL_END
@@ -9,22 +9,22 @@
9
9
  #ifdef RCT_NEW_ARCH_ENABLED
10
10
 
11
11
  #import "TrueSheetFooterView.h"
12
- #import <react/renderer/components/TrueSheetSpec/ComponentDescriptors.h>
13
12
  #import <react/renderer/components/TrueSheetSpec/EventEmitters.h>
14
13
  #import <react/renderer/components/TrueSheetSpec/Props.h>
15
14
  #import <react/renderer/components/TrueSheetSpec/RCTComponentViewHelpers.h>
15
+ #import <react/renderer/components/TrueSheetSpec/TrueSheetFooterViewComponentDescriptor.h>
16
+ #import <react/renderer/components/TrueSheetSpec/TrueSheetFooterViewShadowNode.h>
16
17
  #import "TrueSheetViewController.h"
17
- #import "utils/LayoutUtil.h"
18
18
  #import "utils/UIView+ScrollEdgeInteraction.h"
19
19
 
20
20
  using namespace facebook::react;
21
21
 
22
22
  @implementation TrueSheetFooterView {
23
+ TrueSheetFooterViewShadowNode::ConcreteState::Shared _state;
23
24
  CGFloat _lastHeight;
24
- CGFloat _pendingHeight;
25
- BOOL _didInitialLayout;
26
- NSLayoutConstraint *_bottomConstraint;
27
25
  CGFloat _currentKeyboardOffset;
26
+ CGFloat _bottomInset;
27
+ BOOL _keyboardVisible;
28
28
  }
29
29
 
30
30
  + (ComponentDescriptorProvider)componentDescriptorProvider {
@@ -40,14 +40,59 @@ using namespace facebook::react;
40
40
  self.isAccessibilityElement = NO;
41
41
 
42
42
  _lastHeight = 0;
43
- _pendingHeight = 0;
44
- _didInitialLayout = NO;
45
- _bottomConstraint = nil;
46
43
  _currentKeyboardOffset = 0;
44
+ _bottomInset = 0;
45
+ _keyboardVisible = NO;
47
46
  }
48
47
  return self;
49
48
  }
50
49
 
50
+ - (void)updateState:(const State::Shared &)state oldState:(const State::Shared &)oldState {
51
+ _state = std::static_pointer_cast<TrueSheetFooterViewShadowNode::ConcreteState const>(state);
52
+ }
53
+
54
+ // Tells the shadow node to pad the footer's bottom edge with the sheet's
55
+ // bottom safe-area inset — the footer owns the sheet's bottom edge, so it
56
+ // absorbs the inset and its background fills it.
57
+ - (void)setBottomInset:(CGFloat)bottomInset {
58
+ if (_bottomInset == bottomInset) {
59
+ return;
60
+ }
61
+ _bottomInset = bottomInset;
62
+ [self pushBottomInsetState];
63
+ }
64
+
65
+ // Skipped while the keyboard is open — an absolute footer rises above the
66
+ // keyboard, so the inset would leave a gap.
67
+ - (void)setKeyboardVisible:(BOOL)keyboardVisible {
68
+ if (_keyboardVisible == keyboardVisible) {
69
+ return;
70
+ }
71
+ _keyboardVisible = keyboardVisible;
72
+ [self pushBottomInsetState];
73
+ }
74
+
75
+ - (void)pushBottomInsetState {
76
+ if (!_state) {
77
+ return;
78
+ }
79
+
80
+ TrueSheetFooterViewState newState;
81
+ newState.bottomInset = _keyboardVisible ? 0 : _bottomInset;
82
+ newState.initialized = true;
83
+ // Immediate so the inset lands before anything reads the footer's height —
84
+ // the async path races the keyboard caret scroll and detent setup, leaving
85
+ // them an inset stale (same reason Android bridges through
86
+ // TrueSheetStateUpdater).
87
+ _state->updateState(std::move(newState), EventQueue::UpdateMode::unstable_Immediate);
88
+ }
89
+
90
+ // Height the footer occupies above the keyboard — its layout height minus the
91
+ // safe-area inset it drops while the keyboard is open.
92
+ - (CGFloat)keyboardOcclusionHeight {
93
+ return MAX(0, _lastHeight - (_keyboardVisible ? 0 : _bottomInset));
94
+ }
95
+
51
96
  #pragma mark - Accessibility
52
97
 
53
98
  - (NSArray *)accessibilityElements {
@@ -74,57 +119,13 @@ using namespace facebook::react;
74
119
 
75
120
  #pragma mark - Layout
76
121
 
77
- - (void)setupConstraintsWithHeight:(CGFloat)height {
78
- UIView *parentView = self.superview;
79
- if (!parentView) {
80
- // On recycled views, updateLayoutMetrics can fire before the view is
81
- // reparented. Remember the desired height so didMoveToSuperview applies
82
- // it instead of falling back to the stale self.frame from the previous
83
- // present cycle.
84
- _pendingHeight = height;
85
- return;
86
- }
87
-
88
- [LayoutUtil unpinView:self fromParentView:parentView];
89
- _bottomConstraint = nil;
90
-
91
- self.translatesAutoresizingMaskIntoConstraints = NO;
92
-
93
- [self.leadingAnchor constraintEqualToAnchor:parentView.leadingAnchor].active = YES;
94
- [self.trailingAnchor constraintEqualToAnchor:parentView.trailingAnchor].active = YES;
95
-
96
- _bottomConstraint = [self.bottomAnchor constraintEqualToAnchor:parentView.bottomAnchor
97
- constant:-_currentKeyboardOffset];
98
- _bottomConstraint.active = YES;
99
-
100
- if (height > 0) {
101
- [self.heightAnchor constraintEqualToConstant:height].active = YES;
102
- }
103
-
104
- _lastHeight = height;
105
- _pendingHeight = 0;
106
- }
107
-
108
- - (void)didMoveToSuperview {
109
- [super didMoveToSuperview];
110
-
111
- if (self.superview) {
112
- CGFloat initialHeight = _pendingHeight > 0 ? _pendingHeight : self.frame.size.height;
113
- [self setupConstraintsWithHeight:initialHeight];
114
- }
115
- }
116
-
117
122
  - (void)updateLayoutMetrics:(const facebook::react::LayoutMetrics &)layoutMetrics
118
123
  oldLayoutMetrics:(const facebook::react::LayoutMetrics &)oldLayoutMetrics {
119
- CGFloat height = layoutMetrics.frame.size.height;
120
-
121
- if (!_didInitialLayout) {
122
- [super updateLayoutMetrics:layoutMetrics oldLayoutMetrics:oldLayoutMetrics];
123
- _didInitialLayout = YES;
124
- }
124
+ [super updateLayoutMetrics:layoutMetrics oldLayoutMetrics:oldLayoutMetrics];
125
125
 
126
+ CGFloat height = layoutMetrics.frame.size.height;
126
127
  if (height != _lastHeight) {
127
- [self setupConstraintsWithHeight:height];
128
+ _lastHeight = height;
128
129
  [self.delegate footerViewDidChangeSize:CGSizeMake(layoutMetrics.frame.size.width, height)];
129
130
  }
130
131
  }
@@ -132,25 +133,31 @@ using namespace facebook::react;
132
133
  - (void)prepareForRecycle {
133
134
  [super prepareForRecycle];
134
135
 
135
- [LayoutUtil unpinView:self fromParentView:self.superview];
136
136
  if (@available(iOS 26.0, *)) {
137
137
  [self cleanupEdgeInteraction];
138
138
  }
139
139
 
140
+ self.transform = CGAffineTransformIdentity;
141
+ _state = nullptr;
140
142
  _lastHeight = 0;
141
- _pendingHeight = 0;
142
- _didInitialLayout = NO;
143
- _bottomConstraint = nil;
144
143
  _currentKeyboardOffset = 0;
144
+ _bottomInset = 0;
145
+ _keyboardVisible = NO;
145
146
  }
146
147
 
147
148
  #pragma mark - TrueSheetKeyboardObserverDelegate
148
149
 
150
+ // Yoga owns the footer's frame (pinned to the container's bottom edge), so the
151
+ // keyboard slide is carried by a transform instead of layout. Only an absolute
152
+ // footer floats above the keyboard — a relative footer stays in the layout
153
+ // flow behind it.
149
154
  - (void)keyboardWillShow:(CGFloat)height duration:(NSTimeInterval)duration curve:(UIViewAnimationOptions)curve {
150
- if (!_bottomConstraint) {
155
+ if (!self.keyboardObserver.viewController.absoluteFooter) {
151
156
  return;
152
157
  }
153
158
 
159
+ [self setKeyboardVisible:YES];
160
+
154
161
  CGFloat keyboardOffset = self.keyboardObserver.viewController.footerKeyboardOffset;
155
162
  CGFloat slide = MAX(0, height + keyboardOffset);
156
163
  _currentKeyboardOffset = slide;
@@ -159,40 +166,36 @@ using namespace facebook::react;
159
166
  delay:0
160
167
  options:curve | UIViewAnimationOptionBeginFromCurrentState
161
168
  animations:^{
162
- self->_bottomConstraint.constant = -slide;
163
- [self.superview layoutIfNeeded];
169
+ self.transform = CGAffineTransformMakeTranslation(0, -slide);
164
170
  }
165
171
  completion:nil];
166
172
  }
167
173
 
168
174
  - (void)keyboardWillHide:(NSTimeInterval)duration curve:(UIViewAnimationOptions)curve {
169
- if (!_bottomConstraint) {
170
- return;
171
- }
172
-
175
+ [self setKeyboardVisible:NO];
173
176
  _currentKeyboardOffset = 0;
174
177
 
175
178
  [UIView animateWithDuration:duration
176
179
  delay:0
177
180
  options:curve | UIViewAnimationOptionBeginFromCurrentState
178
181
  animations:^{
179
- self->_bottomConstraint.constant = 0;
180
- [self.superview layoutIfNeeded];
182
+ self.transform = CGAffineTransformIdentity;
181
183
  }
182
184
  completion:nil];
183
185
  }
184
186
 
185
187
  - (void)applyKeyboardOffset {
186
188
  CGFloat height = self.keyboardObserver.currentHeight;
187
- if (!_bottomConstraint || height <= 0) {
189
+ if (height <= 0 || !self.keyboardObserver.viewController.absoluteFooter) {
188
190
  return;
189
191
  }
190
192
 
193
+ [self setKeyboardVisible:YES];
194
+
191
195
  CGFloat keyboardOffset = self.keyboardObserver.viewController.footerKeyboardOffset;
192
196
  CGFloat slide = MAX(0, height + keyboardOffset);
193
197
  _currentKeyboardOffset = slide;
194
- _bottomConstraint.constant = -slide;
195
- [self.superview layoutIfNeeded];
198
+ self.transform = CGAffineTransformMakeTranslation(0, -slide);
196
199
  }
197
200
 
198
201
  @end
@@ -13,7 +13,6 @@
13
13
  #import <react/renderer/components/TrueSheetSpec/EventEmitters.h>
14
14
  #import <react/renderer/components/TrueSheetSpec/Props.h>
15
15
  #import <react/renderer/components/TrueSheetSpec/RCTComponentViewHelpers.h>
16
- #import "utils/LayoutUtil.h"
17
16
  #import "utils/UIView+ScrollEdgeInteraction.h"
18
17
 
19
18
  using namespace facebook::react;