@lodev09/react-native-true-sheet 4.0.0-beta.2 → 4.0.0-beta.21

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 (205) hide show
  1. package/README.md +25 -4
  2. package/android/build.gradle +22 -1
  3. package/android/src/main/java/com/lodev09/truesheet/TrueSheetContainerView.kt +50 -16
  4. package/android/src/main/java/com/lodev09/truesheet/TrueSheetContentView.kt +303 -184
  5. package/android/src/main/java/com/lodev09/truesheet/TrueSheetContentViewManager.kt +5 -1
  6. package/android/src/main/java/com/lodev09/truesheet/TrueSheetFooterView.kt +21 -110
  7. package/android/src/main/java/com/lodev09/truesheet/TrueSheetOverlayView.kt +116 -0
  8. package/android/src/main/java/com/lodev09/truesheet/TrueSheetOverlayViewManager.kt +45 -0
  9. package/android/src/main/java/com/lodev09/truesheet/TrueSheetPackage.kt +2 -1
  10. package/android/src/main/java/com/lodev09/truesheet/TrueSheetStateUpdater.kt +18 -0
  11. package/android/src/main/java/com/lodev09/truesheet/TrueSheetView.kt +124 -50
  12. package/android/src/main/java/com/lodev09/truesheet/TrueSheetViewController.kt +369 -163
  13. package/android/src/main/java/com/lodev09/truesheet/TrueSheetViewManager.kt +59 -12
  14. package/android/src/main/java/com/lodev09/truesheet/core/TrueSheetBottomSheetBehavior.kt +126 -0
  15. package/android/src/main/java/com/lodev09/truesheet/core/TrueSheetBottomSheetView.kt +22 -9
  16. package/android/src/main/java/com/lodev09/truesheet/core/TrueSheetCoordinatorLayout.kt +84 -5
  17. package/android/src/main/java/com/lodev09/truesheet/core/TrueSheetDetentCalculator.kt +104 -39
  18. package/android/src/main/java/com/lodev09/truesheet/core/TrueSheetDimView.kt +3 -0
  19. package/android/src/main/java/com/lodev09/truesheet/core/TrueSheetOverlayRootView.kt +137 -0
  20. package/android/src/main/java/com/lodev09/truesheet/events/TrueSheetLifecycleEvents.kt +15 -0
  21. package/android/src/main/java/com/lodev09/truesheet/utils/KeyboardUtils.kt +15 -2
  22. package/android/src/main/java/com/lodev09/truesheet/utils/ScreenUtils.kt +0 -12
  23. package/android/src/main/java/com/lodev09/truesheet/utils/ViewUtils.kt +19 -7
  24. package/android/src/main/jni/TrueSheetSpec.h +1 -0
  25. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetContentViewComponentDescriptor.h +1 -9
  26. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetContentViewShadowNode.cpp +110 -30
  27. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetContentViewShadowNode.h +30 -7
  28. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetContentViewState.cpp +2 -2
  29. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetContentViewState.h +9 -8
  30. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetFooterViewComponentDescriptor.h +3 -5
  31. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetFooterViewShadowNode.cpp +9 -3
  32. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetFooterViewShadowNode.h +3 -6
  33. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetFooterViewState.cpp +1 -1
  34. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetFooterViewState.h +4 -6
  35. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetInsets.cpp +1 -1
  36. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetInsets.h +1 -1
  37. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetLayoutUtils.h +43 -0
  38. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetOverlayViewComponentDescriptor.h +21 -0
  39. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetOverlayViewShadowNode.cpp +17 -0
  40. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetOverlayViewShadowNode.h +33 -0
  41. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetViewComponentDescriptor.h +3 -5
  42. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetViewShadowNode.cpp +7 -40
  43. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetViewShadowNode.h +3 -7
  44. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetViewState.cpp +3 -5
  45. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetViewState.h +4 -5
  46. package/ios/TrueSheetContainerView.h +27 -7
  47. package/ios/TrueSheetContainerView.mm +31 -11
  48. package/ios/TrueSheetContentView.h +43 -15
  49. package/ios/TrueSheetContentView.mm +211 -189
  50. package/ios/TrueSheetFooterView.h +6 -0
  51. package/ios/TrueSheetFooterView.mm +93 -17
  52. package/ios/TrueSheetModule.h +2 -7
  53. package/ios/TrueSheetModule.mm +7 -16
  54. package/ios/TrueSheetOverlayView.h +32 -0
  55. package/ios/TrueSheetOverlayView.mm +161 -0
  56. package/ios/TrueSheetView.mm +275 -56
  57. package/ios/TrueSheetViewController.h +31 -3
  58. package/ios/TrueSheetViewController.mm +393 -94
  59. package/ios/core/TrueSheetBlurView.h +0 -2
  60. package/ios/core/TrueSheetBlurView.mm +3 -36
  61. package/ios/core/TrueSheetDetentCalculator.h +7 -1
  62. package/ios/core/TrueSheetDetentCalculator.mm +27 -4
  63. package/ios/core/TrueSheetGrabberView.mm +13 -3
  64. package/ios/core/TrueSheetKeyboardObserver.mm +2 -0
  65. package/ios/core/TrueSheetOverlayContainerView.h +31 -0
  66. package/ios/core/TrueSheetOverlayContainerView.mm +35 -0
  67. package/ios/events/TrueSheetLifecycleEvents.h +2 -0
  68. package/ios/events/TrueSheetLifecycleEvents.mm +8 -0
  69. package/ios/tvos/TrueSheetStubs.mm +12 -0
  70. package/ios/utils/UIView+ScrollEdgeInteraction.h +1 -0
  71. package/ios/utils/UIView+ScrollEdgeInteraction.mm +24 -2
  72. package/lib/module/TrueSheet.js +175 -39
  73. package/lib/module/TrueSheet.js.map +1 -1
  74. package/lib/module/TrueSheet.web.js +220 -243
  75. package/lib/module/TrueSheet.web.js.map +1 -1
  76. package/lib/module/TrueSheetBackground.js +15 -0
  77. package/lib/module/TrueSheetBackground.js.map +1 -0
  78. package/lib/module/TrueSheetOverlay.js +30 -0
  79. package/lib/module/TrueSheetOverlay.js.map +1 -0
  80. package/lib/module/TrueSheetOverlay.web.js +35 -0
  81. package/lib/module/TrueSheetOverlay.web.js.map +1 -0
  82. package/lib/module/TrueSheetPeek.web.js +20 -15
  83. package/lib/module/TrueSheetPeek.web.js.map +1 -1
  84. package/lib/module/detentBackgrounds.js +21 -0
  85. package/lib/module/detentBackgrounds.js.map +1 -0
  86. package/lib/module/fabric/TrueSheetContentViewNativeComponent.ts +1 -1
  87. package/lib/module/fabric/TrueSheetOverlayViewNativeComponent.ts +12 -0
  88. package/lib/module/fabric/TrueSheetViewNativeComponent.ts +15 -10
  89. package/lib/module/index.js +1 -0
  90. package/lib/module/index.js.map +1 -1
  91. package/lib/module/mocks/index.js +18 -2
  92. package/lib/module/mocks/index.js.map +1 -1
  93. package/lib/module/mocks/navigationExpoRouter.js.map +1 -1
  94. package/lib/module/navigation/base-types.js +2 -0
  95. package/lib/module/navigation/base-types.js.map +1 -0
  96. package/lib/module/navigation/createTrueSheetRouter.js +0 -9
  97. package/lib/module/navigation/createTrueSheetRouter.js.map +1 -1
  98. package/lib/module/navigation/expo-router/base-types.js +2 -0
  99. package/lib/module/navigation/expo-router/base-types.js.map +1 -0
  100. package/lib/module/navigation/expo-router/index.js +10 -7
  101. package/lib/module/navigation/expo-router/index.js.map +1 -1
  102. package/lib/module/navigation/expo-router/types.js +4 -0
  103. package/lib/module/navigation/expo-router/types.js.map +1 -0
  104. package/lib/module/navigation/index.js.map +1 -1
  105. package/lib/module/navigation/screen/useSheetScreenState.js +1 -0
  106. package/lib/module/navigation/screen/useSheetScreenState.js.map +1 -1
  107. package/lib/module/web/constants.js +1 -1
  108. package/lib/module/web/constants.js.map +1 -1
  109. package/lib/module/web/layout.js +168 -0
  110. package/lib/module/web/layout.js.map +1 -0
  111. package/lib/module/web/vaul/constants.js +9 -0
  112. package/lib/module/web/vaul/constants.js.map +1 -1
  113. package/lib/module/web/vaul/context.js.map +1 -1
  114. package/lib/module/web/vaul/helpers.js +1 -0
  115. package/lib/module/web/vaul/helpers.js.map +1 -1
  116. package/lib/module/web/vaul/index.js +47 -7
  117. package/lib/module/web/vaul/index.js.map +1 -1
  118. package/lib/module/web/vaul/use-snap-points.js +21 -10
  119. package/lib/module/web/vaul/use-snap-points.js.map +1 -1
  120. package/lib/typescript/src/TrueSheet.d.ts +6 -0
  121. package/lib/typescript/src/TrueSheet.d.ts.map +1 -1
  122. package/lib/typescript/src/TrueSheet.types.d.ts +157 -34
  123. package/lib/typescript/src/TrueSheet.types.d.ts.map +1 -1
  124. package/lib/typescript/src/TrueSheet.web.d.ts.map +1 -1
  125. package/lib/typescript/src/TrueSheetBackground.d.ts +3 -0
  126. package/lib/typescript/src/TrueSheetBackground.d.ts.map +1 -0
  127. package/lib/typescript/src/TrueSheetOverlay.d.ts +8 -0
  128. package/lib/typescript/src/TrueSheetOverlay.d.ts.map +1 -0
  129. package/lib/typescript/src/TrueSheetOverlay.web.d.ts +8 -0
  130. package/lib/typescript/src/TrueSheetOverlay.web.d.ts.map +1 -0
  131. package/lib/typescript/src/TrueSheetPeek.web.d.ts +8 -4
  132. package/lib/typescript/src/TrueSheetPeek.web.d.ts.map +1 -1
  133. package/lib/typescript/src/detentBackgrounds.d.ts +10 -0
  134. package/lib/typescript/src/detentBackgrounds.d.ts.map +1 -0
  135. package/lib/typescript/src/fabric/TrueSheetOverlayViewNativeComponent.d.ts +6 -0
  136. package/lib/typescript/src/fabric/TrueSheetOverlayViewNativeComponent.d.ts.map +1 -0
  137. package/lib/typescript/src/fabric/TrueSheetViewNativeComponent.d.ts +12 -7
  138. package/lib/typescript/src/fabric/TrueSheetViewNativeComponent.d.ts.map +1 -1
  139. package/lib/typescript/src/index.d.ts +1 -0
  140. package/lib/typescript/src/index.d.ts.map +1 -1
  141. package/lib/typescript/src/mocks/index.d.ts +4 -0
  142. package/lib/typescript/src/mocks/index.d.ts.map +1 -1
  143. package/lib/typescript/src/mocks/navigationExpoRouter.d.ts +3 -3
  144. package/lib/typescript/src/mocks/navigationExpoRouter.d.ts.map +1 -1
  145. package/lib/typescript/src/navigation/base-types.d.ts +9 -0
  146. package/lib/typescript/src/navigation/base-types.d.ts.map +1 -0
  147. package/lib/typescript/src/navigation/createTrueSheetRouter.d.ts +2 -13
  148. package/lib/typescript/src/navigation/createTrueSheetRouter.d.ts.map +1 -1
  149. package/lib/typescript/src/navigation/expo-router/base-types.d.ts +8 -0
  150. package/lib/typescript/src/navigation/expo-router/base-types.d.ts.map +1 -0
  151. package/lib/typescript/src/navigation/expo-router/index.d.ts +8 -6
  152. package/lib/typescript/src/navigation/expo-router/index.d.ts.map +1 -1
  153. package/lib/typescript/src/navigation/expo-router/types.d.ts +44 -0
  154. package/lib/typescript/src/navigation/expo-router/types.d.ts.map +1 -0
  155. package/lib/typescript/src/navigation/index.d.ts +3 -4
  156. package/lib/typescript/src/navigation/index.d.ts.map +1 -1
  157. package/lib/typescript/src/navigation/screen/useSheetScreenState.d.ts +2 -1
  158. package/lib/typescript/src/navigation/screen/useSheetScreenState.d.ts.map +1 -1
  159. package/lib/typescript/src/navigation/types.d.ts +41 -5
  160. package/lib/typescript/src/navigation/types.d.ts.map +1 -1
  161. package/lib/typescript/src/web/constants.d.ts +1 -1
  162. package/lib/typescript/src/web/constants.d.ts.map +1 -1
  163. package/lib/typescript/src/web/layout.d.ts +19 -0
  164. package/lib/typescript/src/web/layout.d.ts.map +1 -0
  165. package/lib/typescript/src/web/vaul/constants.d.ts +3 -0
  166. package/lib/typescript/src/web/vaul/constants.d.ts.map +1 -1
  167. package/lib/typescript/src/web/vaul/context.d.ts +1 -0
  168. package/lib/typescript/src/web/vaul/context.d.ts.map +1 -1
  169. package/lib/typescript/src/web/vaul/helpers.d.ts +1 -0
  170. package/lib/typescript/src/web/vaul/helpers.d.ts.map +1 -1
  171. package/lib/typescript/src/web/vaul/index.d.ts +13 -1
  172. package/lib/typescript/src/web/vaul/index.d.ts.map +1 -1
  173. package/lib/typescript/src/web/vaul/use-snap-points.d.ts +2 -1
  174. package/lib/typescript/src/web/vaul/use-snap-points.d.ts.map +1 -1
  175. package/package.json +17 -8
  176. package/react-native.config.js +1 -0
  177. package/src/TrueSheet.tsx +208 -48
  178. package/src/TrueSheet.types.ts +166 -35
  179. package/src/TrueSheet.web.tsx +272 -290
  180. package/src/TrueSheetBackground.tsx +17 -0
  181. package/src/TrueSheetOverlay.tsx +24 -0
  182. package/src/TrueSheetOverlay.web.tsx +33 -0
  183. package/src/TrueSheetPeek.web.tsx +25 -23
  184. package/src/detentBackgrounds.ts +24 -0
  185. package/src/fabric/TrueSheetContentViewNativeComponent.ts +1 -1
  186. package/src/fabric/TrueSheetOverlayViewNativeComponent.ts +12 -0
  187. package/src/fabric/TrueSheetViewNativeComponent.ts +15 -10
  188. package/src/index.ts +1 -0
  189. package/src/mocks/index.ts +17 -2
  190. package/src/mocks/navigationExpoRouter.ts +6 -5
  191. package/src/navigation/base-types.ts +20 -0
  192. package/src/navigation/createTrueSheetRouter.ts +5 -33
  193. package/src/navigation/expo-router/base-types.ts +18 -0
  194. package/src/navigation/expo-router/index.ts +28 -30
  195. package/src/navigation/expo-router/types.ts +116 -0
  196. package/src/navigation/index.ts +6 -7
  197. package/src/navigation/screen/useSheetScreenState.ts +2 -0
  198. package/src/navigation/types.ts +75 -22
  199. package/src/web/constants.ts +1 -1
  200. package/src/web/layout.ts +196 -0
  201. package/src/web/vaul/constants.ts +9 -0
  202. package/src/web/vaul/context.ts +1 -0
  203. package/src/web/vaul/helpers.ts +5 -0
  204. package/src/web/vaul/index.tsx +75 -5
  205. package/src/web/vaul/use-snap-points.ts +32 -15
@@ -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 _appliedSafeAreaInsetAdjustment;
39
34
  }
40
35
 
41
36
  + (ComponentDescriptorProvider)componentDescriptorProvider {
@@ -52,37 +47,60 @@ 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)setSafeAreaInsetAdjustment:(BOOL)safeAreaInsetAdjustment {
73
+ if (_safeAreaInsetAdjustment == safeAreaInsetAdjustment) {
74
+ return;
75
+ }
76
+ _safeAreaInsetAdjustment = safeAreaInsetAdjustment;
77
+ [self applySafeAreaInsetAdjustment];
78
+ [self updateBottomInset];
79
+ }
80
+
81
+ - (void)setFooterInsetAdjustment:(BOOL)footerInsetAdjustment {
82
+ if (_footerInsetAdjustment == footerInsetAdjustment) {
83
+ return;
75
84
  }
85
+ _footerInsetAdjustment = footerInsetAdjustment;
86
+ [self updateBottomInset];
76
87
  }
77
88
 
78
- - (void)setHasAutoDetent:(BOOL)hasAutoDetent {
79
- if (_hasAutoDetent == hasAutoDetent) {
89
+ // Hands the bottom safe-area inset to UIKit — `automatic` only insets while
90
+ // the content can scroll, which the Android counterpart mirrors manually.
91
+ - (void)applySafeAreaInsetAdjustment {
92
+ if (!_detectedScrollView) {
80
93
  return;
81
94
  }
82
- _hasAutoDetent = hasAutoDetent;
83
95
 
84
- if (_pinnedScrollView) {
85
- [self setScrollableBounded:hasAutoDetent];
96
+ UIScrollView *scrollView = _detectedScrollView.scrollView;
97
+ if (_safeAreaInsetAdjustment && !_appliedSafeAreaInsetAdjustment) {
98
+ _originalInsetAdjustmentBehavior = scrollView.contentInsetAdjustmentBehavior;
99
+ scrollView.contentInsetAdjustmentBehavior = UIScrollViewContentInsetAdjustmentAutomatic;
100
+ _appliedSafeAreaInsetAdjustment = YES;
101
+ } else if (!_safeAreaInsetAdjustment && _appliedSafeAreaInsetAdjustment) {
102
+ scrollView.contentInsetAdjustmentBehavior = _originalInsetAdjustmentBehavior;
103
+ _appliedSafeAreaInsetAdjustment = NO;
86
104
  }
87
105
  }
88
106
 
@@ -111,58 +129,25 @@ static void *TrueSheetContentSizeContext = &TrueSheetContentSizeContext;
111
129
  #pragma mark - Layout
112
130
 
113
131
  - (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;
132
+ if (_state) {
133
+ CGFloat height = _state->getData().naturalHeight;
134
+ if (height > 0) {
135
+ return height;
136
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
- }
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
137
  }
138
+ return self.frame.size.height;
160
139
  }
161
140
 
162
141
  - (void)updateLayoutMetrics:(const LayoutMetrics &)layoutMetrics
163
142
  oldLayoutMetrics:(const LayoutMetrics &)oldLayoutMetrics {
164
143
  [super updateLayoutMetrics:layoutMetrics oldLayoutMetrics:oldLayoutMetrics];
165
- [self reportSizeIfChanged];
144
+
145
+ // A deep ScrollView unmount doesn't pass through this view's mount hooks —
146
+ // detect the stale reference here so it's released instead of lingering until the
147
+ // next explicit setup or recycle.
148
+ if (_detectedScrollView && ![_detectedScrollView isDescendantOfView:self]) {
149
+ [self.delegate contentViewScrollViewDidChange];
150
+ }
166
151
  }
167
152
 
168
153
  #pragma mark - Child Mounting
@@ -178,46 +163,105 @@ static void *TrueSheetContentSizeContext = &TrueSheetContentSizeContext;
178
163
  }
179
164
 
180
165
  - (void)checkScrollViewChanged {
181
- if (!_pinnedScrollView || ![_pinnedScrollView isDescendantOfView:self]) {
166
+ if (!_detectedScrollView || ![_detectedScrollView isDescendantOfView:self]) {
167
+ [self.delegate contentViewScrollViewDidChange];
168
+ }
169
+ }
170
+
171
+ // A handle that commits in the same transaction as the ScrollView's insertion
172
+ // resolves to nothing — prop updates process before mount instructions, and a
173
+ // deep insertion doesn't pass through this view's mount hooks. Retry once the
174
+ // whole transaction has mounted.
175
+ - (void)mountingTransactionDidMount:(const MountingTransaction &)transaction
176
+ withSurfaceTelemetry:(const SurfaceTelemetry &)surfaceTelemetry {
177
+ if (_scrollableHandle > 0 && !_detectedScrollView) {
182
178
  [self.delegate contentViewScrollViewDidChange];
183
179
  }
180
+
181
+ // Layout has settled for the whole tree — the footer overlap (sheet height,
182
+ // scroll view frame) and the keyboard inset are current here
183
+ [self updateBottomInset];
184
184
  }
185
185
 
186
186
  #pragma mark - Scrollable
187
187
 
188
- - (void)setScrollViewContentInset:(CGFloat)contentBottom indicatorInset:(CGFloat)indicatorBottom {
189
- if (!_pinnedScrollView)
188
+ // The scroll indicator follows automatically — UIKit derives its insets from
189
+ // the content inset while `automaticallyAdjustsScrollIndicatorInsets` is set.
190
+ - (void)setBottomInset:(CGFloat)inset {
191
+ if (!_detectedScrollView)
190
192
  return;
191
193
 
192
- UIEdgeInsets contentInset = _pinnedScrollView.scrollView.contentInset;
193
- contentInset.bottom = contentBottom;
194
- _pinnedScrollView.scrollView.contentInset = contentInset;
194
+ UIEdgeInsets contentInset = _detectedScrollView.scrollView.contentInset;
195
+ if (fabs(contentInset.bottom - inset) < 0.001) {
196
+ return;
197
+ }
195
198
 
196
- UIEdgeInsets indicatorInsets = _pinnedScrollView.scrollView.verticalScrollIndicatorInsets;
197
- indicatorInsets.bottom = indicatorBottom;
198
- _pinnedScrollView.scrollView.verticalScrollIndicatorInsets = indicatorInsets;
199
+ contentInset.bottom = inset;
200
+ _detectedScrollView.scrollView.contentInset = contentInset;
201
+ }
202
+
203
+ // How much of the absolute footer covers the scroll view — measured from the
204
+ // footer's layout position (pinned to the container's bottom edge; its
205
+ // keyboard rise is a transform) so a short scroll view ending above the footer
206
+ // gets no inset. UIKit stacks the safe area on top of contentInset while the
207
+ // adjustment behavior is automatic — take out the inset baked into the
208
+ // footer's height so the total lands on the footer's top edge. Stays constant
209
+ // across keyboard transitions: the footer drops its inset as it rises, and
210
+ // the baked value drops with it. A pinned footer adds only the overlap not
211
+ // already covered by the keyboard inset.
212
+ - (CGFloat)footerInsetAbove:(CGFloat)keyboardInset {
213
+ if (!_footerInsetAdjustment || !self.footerView || !_detectedScrollView) {
214
+ return 0;
215
+ }
216
+
217
+ UIView *container = self.footerView.superview;
218
+ UIScrollView *scrollView = _detectedScrollView.scrollView;
219
+ CGRect scrollFrame = [scrollView convertRect:scrollView.bounds toView:container];
220
+ CGFloat footerTop = container.bounds.size.height - self.footerView.bounds.size.height;
221
+
222
+ CGFloat inset = CGRectGetMaxY(scrollFrame) - footerTop;
223
+ if (_appliedSafeAreaInsetAdjustment) {
224
+ inset -= self.footerView.appliedBottomInset;
225
+ }
226
+ if (!_keyboardObserver.viewController.footerAvoidsKeyboard) {
227
+ inset -= keyboardInset;
228
+ }
229
+ return MAX(0, inset);
230
+ }
231
+
232
+ - (CGFloat)keyboardInset {
233
+ CGFloat keyboardHeight = _keyboardObserver.currentHeight;
234
+ return keyboardHeight > 0 ? [self keyboardInsetWithHeight:keyboardHeight] : 0;
235
+ }
236
+
237
+ // Safe to call mid keyboard animation: the target matches the animation's
238
+ // model value (the footer inset doesn't change with the keyboard), so the
239
+ // early-out in setBottomInset: leaves the animation alone.
240
+ - (void)updateBottomInset {
241
+ if (!_detectedScrollView) {
242
+ return;
243
+ }
244
+ CGFloat keyboardInset = [self keyboardInset];
245
+ [self setBottomInset:[self footerInsetAbove:keyboardInset] + keyboardInset];
199
246
  }
200
247
 
201
248
  - (void)clearScrollable {
202
- if (_pinnedScrollView) {
203
- [self setScrollViewContentInset:0 indicatorInset:_originalIndicatorBottomInset];
249
+ [self setBottomInset:0];
250
+ _appliedKeyboardOffset = 0;
251
+ if (_appliedSafeAreaInsetAdjustment) {
252
+ _detectedScrollView.scrollView.contentInsetAdjustmentBehavior = _originalInsetAdjustmentBehavior;
253
+ _appliedSafeAreaInsetAdjustment = NO;
204
254
  }
205
- [self unobserveScrollViewContentSize];
206
- [self setScrollableBounded:NO];
207
- _pinnedScrollView = nil;
208
- _bottomInset = 0;
209
- _originalIndicatorBottomInset = 0;
210
- [self reportSizeIfChanged];
255
+ _detectedScrollView = nil;
211
256
  }
212
257
 
213
- - (void)setupScrollableWithBottomInset:(CGFloat)bottomInset {
214
- // Check if pinned scroll view is still valid (still in view hierarchy)
215
- if (_pinnedScrollView && ![_pinnedScrollView isDescendantOfView:self]) {
258
+ - (void)setupScrollable {
259
+ // Check if the detected scroll view is still valid (still in view hierarchy)
260
+ if (_detectedScrollView && ![_detectedScrollView isDescendantOfView:self]) {
216
261
  [self clearScrollable];
217
262
  }
218
263
 
219
- // Already set up with same inset and valid scroll view
220
- if (_pinnedScrollView && _bottomInset == bottomInset) {
264
+ if (_detectedScrollView) {
221
265
  return;
222
266
  }
223
267
 
@@ -226,104 +270,76 @@ static void *TrueSheetContentSizeContext = &TrueSheetContentSizeContext;
226
270
  return;
227
271
  }
228
272
 
229
- // Only capture originals on first pin
230
- if (!_pinnedScrollView) {
231
- _originalIndicatorBottomInset = scrollView.scrollView.verticalScrollIndicatorInsets.bottom;
232
- _pinnedScrollView = scrollView;
233
-
234
- [self observeScrollViewContentSize];
235
- [self setScrollableBounded:_hasAutoDetent];
236
- [self reportSizeIfChanged];
237
- }
238
-
239
- _bottomInset = bottomInset;
273
+ _detectedScrollView = scrollView;
240
274
 
241
- [self setScrollViewContentInset:_bottomInset indicatorInset:_originalIndicatorBottomInset];
275
+ [self applySafeAreaInsetAdjustment];
242
276
 
243
- // If keyboard is currently showing, re-apply the keyboard inset to the new ScrollView
244
- CGFloat keyboardHeight = _keyboardObserver ? _keyboardObserver.currentHeight : 0;
245
- if (keyboardHeight > 0) {
246
- CGFloat inset = [self keyboardInsetWithHeight:keyboardHeight];
247
- [self setScrollViewContentInset:inset indicatorInset:_originalIndicatorBottomInset + inset];
248
- }
277
+ // Apply the footer inset, and the keyboard inset if it's currently showing
278
+ [self updateBottomInset];
249
279
  }
250
280
 
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.
281
+ // Short or nested scroll views can end above the sheet's bottom edge.
282
+ // Their frame also accounts for space occupied by a relative footer.
255
283
  - (CGFloat)keyboardInsetWithHeight:(CGFloat)height {
256
- if (!self.footerView) {
257
- return height;
258
- }
259
- if (_keyboardObserver.viewController.absoluteFooter) {
260
- return height + [self.footerView keyboardOcclusionHeight];
261
- }
262
- return MAX(0, height - self.footerView.frame.size.height);
263
- }
284
+ UIScrollView *scrollView = _detectedScrollView.scrollView;
285
+ UIWindow *window = scrollView.window;
286
+ CGRect scrollFrame = [scrollView convertRect:scrollView.bounds toView:window];
287
+ CGFloat keyboardTop = CGRectGetMaxY(window.bounds) - height;
288
+ CGFloat inset = MAX(0, CGRectGetMaxY(scrollFrame) - keyboardTop);
264
289
 
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;
290
+ // UIKit stacks the safe area on top of contentInset while the adjustment
291
+ // behavior is automatic — take it out so the total lands on the keyboard edge.
292
+ if (_appliedSafeAreaInsetAdjustment) {
293
+ inset = MAX(0, inset - scrollView.safeAreaInsets.bottom);
271
294
  }
272
- [self unobserveScrollViewContentSize];
273
- _observedScrollView = scrollView;
274
- [scrollView addObserver:self
275
- forKeyPath:@"contentSize"
276
- options:NSKeyValueObservingOptionNew
277
- context:TrueSheetContentSizeContext];
278
- }
279
295
 
280
- - (void)unobserveScrollViewContentSize {
281
- if (_observedScrollView) {
282
- [_observedScrollView removeObserver:self forKeyPath:@"contentSize" context:TrueSheetContentSizeContext];
283
- _observedScrollView = nil;
296
+ // Track how much of keyboardOffset actually lands in the inset so the caret
297
+ // reveal can compensate — the offset shifts the inset, not the keyboard edge.
298
+ CGFloat adjustedInset = MAX(0, inset + self.keyboardOffset);
299
+ _appliedKeyboardOffset =
300
+ adjustedInset + [self footerInsetAbove:adjustedInset] - (inset + [self footerInsetAbove:inset]);
301
+
302
+ // Content that already fits above the keyboard (and footer) has nothing to
303
+ // reveal — the inset would only open blank scroll range below it (huge gap
304
+ // at the bottom).
305
+ if (scrollView.contentSize.height + [self footerInsetAbove:adjustedInset] <=
306
+ scrollView.bounds.size.height - adjustedInset) {
307
+ _appliedKeyboardOffset = 0;
308
+ return 0;
284
309
  }
310
+
311
+ return adjustedInset;
285
312
  }
286
313
 
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;
314
+ // An absolute footer floats over the viewport's bottom edge — extend the
315
+ // caret target so it clears the footer, not just the keyboard. Not needed
316
+ // while the footer inset is applied (the visible rect already ends above it)
317
+ // or when the footer stays behind the keyboard.
318
+ - (CGFloat)footerOcclusion {
319
+ TrueSheetViewController *controller = _keyboardObserver.viewController;
320
+ if (self.footerView && controller.absoluteFooter && controller.footerAvoidsKeyboard &&
321
+ [self footerInsetAbove:0] <= 0) {
322
+ return [self.footerView keyboardOcclusionHeight];
294
323
  }
295
- [super observeValueForKeyPath:keyPath ofObject:object change:change context:context];
324
+ return 0;
296
325
  }
297
326
 
327
+ // Resolves the user-provided `scrollableHandle` within the content subtree —
328
+ // Fabric component views carry their React tag as `UIView.tag`.
298
329
  - (RCTScrollViewComponentView *)findScrollView {
299
- if (_pinnedScrollView) {
300
- return _pinnedScrollView;
330
+ if (_detectedScrollView) {
331
+ return _detectedScrollView;
301
332
  }
302
333
 
303
- if (self.subviews.count == 0) {
334
+ if (_scrollableHandle <= 0) {
304
335
  return nil;
305
336
  }
306
337
 
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
- }
338
+ UIView *view = [self viewWithTag:_scrollableHandle];
339
+ if ([view isKindOfClass:RCTScrollViewComponentView.class]) {
340
+ return (RCTScrollViewComponentView *)view;
316
341
  }
317
342
 
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
343
  return nil;
328
344
  }
329
345
 
@@ -331,11 +347,11 @@ static void *TrueSheetContentSizeContext = &TrueSheetContentSizeContext;
331
347
 
332
348
  - (void)applyScrollEdgeEffects:(nullable ScrollableOptions *)options {
333
349
  #if RNTS_IPHONE_OS_VERSION_AVAILABLE(26_0)
334
- if (!_pinnedScrollView)
350
+ if (!_detectedScrollView)
335
351
  return;
336
352
 
337
353
  if (@available(iOS 26.0, *)) {
338
- UIScrollView *scrollView = _pinnedScrollView.scrollView;
354
+ UIScrollView *scrollView = _detectedScrollView.scrollView;
339
355
  auto topEffect = options ? options.topScrollEdgeEffect : TrueSheetViewTopScrollEdgeEffect::Hidden;
340
356
  auto bottomEffect = options ? options.bottomScrollEdgeEffect : TrueSheetViewBottomScrollEdgeEffect::Hidden;
341
357
 
@@ -371,7 +387,7 @@ static void *TrueSheetContentSizeContext = &TrueSheetContentSizeContext;
371
387
  #pragma mark - TrueSheetKeyboardObserverDelegate
372
388
 
373
389
  - (void)keyboardWillShow:(CGFloat)height duration:(NSTimeInterval)duration curve:(UIViewAnimationOptions)curve {
374
- if (!_pinnedScrollView) {
390
+ if (!_detectedScrollView) {
375
391
  return;
376
392
  }
377
393
 
@@ -380,14 +396,17 @@ static void *TrueSheetContentSizeContext = &TrueSheetContentSizeContext;
380
396
  TrueSheetViewController *sheetController = _keyboardObserver.viewController;
381
397
  UIView *firstResponder = sheetController ? [sheetController.view findFirstResponder] : nil;
382
398
 
383
- CGFloat inset = [self keyboardInsetWithHeight:height];
399
+ CGFloat keyboardInset = [self keyboardInsetWithHeight:height];
400
+ CGFloat inset = [self footerInsetAbove:keyboardInset] + keyboardInset;
384
401
  [UIView animateWithDuration:duration
385
- delay:0
386
- options:curve | UIViewAnimationOptionBeginFromCurrentState
387
- animations:^{
388
- [self setScrollViewContentInset:inset indicatorInset:self->_originalIndicatorBottomInset + inset];
389
- }
390
- completion:nil];
402
+ delay:0
403
+ options:curve | UIViewAnimationOptionBeginFromCurrentState
404
+ animations:^{
405
+ [self setBottomInset:inset];
406
+ }
407
+ completion:^(BOOL finished) {
408
+ [self updateBottomInset];
409
+ }];
391
410
 
392
411
  // Defer scroll until the next run loop so content insets are applied first
393
412
  if (firstResponder) {
@@ -398,7 +417,7 @@ static void *TrueSheetContentSizeContext = &TrueSheetContentSizeContext;
398
417
  }
399
418
 
400
419
  - (void)focusedInputTextDidChange:(NSNotification *)notification {
401
- if (!_pinnedScrollView || !_keyboardObserver || _keyboardObserver.currentHeight <= 0) {
420
+ if (!_detectedScrollView || !_keyboardObserver || _keyboardObserver.currentHeight <= 0) {
402
421
  return;
403
422
  }
404
423
 
@@ -409,12 +428,15 @@ static void *TrueSheetContentSizeContext = &TrueSheetContentSizeContext;
409
428
  }
410
429
 
411
430
  dispatch_async(dispatch_get_main_queue(), ^{
431
+ // Typing can grow the content (e.g. a multiline input) past the fits-above-
432
+ // the-keyboard threshold — keep the inset in sync before revealing the caret.
433
+ [self updateBottomInset];
412
434
  [self scrollToFocusedCaretAnimated:YES];
413
435
  });
414
436
  }
415
437
 
416
438
  - (void)scrollToFocusedCaretAnimated:(BOOL)animated {
417
- if (!_pinnedScrollView) {
439
+ if (!_detectedScrollView) {
418
440
  return;
419
441
  }
420
442
 
@@ -424,7 +446,7 @@ static void *TrueSheetContentSizeContext = &TrueSheetContentSizeContext;
424
446
  return;
425
447
  }
426
448
 
427
- UIScrollView *scrollView = _pinnedScrollView.scrollView;
449
+ UIScrollView *scrollView = _detectedScrollView.scrollView;
428
450
  CGRect targetRect = [firstResponder convertRect:firstResponder.bounds toView:scrollView];
429
451
 
430
452
  if ([firstResponder conformsToProtocol:@protocol(UITextInput)]) {
@@ -442,14 +464,14 @@ static void *TrueSheetContentSizeContext = &TrueSheetContentSizeContext;
442
464
  }
443
465
  }
444
466
 
445
- targetRect.size.height += self.keyboardScrollOffset;
467
+ targetRect.size.height += self.keyboardScrollOffset + [self footerOcclusion] - _appliedKeyboardOffset;
446
468
  [scrollView scrollRectToVisible:targetRect animated:animated];
447
469
  }
448
470
 
449
471
  - (void)keyboardWillHide:(NSTimeInterval)duration curve:(UIViewAnimationOptions)curve {
450
472
  [self stopObservingTextChanges];
451
473
 
452
- if (!_pinnedScrollView) {
474
+ if (!_detectedScrollView) {
453
475
  return;
454
476
  }
455
477
 
@@ -457,8 +479,7 @@ static void *TrueSheetContentSizeContext = &TrueSheetContentSizeContext;
457
479
  delay:0
458
480
  options:curve | UIViewAnimationOptionBeginFromCurrentState
459
481
  animations:^{
460
- [self setScrollViewContentInset:self->_bottomInset
461
- indicatorInset:self->_originalIndicatorBottomInset];
482
+ [self setBottomInset:[self footerInsetAbove:0]];
462
483
  }
463
484
  completion:nil];
464
485
  }
@@ -470,9 +491,10 @@ static void *TrueSheetContentSizeContext = &TrueSheetContentSizeContext;
470
491
  [self stopObservingTextChanges];
471
492
  [self clearScrollable];
472
493
  _state.reset();
473
- _scrollableBounded = NO;
474
- _hasAutoDetent = NO;
475
- _lastSize = CGSizeZero;
494
+ _scrollableHandle = 0;
495
+ _safeAreaInsetAdjustment = NO;
496
+ _footerInsetAdjustment = NO;
497
+ _lastReportedNaturalHeight = 0;
476
498
  }
477
499
 
478
500
  @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.