@lodev09/react-native-true-sheet 4.0.0-beta.14 → 4.0.0-beta.16

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 (74) hide show
  1. package/android/src/main/java/com/lodev09/truesheet/TrueSheetContainerView.kt +28 -6
  2. package/android/src/main/java/com/lodev09/truesheet/TrueSheetContentView.kt +99 -7
  3. package/android/src/main/java/com/lodev09/truesheet/TrueSheetFooterView.kt +12 -0
  4. package/android/src/main/java/com/lodev09/truesheet/TrueSheetOverlayView.kt +11 -1
  5. package/android/src/main/java/com/lodev09/truesheet/TrueSheetView.kt +18 -7
  6. package/android/src/main/java/com/lodev09/truesheet/TrueSheetViewController.kt +40 -39
  7. package/android/src/main/java/com/lodev09/truesheet/TrueSheetViewManager.kt +18 -8
  8. package/android/src/main/java/com/lodev09/truesheet/core/TrueSheetBottomSheetView.kt +14 -8
  9. package/android/src/main/java/com/lodev09/truesheet/core/TrueSheetCoordinatorLayout.kt +8 -1
  10. package/android/src/main/java/com/lodev09/truesheet/core/TrueSheetDetentCalculator.kt +32 -5
  11. package/android/src/main/java/com/lodev09/truesheet/core/TrueSheetDimView.kt +3 -0
  12. package/ios/TrueSheetContainerView.h +17 -4
  13. package/ios/TrueSheetContainerView.mm +23 -6
  14. package/ios/TrueSheetContentView.h +13 -1
  15. package/ios/TrueSheetContentView.mm +93 -35
  16. package/ios/TrueSheetFooterView.mm +46 -16
  17. package/ios/TrueSheetView.mm +30 -28
  18. package/ios/TrueSheetViewController.h +21 -1
  19. package/ios/TrueSheetViewController.mm +54 -16
  20. package/ios/core/TrueSheetDetentCalculator.h +6 -1
  21. package/ios/core/TrueSheetDetentCalculator.mm +9 -4
  22. package/ios/core/TrueSheetKeyboardObserver.mm +2 -0
  23. package/lib/module/TrueSheet.js +8 -5
  24. package/lib/module/TrueSheet.js.map +1 -1
  25. package/lib/module/TrueSheet.web.js +93 -218
  26. package/lib/module/TrueSheet.web.js.map +1 -1
  27. package/lib/module/TrueSheetPeek.web.js +20 -15
  28. package/lib/module/TrueSheetPeek.web.js.map +1 -1
  29. package/lib/module/fabric/TrueSheetViewNativeComponent.ts +5 -3
  30. package/lib/module/navigation/expo-router/index.js +5 -4
  31. package/lib/module/navigation/expo-router/index.js.map +1 -1
  32. package/lib/module/web/constants.js +1 -1
  33. package/lib/module/web/constants.js.map +1 -1
  34. package/lib/module/web/layout.js +168 -0
  35. package/lib/module/web/layout.js.map +1 -0
  36. package/lib/module/web/vaul/helpers.js +1 -0
  37. package/lib/module/web/vaul/helpers.js.map +1 -1
  38. package/lib/module/web/vaul/index.js +9 -4
  39. package/lib/module/web/vaul/index.js.map +1 -1
  40. package/lib/module/web/vaul/use-snap-points.js +6 -8
  41. package/lib/module/web/vaul/use-snap-points.js.map +1 -1
  42. package/lib/typescript/src/TrueSheet.d.ts.map +1 -1
  43. package/lib/typescript/src/TrueSheet.types.d.ts +64 -25
  44. package/lib/typescript/src/TrueSheet.types.d.ts.map +1 -1
  45. package/lib/typescript/src/TrueSheet.web.d.ts.map +1 -1
  46. package/lib/typescript/src/TrueSheetPeek.web.d.ts +8 -4
  47. package/lib/typescript/src/TrueSheetPeek.web.d.ts.map +1 -1
  48. package/lib/typescript/src/fabric/TrueSheetViewNativeComponent.d.ts +5 -3
  49. package/lib/typescript/src/fabric/TrueSheetViewNativeComponent.d.ts.map +1 -1
  50. package/lib/typescript/src/navigation/expo-router/index.d.ts +5 -3
  51. package/lib/typescript/src/navigation/expo-router/index.d.ts.map +1 -1
  52. package/lib/typescript/src/navigation/types.d.ts +1 -1
  53. package/lib/typescript/src/navigation/types.d.ts.map +1 -1
  54. package/lib/typescript/src/web/constants.d.ts +1 -1
  55. package/lib/typescript/src/web/constants.d.ts.map +1 -1
  56. package/lib/typescript/src/web/layout.d.ts +19 -0
  57. package/lib/typescript/src/web/layout.d.ts.map +1 -0
  58. package/lib/typescript/src/web/vaul/helpers.d.ts +1 -0
  59. package/lib/typescript/src/web/vaul/helpers.d.ts.map +1 -1
  60. package/lib/typescript/src/web/vaul/index.d.ts.map +1 -1
  61. package/lib/typescript/src/web/vaul/use-snap-points.d.ts.map +1 -1
  62. package/package.json +7 -6
  63. package/src/TrueSheet.tsx +7 -4
  64. package/src/TrueSheet.types.ts +67 -25
  65. package/src/TrueSheet.web.tsx +132 -263
  66. package/src/TrueSheetPeek.web.tsx +25 -23
  67. package/src/fabric/TrueSheetViewNativeComponent.ts +5 -3
  68. package/src/navigation/expo-router/index.ts +8 -11
  69. package/src/navigation/types.ts +3 -2
  70. package/src/web/constants.ts +1 -1
  71. package/src/web/layout.ts +196 -0
  72. package/src/web/vaul/helpers.ts +5 -0
  73. package/src/web/vaul/index.tsx +21 -4
  74. package/src/web/vaul/use-snap-points.ts +6 -14
@@ -18,7 +18,7 @@ import androidx.core.view.ViewCompat
18
18
  import com.facebook.react.uimanager.PixelUtil.dpToPx
19
19
  import com.facebook.react.uimanager.ThemedReactContext
20
20
  import com.google.android.material.bottomsheet.BottomSheetBehavior
21
- import com.lodev09.truesheet.TrueSheetAnchor
21
+ import com.lodev09.truesheet.TrueSheetPlacement
22
22
  import com.lodev09.truesheet.utils.ScreenUtils
23
23
 
24
24
  interface TrueSheetBottomSheetViewDelegate {
@@ -27,8 +27,8 @@ interface TrueSheetBottomSheetViewDelegate {
27
27
  val sheetElevation: Float
28
28
  val sheetBackgroundColor: Int?
29
29
  val maxContentWidth: Int?
30
- val anchor: TrueSheetAnchor
31
- val anchorOffset: Int
30
+ val placement: TrueSheetPlacement
31
+ val placementOffset: Int
32
32
  val grabber: Boolean
33
33
  val grabberOptions: GrabberOptions?
34
34
  val accessibilityOptions: AccessibilityOptions?
@@ -94,13 +94,19 @@ class TrueSheetBottomSheetView(private val reactContext: ThemedReactContext) : F
94
94
  // =============================================================================
95
95
 
96
96
  private fun resolveAnchor(): Pair<Int, Int> {
97
- val anchor = if (ScreenUtils.isPortraitPhone(reactContext)) null else delegate?.anchor
98
- val gravity = when (anchor) {
99
- TrueSheetAnchor.LEFT -> Gravity.START
100
- TrueSheetAnchor.RIGHT -> Gravity.END
97
+ val placement = if (ScreenUtils.isPortraitPhone(reactContext)) null else delegate?.placement
98
+ val gravity = when (placement) {
99
+ TrueSheetPlacement.LEADING -> Gravity.START
100
+ TrueSheetPlacement.TRAILING -> Gravity.END
101
101
  else -> Gravity.CENTER_HORIZONTAL
102
102
  } or Gravity.BOTTOM
103
- val margin = if (anchor == TrueSheetAnchor.LEFT || anchor == TrueSheetAnchor.RIGHT) delegate?.anchorOffset ?: 0 else 0
103
+ val margin = if (placement == TrueSheetPlacement.LEADING ||
104
+ placement == TrueSheetPlacement.TRAILING
105
+ ) {
106
+ delegate?.placementOffset ?: 0
107
+ } else {
108
+ 0
109
+ }
104
110
  return Pair(gravity, margin)
105
111
  }
106
112
 
@@ -72,6 +72,7 @@ class TrueSheetCoordinatorLayout(context: Context) :
72
72
  // manually.
73
73
  private var streamSheetDraggable = false
74
74
  private var streamDraggableEditText = false
75
+ private var streamScrollsIndependently = false
75
76
 
76
77
  // Pull-down tracking. BottomSheetBehavior gives no callback when a downward drag
77
78
  // is clamped by `isHideable = false` — the sheet stays put (or the scrollable
@@ -196,6 +197,7 @@ class TrueSheetCoordinatorLayout(context: Context) :
196
197
  private fun updateStreamTargetFlags(ev: MotionEvent) {
197
198
  streamSheetDraggable = false
198
199
  streamDraggableEditText = false
200
+ streamScrollsIndependently = false
199
201
 
200
202
  val sheet = delegate?.findSheetView() ?: return
201
203
  val target = TouchTargetHelper.findTargetPathAndCoordinatesForTouch(ev.x, ev.y, this, FloatArray(2))
@@ -215,7 +217,10 @@ class TrueSheetCoordinatorLayout(context: Context) :
215
217
 
216
218
  var view: View? = target
217
219
  while (view != null && view !== this) {
218
- if (view.canScrollVertically(1) || view.canScrollVertically(-1)) return
220
+ if (view.canScrollVertically(1) || view.canScrollVertically(-1)) {
221
+ streamScrollsIndependently = !view.isNestedScrollingEnabled
222
+ return
223
+ }
219
224
  view = view.parent as? View
220
225
  }
221
226
 
@@ -297,6 +302,8 @@ class TrueSheetCoordinatorLayout(context: Context) :
297
302
  return false
298
303
  }
299
304
 
305
+ if (streamScrollsIndependently) return false
306
+
300
307
  if (streamGestureClaimed) {
301
308
  if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_CANCEL) {
302
309
  streamGestureClaimed = false
@@ -18,6 +18,15 @@ interface TrueSheetDetentCalculatorDelegate {
18
18
  val absoluteHeader: Boolean
19
19
  val footerHeight: Int
20
20
  val absoluteFooter: Boolean
21
+
22
+ /**
23
+ * True while the plugged scrollable is padded by the absolute footer's
24
+ * height (see ScrollableOptions.footerInsetAdjustment) — the footer then
25
+ * counts toward the auto detent like a relative one, so the content ends
26
+ * above it.
27
+ */
28
+ val footerInsetAdjustment: Boolean
29
+ val footerAvoidsKeyboard: Boolean
21
30
  val peekContentHeight: Int
22
31
  val contentBottomInset: Int
23
32
  val maxContentHeight: Int?
@@ -53,6 +62,14 @@ class TrueSheetDetentCalculator(private val reactContext: ThemedReactContext) {
53
62
  private val topInset: Int get() = delegate?.topInset ?: 0
54
63
  private val hasKeyboardFloor: Boolean get() = delegate?.hasKeyboardFloor == true
55
64
 
65
+ /**
66
+ * An absolute footer floats over the content and contributes no height to
67
+ * the auto detent — unless it pads the scrollable, then the content ends
68
+ * above it like a relative footer.
69
+ */
70
+ private val footerInAuto: Boolean
71
+ get() = delegate?.absoluteFooter == false || delegate?.footerInsetAdjustment == true
72
+
56
73
  /**
57
74
  * Height for auto (-1.0) detents: content + header + footer height.
58
75
  * An absolute (floating) header or footer overlaps the content, so it contributes no height.
@@ -60,14 +77,14 @@ class TrueSheetDetentCalculator(private val reactContext: ThemedReactContext) {
60
77
  private val autoDetentHeight: Int
61
78
  get() = contentHeight +
62
79
  (if (delegate?.absoluteHeader == true) 0 else headerHeight) +
63
- (if (delegate?.absoluteFooter == true) 0 else footerHeight)
80
+ (if (footerInAuto) footerHeight else 0)
64
81
 
65
82
  /**
66
- * Bottom inset for auto detents. A relative footer owns the sheet's bottom
67
- * edge, so it absorbs the inset instead of adding it to the auto height.
83
+ * Bottom inset for auto detents. A footer counted in the auto height owns
84
+ * the sheet's bottom edge, so it absorbs the inset instead of adding it.
68
85
  */
69
86
  private val autoDetentBottomInset: Int
70
- get() = if (footerHeight > 0 && delegate?.absoluteFooter == false) 0 else contentBottomInset
87
+ get() = if (footerHeight > 0 && footerInAuto) 0 else contentBottomInset
71
88
 
72
89
  /**
73
90
  * Bottom inset for peek detents. An absolute footer counts toward the peek
@@ -105,7 +122,17 @@ class TrueSheetDetentCalculator(private val reactContext: ThemedReactContext) {
105
122
  (detent * screenHeight).toInt() + contentBottomInset
106
123
  }
107
124
 
108
- val height = if (includeKeyboard) baseHeight + keyboardInset else baseHeight
125
+ val keyboardHeight = if (includeKeyboard) keyboardInset else 0
126
+ val footerKeyboardOverlap = if (detent == -1.0 &&
127
+ delegate?.absoluteFooter == true &&
128
+ footerInAuto &&
129
+ delegate?.footerAvoidsKeyboard == false
130
+ ) {
131
+ minOf(keyboardHeight, maxOf(0, footerHeight - contentBottomInset))
132
+ } else {
133
+ 0
134
+ }
135
+ val height = baseHeight + keyboardHeight - footerKeyboardOverlap
109
136
  val maxAllowedHeight = screenHeight + contentBottomInset
110
137
  return maxContentHeight?.let { minOf(height, it, maxAllowedHeight) } ?: minOf(height, maxAllowedHeight)
111
138
  }
@@ -139,6 +139,9 @@ class TrueSheetDimView(private val reactContext: ThemedReactContext) :
139
139
 
140
140
  override fun onTouchEvent(event: MotionEvent): Boolean {
141
141
  if (blockGestures) {
142
+ if (event.actionMasked == MotionEvent.ACTION_DOWN) {
143
+ parent?.requestDisallowInterceptTouchEvent(true)
144
+ }
142
145
  // When dimmed, consume touch and trigger click on ACTION_UP
143
146
  if (event.action == MotionEvent.ACTION_UP) {
144
147
  performClick()
@@ -16,13 +16,19 @@ NS_ASSUME_NONNULL_BEGIN
16
16
 
17
17
  @interface ScrollableOptions : NSObject
18
18
 
19
- @property (nonatomic, assign) BOOL contentInsetAdjustment;
19
+ @property (nonatomic, assign) facebook::react::TrueSheetViewContentInsetAdjustment contentInsetAdjustment;
20
20
  @property (nonatomic, assign) CGFloat keyboardScrollOffset;
21
21
  @property (nonatomic, assign) CGFloat keyboardOffset;
22
22
  @property (nonatomic, assign) BOOL scrollingExpandsSheet;
23
23
  @property (nonatomic, assign) facebook::react::TrueSheetViewTopScrollEdgeEffect topScrollEdgeEffect;
24
24
  @property (nonatomic, assign) facebook::react::TrueSheetViewBottomScrollEdgeEffect bottomScrollEdgeEffect;
25
25
 
26
+ /**
27
+ * Derived from `contentInsetAdjustment` — which of the two bottom insets apply
28
+ */
29
+ @property (nonatomic, readonly) BOOL safeAreaInsetAdjustment;
30
+ @property (nonatomic, readonly) BOOL footerInsetAdjustment;
31
+
26
32
  @end
27
33
 
28
34
  @class TrueSheetPeekView;
@@ -58,10 +64,17 @@ NS_ASSUME_NONNULL_BEGIN
58
64
  @property (nonatomic, assign) NSInteger scrollableHandle;
59
65
 
60
66
  /**
61
- * Whether the resolved scrollable's `contentInsetAdjustmentBehavior` is set to
62
- * `automatic` — already gated by the sheet's `insetAdjustment`
67
+ * Whether the resolved scrollable gets the bottom safe-area inset (UIKit's
68
+ * `contentInsetAdjustmentBehavior` set to `automatic`) — already gated by the
69
+ * sheet's `insetAdjustment`
70
+ */
71
+ @property (nonatomic, assign) BOOL safeAreaInsetAdjustment;
72
+
73
+ /**
74
+ * Whether the resolved scrollable is padded by the absolute footer's height —
75
+ * already gated by the footer position and a plugged scrollable
63
76
  */
64
- @property (nonatomic, assign) BOOL contentInsetAdjustment;
77
+ @property (nonatomic, assign) BOOL footerInsetAdjustment;
65
78
 
66
79
  /**
67
80
  * Bottom safe-area inset the footer absorbs as padding — the footer
@@ -32,7 +32,7 @@ using namespace facebook::react;
32
32
 
33
33
  - (instancetype)init {
34
34
  if (self = [super init]) {
35
- _contentInsetAdjustment = YES;
35
+ _contentInsetAdjustment = TrueSheetViewContentInsetAdjustment::Automatic;
36
36
  _keyboardScrollOffset = 0;
37
37
  _keyboardOffset = 0;
38
38
  _scrollingExpandsSheet = YES;
@@ -42,6 +42,16 @@ using namespace facebook::react;
42
42
  return self;
43
43
  }
44
44
 
45
+ - (BOOL)safeAreaInsetAdjustment {
46
+ return _contentInsetAdjustment == TrueSheetViewContentInsetAdjustment::Automatic ||
47
+ _contentInsetAdjustment == TrueSheetViewContentInsetAdjustment::SafeArea;
48
+ }
49
+
50
+ - (BOOL)footerInsetAdjustment {
51
+ return _contentInsetAdjustment == TrueSheetViewContentInsetAdjustment::Automatic ||
52
+ _contentInsetAdjustment == TrueSheetViewContentInsetAdjustment::Footer;
53
+ }
54
+
45
55
  @end
46
56
 
47
57
  @interface TrueSheetContainerView () <TrueSheetContentViewDelegate,
@@ -153,10 +163,16 @@ using namespace facebook::react;
153
163
  _contentView.keyboardOffset = scrollableOptions ? scrollableOptions.keyboardOffset : 0;
154
164
  }
155
165
 
166
+ - (void)setFooterInsetAdjustment:(BOOL)footerInsetAdjustment {
167
+ _footerInsetAdjustment = footerInsetAdjustment;
168
+ _contentView.footerInsetAdjustment = footerInsetAdjustment;
169
+ }
170
+
156
171
  - (void)setupScrollable {
157
172
  if (_contentView) {
158
173
  _contentView.scrollableHandle = _scrollableHandle;
159
- _contentView.contentInsetAdjustment = _contentInsetAdjustment;
174
+ _contentView.safeAreaInsetAdjustment = _safeAreaInsetAdjustment;
175
+ _contentView.footerInsetAdjustment = _footerInsetAdjustment;
160
176
  [_contentView setupScrollable];
161
177
  [_contentView applyScrollEdgeEffects:_scrollableOptions];
162
178
  if (@available(iOS 26.0, *)) {
@@ -202,6 +218,7 @@ using namespace facebook::react;
202
218
  _contentView = (TrueSheetContentView *)childComponentView;
203
219
  _contentView.delegate = self;
204
220
  _contentView.footerView = _footerView;
221
+ _contentView.footerInsetAdjustment = _footerInsetAdjustment;
205
222
 
206
223
  // Children mount bottom-up, so the content subtree is complete here.
207
224
  // Late-mounted peek views attach themselves instead (see TrueSheetPeekView).
@@ -283,6 +300,7 @@ using namespace facebook::react;
283
300
 
284
301
  - (void)footerViewDidChangeSize:(CGSize)newSize {
285
302
  [self.delegate containerViewFooterDidChangeSize:newSize];
303
+ [_contentView updateBottomInset];
286
304
  if (@available(iOS 26.0, *)) {
287
305
  [self setupEdgeInteractions];
288
306
  }
@@ -310,10 +328,9 @@ using namespace facebook::react;
310
328
  return;
311
329
  }
312
330
 
313
- if (_peekView != nil) {
314
- RCTLogWarn(@"TrueSheet: Sheet can only have one peek component.");
315
- return;
316
- }
331
+ // Last-attached wins. During a screen transition the incoming peek mounts
332
+ // before the outgoing one detaches, and the outgoing detach is a no-op.
333
+ _peekView.delegate = nil;
317
334
 
318
335
  _peekView = peekView;
319
336
  peekView.delegate = self;
@@ -69,10 +69,22 @@ NS_ASSUME_NONNULL_BEGIN
69
69
  * `automatic` while plugged so UIKit applies the bottom safe-area inset to
70
70
  * the scroll content. The previous behavior is restored on clear.
71
71
  */
72
- @property (nonatomic, assign) BOOL contentInsetAdjustment;
72
+ @property (nonatomic, assign) BOOL safeAreaInsetAdjustment;
73
+
74
+ /**
75
+ * Pads the resolved ScrollView's bottom content inset by the absolute
76
+ * footer's height so the last item scrolls into view above it. Stacks with
77
+ * the keyboard inset.
78
+ */
79
+ @property (nonatomic, assign) BOOL footerInsetAdjustment;
73
80
 
74
81
  - (RCTScrollViewComponentView *_Nullable)findScrollView;
75
82
 
83
+ /**
84
+ * Re-applies the footer and keyboard insets — call when the footer's size changes
85
+ */
86
+ - (void)updateBottomInset;
87
+
76
88
  /**
77
89
  * Resolve the ScrollView from `scrollableHandle`, wiring keyboard handling
78
90
  */
@@ -30,7 +30,7 @@ using namespace facebook::react;
30
30
  CGFloat _appliedKeyboardOffset;
31
31
  BOOL _observingTextChanges;
32
32
  UIScrollViewContentInsetAdjustmentBehavior _originalInsetAdjustmentBehavior;
33
- BOOL _appliedContentInsetAdjustment;
33
+ BOOL _appliedSafeAreaInsetAdjustment;
34
34
  }
35
35
 
36
36
  + (ComponentDescriptorProvider)componentDescriptorProvider {
@@ -69,29 +69,38 @@ using namespace facebook::react;
69
69
  [self clearScrollable];
70
70
  }
71
71
 
72
- - (void)setContentInsetAdjustment:(BOOL)contentInsetAdjustment {
73
- if (_contentInsetAdjustment == contentInsetAdjustment) {
72
+ - (void)setSafeAreaInsetAdjustment:(BOOL)safeAreaInsetAdjustment {
73
+ if (_safeAreaInsetAdjustment == safeAreaInsetAdjustment) {
74
74
  return;
75
75
  }
76
- _contentInsetAdjustment = contentInsetAdjustment;
77
- [self applyContentInsetAdjustment];
76
+ _safeAreaInsetAdjustment = safeAreaInsetAdjustment;
77
+ [self applySafeAreaInsetAdjustment];
78
+ [self updateBottomInset];
79
+ }
80
+
81
+ - (void)setFooterInsetAdjustment:(BOOL)footerInsetAdjustment {
82
+ if (_footerInsetAdjustment == footerInsetAdjustment) {
83
+ return;
84
+ }
85
+ _footerInsetAdjustment = footerInsetAdjustment;
86
+ [self updateBottomInset];
78
87
  }
79
88
 
80
89
  // Hands the bottom safe-area inset to UIKit — `automatic` only insets while
81
90
  // the content can scroll, which the Android counterpart mirrors manually.
82
- - (void)applyContentInsetAdjustment {
91
+ - (void)applySafeAreaInsetAdjustment {
83
92
  if (!_detectedScrollView) {
84
93
  return;
85
94
  }
86
95
 
87
96
  UIScrollView *scrollView = _detectedScrollView.scrollView;
88
- if (_contentInsetAdjustment && !_appliedContentInsetAdjustment) {
97
+ if (_safeAreaInsetAdjustment && !_appliedSafeAreaInsetAdjustment) {
89
98
  _originalInsetAdjustmentBehavior = scrollView.contentInsetAdjustmentBehavior;
90
99
  scrollView.contentInsetAdjustmentBehavior = UIScrollViewContentInsetAdjustmentAutomatic;
91
- _appliedContentInsetAdjustment = YES;
92
- } else if (!_contentInsetAdjustment && _appliedContentInsetAdjustment) {
100
+ _appliedSafeAreaInsetAdjustment = YES;
101
+ } else if (!_safeAreaInsetAdjustment && _appliedSafeAreaInsetAdjustment) {
93
102
  scrollView.contentInsetAdjustmentBehavior = _originalInsetAdjustmentBehavior;
94
- _appliedContentInsetAdjustment = NO;
103
+ _appliedSafeAreaInsetAdjustment = NO;
95
104
  }
96
105
  }
97
106
 
@@ -169,14 +178,16 @@ using namespace facebook::react;
169
178
  [self.delegate contentViewScrollViewDidChange];
170
179
  }
171
180
 
172
- [self updateKeyboardInset];
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];
173
184
  }
174
185
 
175
186
  #pragma mark - Scrollable
176
187
 
177
188
  // The scroll indicator follows automatically — UIKit derives its insets from
178
189
  // the content inset while `automaticallyAdjustsScrollIndicatorInsets` is set.
179
- - (void)setKeyboardInset:(CGFloat)inset {
190
+ - (void)setBottomInset:(CGFloat)inset {
180
191
  if (!_detectedScrollView)
181
192
  return;
182
193
 
@@ -189,19 +200,57 @@ using namespace facebook::react;
189
200
  _detectedScrollView.scrollView.contentInset = contentInset;
190
201
  }
191
202
 
192
- - (void)updateKeyboardInset {
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 {
193
233
  CGFloat keyboardHeight = _keyboardObserver.currentHeight;
194
- if (_detectedScrollView && keyboardHeight > 0) {
195
- [self setKeyboardInset:[self keyboardInsetWithHeight:keyboardHeight]];
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;
196
243
  }
244
+ CGFloat keyboardInset = [self keyboardInset];
245
+ [self setBottomInset:[self footerInsetAbove:keyboardInset] + keyboardInset];
197
246
  }
198
247
 
199
248
  - (void)clearScrollable {
200
- [self setKeyboardInset:0];
249
+ [self setBottomInset:0];
201
250
  _appliedKeyboardOffset = 0;
202
- if (_appliedContentInsetAdjustment) {
251
+ if (_appliedSafeAreaInsetAdjustment) {
203
252
  _detectedScrollView.scrollView.contentInsetAdjustmentBehavior = _originalInsetAdjustmentBehavior;
204
- _appliedContentInsetAdjustment = NO;
253
+ _appliedSafeAreaInsetAdjustment = NO;
205
254
  }
206
255
  _detectedScrollView = nil;
207
256
  }
@@ -223,10 +272,10 @@ using namespace facebook::react;
223
272
 
224
273
  _detectedScrollView = scrollView;
225
274
 
226
- [self applyContentInsetAdjustment];
275
+ [self applySafeAreaInsetAdjustment];
227
276
 
228
- // If keyboard is currently showing, re-apply the keyboard inset to the new ScrollView
229
- [self updateKeyboardInset];
277
+ // Apply the footer inset, and the keyboard inset if it's currently showing
278
+ [self updateBottomInset];
230
279
  }
231
280
 
232
281
  // Short or nested scroll views can end above the sheet's bottom edge.
@@ -240,18 +289,21 @@ using namespace facebook::react;
240
289
 
241
290
  // UIKit stacks the safe area on top of contentInset while the adjustment
242
291
  // behavior is automatic — take it out so the total lands on the keyboard edge.
243
- if (_appliedContentInsetAdjustment) {
292
+ if (_appliedSafeAreaInsetAdjustment) {
244
293
  inset = MAX(0, inset - scrollView.safeAreaInsets.bottom);
245
294
  }
246
295
 
247
296
  // Track how much of keyboardOffset actually lands in the inset so the caret
248
297
  // reveal can compensate — the offset shifts the inset, not the keyboard edge.
249
298
  CGFloat adjustedInset = MAX(0, inset + self.keyboardOffset);
250
- _appliedKeyboardOffset = adjustedInset - inset;
251
-
252
- // Content that already fits above the keyboard has nothing to reveal — the
253
- // inset would only open blank scroll range below it (huge gap at the bottom).
254
- if (scrollView.contentSize.height <= scrollView.bounds.size.height - adjustedInset) {
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) {
255
307
  _appliedKeyboardOffset = 0;
256
308
  return 0;
257
309
  }
@@ -260,9 +312,13 @@ using namespace facebook::react;
260
312
  }
261
313
 
262
314
  // An absolute footer floats over the viewport's bottom edge — extend the
263
- // caret target so it clears the footer, not just the keyboard.
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.
264
318
  - (CGFloat)footerOcclusion {
265
- if (self.footerView && _keyboardObserver.viewController.absoluteFooter) {
319
+ TrueSheetViewController *controller = _keyboardObserver.viewController;
320
+ if (self.footerView && controller.absoluteFooter && controller.footerAvoidsKeyboard &&
321
+ [self footerInsetAbove:0] <= 0) {
266
322
  return [self.footerView keyboardOcclusionHeight];
267
323
  }
268
324
  return 0;
@@ -340,15 +396,16 @@ using namespace facebook::react;
340
396
  TrueSheetViewController *sheetController = _keyboardObserver.viewController;
341
397
  UIView *firstResponder = sheetController ? [sheetController.view findFirstResponder] : nil;
342
398
 
343
- CGFloat inset = [self keyboardInsetWithHeight:height];
399
+ CGFloat keyboardInset = [self keyboardInsetWithHeight:height];
400
+ CGFloat inset = [self footerInsetAbove:keyboardInset] + keyboardInset;
344
401
  [UIView animateWithDuration:duration
345
402
  delay:0
346
403
  options:curve | UIViewAnimationOptionBeginFromCurrentState
347
404
  animations:^{
348
- [self setKeyboardInset:inset];
405
+ [self setBottomInset:inset];
349
406
  }
350
407
  completion:^(BOOL finished) {
351
- [self updateKeyboardInset];
408
+ [self updateBottomInset];
352
409
  }];
353
410
 
354
411
  // Defer scroll until the next run loop so content insets are applied first
@@ -373,7 +430,7 @@ using namespace facebook::react;
373
430
  dispatch_async(dispatch_get_main_queue(), ^{
374
431
  // Typing can grow the content (e.g. a multiline input) past the fits-above-
375
432
  // the-keyboard threshold — keep the inset in sync before revealing the caret.
376
- [self updateKeyboardInset];
433
+ [self updateBottomInset];
377
434
  [self scrollToFocusedCaretAnimated:YES];
378
435
  });
379
436
  }
@@ -422,7 +479,7 @@ using namespace facebook::react;
422
479
  delay:0
423
480
  options:curve | UIViewAnimationOptionBeginFromCurrentState
424
481
  animations:^{
425
- [self setKeyboardInset:0];
482
+ [self setBottomInset:[self footerInsetAbove:0]];
426
483
  }
427
484
  completion:nil];
428
485
  }
@@ -435,7 +492,8 @@ using namespace facebook::react;
435
492
  [self clearScrollable];
436
493
  _state.reset();
437
494
  _scrollableHandle = 0;
438
- _contentInsetAdjustment = NO;
495
+ _safeAreaInsetAdjustment = NO;
496
+ _footerInsetAdjustment = NO;
439
497
  _lastReportedNaturalHeight = 0;
440
498
  }
441
499
 
@@ -155,6 +155,8 @@ using namespace facebook::react;
155
155
  _lastHeight = height;
156
156
  [self.delegate footerViewDidChangeSize:CGSizeMake(layoutMetrics.frame.size.width, height)];
157
157
  }
158
+
159
+ [self applyKeyboardOffset];
158
160
  }
159
161
 
160
162
  - (void)prepareForRecycle {
@@ -176,29 +178,50 @@ using namespace facebook::react;
176
178
 
177
179
  #pragma mark - TrueSheetKeyboardObserverDelegate
178
180
 
181
+ // Only an absolute footer floats above the keyboard — a relative footer stays
182
+ // in the layout flow behind it, and an absolute one opts out via
183
+ // footerOptions.avoidKeyboard.
184
+ - (BOOL)floatsAboveKeyboard {
185
+ TrueSheetViewController *controller = self.keyboardObserver.viewController;
186
+ return controller.absoluteFooter && controller.footerAvoidsKeyboard;
187
+ }
188
+
189
+ - (CGFloat)keyboardSlideForHeight:(CGFloat)height {
190
+ UIWindow *window = self.window;
191
+ if (!window) {
192
+ return 0;
193
+ }
194
+
195
+ // Measure in the transform's coordinate space, excluding the footer's current slide.
196
+ CGFloat footerBottom = self.center.y + self.bounds.size.height / 2;
197
+ CGPoint keyboardTopInWindow = CGPointMake(CGRectGetMidX(window.bounds), CGRectGetMaxY(window.bounds) - height);
198
+ CGFloat keyboardTop = [self.superview convertPoint:keyboardTopInWindow fromView:window].y;
199
+ CGFloat keyboardOffset = self.keyboardObserver.viewController.footerKeyboardOffset;
200
+ return MAX(0, footerBottom - keyboardTop + keyboardOffset);
201
+ }
202
+
179
203
  // Yoga owns the footer's frame (pinned to the container's bottom edge), so the
180
- // keyboard slide is carried by a transform instead of layout. Only an absolute
181
- // footer floats above the keyboard — a relative footer stays in the layout
182
- // flow behind it.
204
+ // keyboard slide is carried by a transform instead of layout.
183
205
  - (void)keyboardWillShow:(CGFloat)height duration:(NSTimeInterval)duration curve:(UIViewAnimationOptions)curve {
184
- if (!self.keyboardObserver.viewController.absoluteFooter) {
206
+ if (![self floatsAboveKeyboard]) {
185
207
  return;
186
208
  }
187
209
 
188
210
  [self setKeyboardVisible:YES];
189
211
 
190
- CGFloat keyboardOffset = self.keyboardObserver.viewController.footerKeyboardOffset;
191
- CGFloat slide = MAX(0, height + keyboardOffset);
212
+ CGFloat slide = [self keyboardSlideForHeight:height];
192
213
  _currentKeyboardOffset = slide;
193
214
 
194
215
  [UIView animateWithDuration:duration
195
- delay:0
196
- options:curve | UIViewAnimationOptionBeginFromCurrentState
197
- animations:^{
198
- self.transform = CGAffineTransformMakeTranslation(0, -slide);
199
- [self syncEdgeEffect];
200
- }
201
- completion:nil];
216
+ delay:0
217
+ options:curve | UIViewAnimationOptionBeginFromCurrentState
218
+ animations:^{
219
+ self.transform = CGAffineTransformMakeTranslation(0, -slide);
220
+ [self syncEdgeEffect];
221
+ }
222
+ completion:^(BOOL finished) {
223
+ [self applyKeyboardOffset];
224
+ }];
202
225
  }
203
226
 
204
227
  - (void)keyboardWillHide:(NSTimeInterval)duration curve:(UIViewAnimationOptions)curve {
@@ -232,14 +255,21 @@ using namespace facebook::react;
232
255
 
233
256
  - (void)applyKeyboardOffset {
234
257
  CGFloat height = self.keyboardObserver.currentHeight;
235
- if (height <= 0 || !self.keyboardObserver.viewController.absoluteFooter) {
258
+ if (height <= 0) {
259
+ return;
260
+ }
261
+
262
+ // avoidKeyboard flipped off mid-keyboard — drop the slide already applied
263
+ if (![self floatsAboveKeyboard]) {
264
+ [self setKeyboardVisible:NO];
265
+ _currentKeyboardOffset = 0;
266
+ self.transform = CGAffineTransformIdentity;
236
267
  return;
237
268
  }
238
269
 
239
270
  [self setKeyboardVisible:YES];
240
271
 
241
- CGFloat keyboardOffset = self.keyboardObserver.viewController.footerKeyboardOffset;
242
- CGFloat slide = MAX(0, height + keyboardOffset);
272
+ CGFloat slide = [self keyboardSlideForHeight:height];
243
273
  _currentKeyboardOffset = slide;
244
274
  self.transform = CGAffineTransformMakeTranslation(0, -slide);
245
275
  }