@lodev09/react-native-true-sheet 4.0.0-beta.24 → 4.0.0-beta.25

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 (78) hide show
  1. package/README.md +1 -0
  2. package/android/src/main/java/com/lodev09/truesheet/TrueSheetModule.kt +13 -0
  3. package/android/src/main/java/com/lodev09/truesheet/TrueSheetOverlayView.kt +11 -0
  4. package/android/src/main/java/com/lodev09/truesheet/TrueSheetView.kt +89 -9
  5. package/android/src/main/java/com/lodev09/truesheet/TrueSheetViewController.kt +100 -24
  6. package/android/src/main/java/com/lodev09/truesheet/TrueSheetViewManager.kt +13 -10
  7. package/android/src/main/java/com/lodev09/truesheet/core/RNScreensEventObserver.kt +31 -6
  8. package/android/src/main/java/com/lodev09/truesheet/core/TrueSheetBottomSheetView.kt +3 -8
  9. package/android/src/main/java/com/lodev09/truesheet/core/TrueSheetCoordinatorLayout.kt +14 -1
  10. package/android/src/main/java/com/lodev09/truesheet/core/TrueSheetDimView.kt +11 -17
  11. package/android/src/main/java/com/lodev09/truesheet/core/TrueSheetGrabberView.kt +2 -4
  12. package/android/src/main/java/com/lodev09/truesheet/utils/ColorUtils.kt +19 -0
  13. package/ios/TrueSheetModule.mm +11 -2
  14. package/ios/TrueSheetView.h +2 -0
  15. package/ios/TrueSheetView.mm +53 -7
  16. package/ios/TrueSheetViewController.h +2 -0
  17. package/ios/TrueSheetViewController.mm +158 -4
  18. package/ios/core/RNScreensEventObserver.mm +18 -1
  19. package/ios/core/TrueSheetDimView.h +27 -0
  20. package/ios/core/TrueSheetDimView.mm +41 -0
  21. package/ios/core/TrueSheetKeyboardObserver.mm +11 -8
  22. package/lib/module/TrueSheet.js +88 -17
  23. package/lib/module/TrueSheet.js.map +1 -1
  24. package/lib/module/TrueSheet.web.js +150 -37
  25. package/lib/module/TrueSheet.web.js.map +1 -1
  26. package/lib/module/TrueSheetProvider.web.js +13 -9
  27. package/lib/module/TrueSheetProvider.web.js.map +1 -1
  28. package/lib/module/fabric/TrueSheetViewNativeComponent.ts +2 -0
  29. package/lib/module/scrollable.js +28 -0
  30. package/lib/module/scrollable.js.map +1 -0
  31. package/lib/module/web/keyboard.js +69 -0
  32. package/lib/module/web/keyboard.js.map +1 -0
  33. package/lib/module/web/layout.js +1 -1
  34. package/lib/module/web/layout.js.map +1 -1
  35. package/lib/module/web/vaul/context.js.map +1 -1
  36. package/lib/module/web/vaul/index.js +18 -8
  37. package/lib/module/web/vaul/index.js.map +1 -1
  38. package/lib/module/web/vaul/use-snap-points.js +15 -10
  39. package/lib/module/web/vaul/use-snap-points.js.map +1 -1
  40. package/lib/typescript/src/TrueSheet.d.ts +14 -1
  41. package/lib/typescript/src/TrueSheet.d.ts.map +1 -1
  42. package/lib/typescript/src/TrueSheet.types.d.ts +22 -2
  43. package/lib/typescript/src/TrueSheet.types.d.ts.map +1 -1
  44. package/lib/typescript/src/TrueSheet.web.d.ts.map +1 -1
  45. package/lib/typescript/src/TrueSheetProvider.web.d.ts +5 -1
  46. package/lib/typescript/src/TrueSheetProvider.web.d.ts.map +1 -1
  47. package/lib/typescript/src/fabric/TrueSheetViewNativeComponent.d.ts +2 -0
  48. package/lib/typescript/src/fabric/TrueSheetViewNativeComponent.d.ts.map +1 -1
  49. package/lib/typescript/src/navigation/types.d.ts +1 -1
  50. package/lib/typescript/src/navigation/types.d.ts.map +1 -1
  51. package/lib/typescript/src/scrollable.d.ts +2 -0
  52. package/lib/typescript/src/scrollable.d.ts.map +1 -0
  53. package/lib/typescript/src/web/keyboard.d.ts +15 -0
  54. package/lib/typescript/src/web/keyboard.d.ts.map +1 -0
  55. package/lib/typescript/src/web/layout.d.ts +1 -0
  56. package/lib/typescript/src/web/layout.d.ts.map +1 -1
  57. package/lib/typescript/src/web/vaul/context.d.ts +2 -1
  58. package/lib/typescript/src/web/vaul/context.d.ts.map +1 -1
  59. package/lib/typescript/src/web/vaul/index.d.ts +8 -1
  60. package/lib/typescript/src/web/vaul/index.d.ts.map +1 -1
  61. package/lib/typescript/src/web/vaul/types.d.ts +9 -0
  62. package/lib/typescript/src/web/vaul/types.d.ts.map +1 -1
  63. package/lib/typescript/src/web/vaul/use-snap-points.d.ts +3 -2
  64. package/lib/typescript/src/web/vaul/use-snap-points.d.ts.map +1 -1
  65. package/package.json +1 -1
  66. package/src/TrueSheet.tsx +91 -14
  67. package/src/TrueSheet.types.ts +26 -2
  68. package/src/TrueSheet.web.tsx +170 -41
  69. package/src/TrueSheetProvider.web.tsx +22 -13
  70. package/src/fabric/TrueSheetViewNativeComponent.ts +2 -0
  71. package/src/navigation/types.ts +2 -0
  72. package/src/scrollable.ts +25 -0
  73. package/src/web/keyboard.ts +79 -0
  74. package/src/web/layout.ts +1 -1
  75. package/src/web/vaul/context.ts +2 -1
  76. package/src/web/vaul/index.tsx +20 -5
  77. package/src/web/vaul/types.ts +10 -0
  78. package/src/web/vaul/use-snap-points.ts +19 -9
@@ -1,7 +1,6 @@
1
1
  package com.lodev09.truesheet.core
2
2
 
3
3
  import android.annotation.SuppressLint
4
- import android.content.res.Configuration
5
4
  import android.graphics.Color
6
5
  import android.graphics.Outline
7
6
  import android.graphics.Rect
@@ -20,6 +19,7 @@ import com.facebook.react.uimanager.ThemedReactContext
20
19
  import com.google.android.material.bottomsheet.BottomSheetBehavior
21
20
  import com.lodev09.truesheet.TrueSheetPlacement
22
21
  import com.lodev09.truesheet.utils.ScreenUtils
22
+ import com.lodev09.truesheet.utils.isDarkMode
23
23
 
24
24
  interface TrueSheetBottomSheetViewDelegate {
25
25
  val isTopmostSheet: Boolean
@@ -186,13 +186,8 @@ class TrueSheetBottomSheetView(private val reactContext: ThemedReactContext) : F
186
186
  }
187
187
  }
188
188
 
189
- fun getDefaultBackgroundColor(): Int {
190
- val isNight = (
191
- reactContext.resources.configuration.uiMode and
192
- Configuration.UI_MODE_NIGHT_MASK
193
- ) == Configuration.UI_MODE_NIGHT_YES
194
- return if (isNight) COLOR_SURFACE_CONTAINER_LOW_DARK else COLOR_SURFACE_CONTAINER_LOW_LIGHT
195
- }
189
+ fun getDefaultBackgroundColor(): Int =
190
+ if (reactContext.isDarkMode) COLOR_SURFACE_CONTAINER_LOW_DARK else COLOR_SURFACE_CONTAINER_LOW_LIGHT
196
191
 
197
192
  fun setupElevation() {
198
193
  val value = delegate?.sheetElevation ?: DEFAULT_ELEVATION
@@ -28,6 +28,9 @@ interface TrueSheetCoordinatorLayoutDelegate {
28
28
  * didn't follow, `keyboardWasVisible` whether the keyboard was up when it began.
29
29
  */
30
30
  fun coordinatorLayoutDidPullDown(overdragPx: Int, keyboardWasVisible: Boolean)
31
+
32
+ // A touch that began inside the sheet ended, after the behavior handled the release
33
+ fun coordinatorLayoutDidEndTouch()
31
34
  }
32
35
 
33
36
  /**
@@ -86,6 +89,10 @@ class TrueSheetCoordinatorLayout(context: Context) :
86
89
  private var pullOverdrag = 0f
87
90
  private var pullKeyboardVisible = false
88
91
 
92
+ // A touch that began inside the sheet is still down
93
+ val isTouchingSheet: Boolean
94
+ get() = pullTracking
95
+
89
96
  init {
90
97
  layoutParams = LayoutParams(
91
98
  LayoutParams.MATCH_PARENT,
@@ -150,10 +157,16 @@ class TrueSheetCoordinatorLayout(context: Context) :
150
157
  if (dy > dx && pullOverdrag > PULL_DOWN_THRESHOLD_DP.dpToPx()) {
151
158
  delegate?.coordinatorLayoutDidPullDown(pullOverdrag.toInt(), pullKeyboardVisible)
152
159
  }
160
+ delegate?.coordinatorLayoutDidEndTouch()
153
161
  return handled
154
162
  }
155
163
 
156
- MotionEvent.ACTION_CANCEL -> pullTracking = false
164
+ MotionEvent.ACTION_CANCEL -> if (pullTracking) {
165
+ pullTracking = false
166
+ val handled = super.dispatchTouchEvent(ev)
167
+ delegate?.coordinatorLayoutDidEndTouch()
168
+ return handled
169
+ }
157
170
  }
158
171
  return super.dispatchTouchEvent(ev)
159
172
  }
@@ -34,10 +34,6 @@ class TrueSheetDimView(private val reactContext: ThemedReactContext) :
34
34
  View(reactContext),
35
35
  ReactPointerEventsView {
36
36
 
37
- companion object {
38
- private const val MAX_ALPHA = 0.5f
39
- }
40
-
41
37
  var delegate: TrueSheetDimViewDelegate? = null
42
38
 
43
39
  private var targetView: ViewGroup? = null
@@ -48,6 +44,10 @@ class TrueSheetDimView(private val reactContext: ThemedReactContext) :
48
44
  private val blockGestures: Boolean
49
45
  get() = alpha > 0f
50
46
 
47
+ fun setDimColor(color: Int) {
48
+ setBackgroundColor(color)
49
+ }
50
+
51
51
  init {
52
52
  layoutParams = ViewGroup.LayoutParams(
53
53
  ViewGroup.LayoutParams.MATCH_PARENT,
@@ -109,30 +109,24 @@ class TrueSheetDimView(private val reactContext: ThemedReactContext) :
109
109
  }
110
110
 
111
111
  // =============================================================================
112
- // MARK: - Alpha Calculation
112
+ // MARK: - Progress Calculation
113
113
  // =============================================================================
114
114
 
115
- fun calculateAlpha(sheetTop: Int, dimmedDetentIndex: Int, getSheetTopForDetentIndex: (Int) -> Int): Float {
115
+ /**
116
+ * How far the sheet is into its dimmed range, from 0 (undimmed) to 1 (fully dimmed).
117
+ */
118
+ fun calculateProgress(sheetTop: Int, dimmedDetentIndex: Int, getSheetTopForDetentIndex: (Int) -> Int): Float {
116
119
  val realHeight = ScreenUtils.getRealScreenHeight(reactContext)
117
120
  val dimmedDetentTop = getSheetTopForDetentIndex(dimmedDetentIndex)
118
121
  val belowDimmedTop = if (dimmedDetentIndex > 0) getSheetTopForDetentIndex(dimmedDetentIndex - 1) else realHeight
119
122
 
120
123
  return when {
121
- sheetTop <= dimmedDetentTop -> MAX_ALPHA
122
-
124
+ sheetTop <= dimmedDetentTop -> 1f
123
125
  sheetTop >= belowDimmedTop -> 0f
124
-
125
- else -> {
126
- val progress = 1f - (sheetTop - dimmedDetentTop).toFloat() / (belowDimmedTop - dimmedDetentTop)
127
- (progress * MAX_ALPHA).coerceIn(0f, MAX_ALPHA)
128
- }
126
+ else -> (1f - (sheetTop - dimmedDetentTop).toFloat() / (belowDimmedTop - dimmedDetentTop)).coerceIn(0f, 1f)
129
127
  }
130
128
  }
131
129
 
132
- fun interpolateAlpha(sheetTop: Int, dimmedDetentIndex: Int, getSheetTopForDetentIndex: (Int) -> Int) {
133
- alpha = calculateAlpha(sheetTop, dimmedDetentIndex, getSheetTopForDetentIndex)
134
- }
135
-
136
130
  // =============================================================================
137
131
  // MARK: - Touch Handling
138
132
  // =============================================================================
@@ -2,7 +2,6 @@ package com.lodev09.truesheet.core
2
2
 
3
3
  import android.annotation.SuppressLint
4
4
  import android.content.Context
5
- import android.content.res.Configuration
6
5
  import android.graphics.Color
7
6
  import android.graphics.drawable.GradientDrawable
8
7
  import android.os.Bundle
@@ -13,6 +12,7 @@ import android.widget.FrameLayout
13
12
  import androidx.core.graphics.ColorUtils
14
13
  import androidx.core.view.ViewCompat
15
14
  import com.facebook.react.uimanager.PixelUtil.dpToPx
15
+ import com.lodev09.truesheet.utils.isDarkMode
16
16
 
17
17
  /**
18
18
  * Options for customizing the grabber appearance.
@@ -165,9 +165,7 @@ class TrueSheetGrabberView(
165
165
  }
166
166
 
167
167
  private fun getAdaptiveColor(baseColor: Int? = null): Int {
168
- val nightMode = context.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK
169
- val isDarkMode = nightMode == Configuration.UI_MODE_NIGHT_YES
170
- val modeColor = if (isDarkMode) Color.WHITE else Color.BLACK
168
+ val modeColor = if (context.isDarkMode) Color.WHITE else Color.BLACK
171
169
 
172
170
  return if (baseColor != null) {
173
171
  // Blend user color with mode color for adaptive effect
@@ -0,0 +1,19 @@
1
+ package com.lodev09.truesheet.utils
2
+
3
+ import android.content.Context
4
+ import android.content.res.Configuration
5
+ import com.facebook.react.bridge.ColorPropConverter
6
+ import com.facebook.react.bridge.ReadableMap
7
+ import com.facebook.react.bridge.ReadableType
8
+
9
+ /**
10
+ * Resolves a processed color (number or `PlatformColor` map) at [key], or null when missing.
11
+ */
12
+ fun ReadableMap.getColorOrNull(key: String, context: Context): Int? {
13
+ if (!hasKey(key) || isNull(key)) return null
14
+ val value = if (getType(key) == ReadableType.Map) getMap(key) else getDouble(key)
15
+ return ColorPropConverter.getColor(value, context)
16
+ }
17
+
18
+ val Context.isDarkMode: Boolean
19
+ get() = (resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES
@@ -149,11 +149,17 @@ RCT_EXPORT_MODULE(TrueSheetModule)
149
149
  - (void)dismissAll:(BOOL)animated resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject {
150
150
  RCTExecuteOnMainQueue(^{
151
151
  @synchronized(viewRegistry) {
152
+ // Drop presents still waiting on another sheet's dismissal
153
+ for (TrueSheetView *view in viewRegistry.objectEnumerator) {
154
+ [view cancelPendingPresent];
155
+ }
156
+
152
157
  // Find the root presented sheet (one without a parent TrueSheet)
153
158
  TrueSheetView *rootSheet = nil;
154
159
 
155
160
  for (TrueSheetView *view in viewRegistry.objectEnumerator) {
156
- if (!view.viewController.isPresented) {
161
+ // A sheet still animating in counts too — dismissAnimated waits for it to land
162
+ if (!view.viewController.isPresented && !view.viewController.isBeingPresented) {
157
163
  continue;
158
164
  }
159
165
 
@@ -171,7 +177,10 @@ RCT_EXPORT_MODULE(TrueSheetModule)
171
177
  return;
172
178
  }
173
179
 
174
- [rootSheet emitDismissedPosition];
180
+ // Mid-present, the deferred dismiss tracks its own position like a regular dismiss
181
+ if (rootSheet.viewController.isPresented) {
182
+ [rootSheet emitDismissedPosition];
183
+ }
175
184
  [rootSheet dismissAnimated:animated
176
185
  completion:^(BOOL success, NSError *_Nullable error) {
177
186
  if (success) {
@@ -34,6 +34,8 @@ typedef void (^TrueSheetCompletionBlock)(BOOL success, NSError *_Nullable error)
34
34
 
35
35
  - (void)emitDismissedPosition;
36
36
 
37
+ - (BOOL)cancelPendingPresent;
38
+
37
39
  - (void)resizeToIndex:(NSInteger)index completion:(nullable TrueSheetCompletionBlock)completion;
38
40
 
39
41
  - (void)dismissStackAnimated:(BOOL)animated completion:(nullable TrueSheetCompletionBlock)completion;
@@ -317,6 +317,8 @@ using namespace facebook::react;
317
317
  _controller.dismissible = newProps.dismissible;
318
318
  _controller.draggable = newProps.draggable;
319
319
  _controller.dimmed = newProps.dimmed;
320
+ _controller.dimOpacity = newProps.dimOpacity < 0 ? nil : @(newProps.dimOpacity);
321
+ _controller.dimColor = RCTUIColorFromSharedColor(newProps.dimColor);
320
322
 
321
323
  if (newProps.dimmedDetentIndex >= 0) {
322
324
  _controller.dimmedDetentIndex = @(newProps.dimmedDetentIndex);
@@ -419,7 +421,8 @@ using namespace facebook::react;
419
421
  [self setupScrollable];
420
422
 
421
423
  if (_controller.isPresented) {
422
- [self applySheetPropsUpdate];
424
+ _pendingPropsUpdate = YES;
425
+ [self setupSheetDetentsForSizeChange];
423
426
  } else if (_controller.isBeingPresented) {
424
427
  _pendingPropsUpdate = YES;
425
428
  } else if (_initialDetentIndex >= 0) {
@@ -630,6 +633,8 @@ using namespace facebook::react;
630
633
  _controller.peekContentHeight = @([_containerView peekContentHeight]);
631
634
  }
632
635
  _pendingSizeChange = NO;
636
+ // Props queued while the previous presentation dismissed — applied above.
637
+ _pendingPropsUpdate = NO;
633
638
 
634
639
  [self refreshFooterBottomInset];
635
640
  [_controller setupSheetDetents];
@@ -719,6 +724,20 @@ using namespace facebook::react;
719
724
  return;
720
725
  }
721
726
 
727
+ // Still animating in (isPresented flips at viewDidAppear): dismiss once the presentation lands
728
+ id<UIViewControllerTransitionCoordinator> presentCoordinator =
729
+ _controller.isBeingPresented ? _controller.transitionCoordinator : nil;
730
+ if (presentCoordinator) {
731
+ __weak __typeof(self) weakSelf = self;
732
+ [presentCoordinator animateAlongsideTransition:nil
733
+ completion:^(id<UIViewControllerTransitionCoordinatorContext> context) {
734
+ dispatch_async(dispatch_get_main_queue(), ^{
735
+ [weakSelf dismissAnimated:animated completion:completion];
736
+ });
737
+ }];
738
+ return;
739
+ }
740
+
722
741
  if (_controller.isBeingDismissed || !_controller.isPresented) {
723
742
  RCTLogWarn(@"TrueSheet: sheet is already dismissed. No need to dismiss it again.");
724
743
 
@@ -768,7 +787,8 @@ using namespace facebook::react;
768
787
  #pragma mark - TrueSheetContainerViewDelegate
769
788
 
770
789
  /**
771
- * Debounced sheet update to handle rapid content/header size changes.
790
+ * Coalesces prop and size changes outside Fabric's mount transaction so
791
+ * the footer's layout can mount inside UIKit's resize animation.
772
792
  */
773
793
  - (void)setupSheetDetentsForSizeChange {
774
794
  // Keep the footer's absorbed inset in sync with the latest measured heights
@@ -784,26 +804,52 @@ using namespace facebook::react;
784
804
  }
785
805
 
786
806
  // Not presented: presentAtIndex measures at present time.
787
- // Dismissing: the sheet is going away — touching detents perturbs the
788
- // presentation stack, visibly glitching the sheet behind.
789
- if (!_controller.isPresented || _controller.isBeingDismissed)
807
+ if (!_controller.isPresented || _isSheetUpdatePending)
790
808
  return;
791
809
 
792
- if (_isSheetUpdatePending)
810
+ // Dismissing: the sheet is going away — touching detents perturbs the
811
+ // presentation stack, visibly glitching the sheet behind. An interactive
812
+ // dismiss can still cancel, so retry once the transition resolves.
813
+ if (_controller.isBeingDismissed) {
814
+ id<UIViewControllerTransitionCoordinator> coordinator = _controller.transitionCoordinator;
815
+ if (coordinator) {
816
+ _isSheetUpdatePending = YES;
817
+
818
+ __weak __typeof(self) weakSelf = self;
819
+ [coordinator animateAlongsideTransition:nil
820
+ completion:^(id<UIViewControllerTransitionCoordinatorContext> context) {
821
+ dispatch_async(dispatch_get_main_queue(), ^{
822
+ __typeof(self) strongSelf = weakSelf;
823
+ if (!strongSelf)
824
+ return;
825
+
826
+ strongSelf->_isSheetUpdatePending = NO;
827
+ [strongSelf setupSheetDetentsForSizeChange];
828
+ });
829
+ }];
830
+ }
793
831
  return;
832
+ }
794
833
 
795
834
  _isSheetUpdatePending = YES;
796
835
 
797
836
  dispatch_async(dispatch_get_main_queue(), ^{
798
837
  self->_isSheetUpdatePending = NO;
799
- if (!self->_containerView || self->_controller.isBeingDismissed)
838
+ if (!self->_containerView || !self->_controller.isPresented || self->_controller.isBeingDismissed)
800
839
  return;
801
840
 
802
841
  // Refresh here (not just on peek size events) since the peek's offset
803
842
  // within the content can change without its own size changing.
804
843
  self->_controller.peekContentHeight = @([self->_containerView peekContentHeight]);
805
844
  [self refreshFooterBottomInset];
845
+
846
+ // Size first: the props rebuild refreshes the heights itself, hiding the
847
+ // change from the controller and skipping its post-resize settle.
806
848
  [self->_controller setupSheetDetentsForSizeChange];
849
+ if (self->_pendingPropsUpdate) {
850
+ self->_pendingPropsUpdate = NO;
851
+ [self applySheetPropsUpdate];
852
+ }
807
853
  });
808
854
  }
809
855
 
@@ -78,6 +78,8 @@ NS_ASSUME_NONNULL_BEGIN
78
78
  @property (nonatomic, assign) facebook::react::TrueSheetViewAccessibilityOptionsStruct accessibilityOptions;
79
79
  @property (nonatomic, assign) BOOL draggable;
80
80
  @property (nonatomic, assign) BOOL dimmed;
81
+ @property (nonatomic, strong, nullable) UIColor *dimColor;
82
+ @property (nonatomic, strong, nullable) NSNumber *dimOpacity;
81
83
  @property (nonatomic, strong, nullable) NSNumber *dimmedDetentIndex;
82
84
  @property (nonatomic, assign) facebook::react::TrueSheetViewBackgroundBlur backgroundBlur;
83
85
  @property (nonatomic, assign) facebook::react::TrueSheetViewPresentation presentation;
@@ -11,10 +11,12 @@
11
11
  #import "TrueSheetContentView.h"
12
12
  #import "core/TrueSheetBlurView.h"
13
13
  #import "core/TrueSheetDetentCalculator.h"
14
+ #import "core/TrueSheetDimView.h"
14
15
  #import "core/TrueSheetGrabberView.h"
15
16
  #import "utils/BlurUtil.h"
16
17
  #import "utils/GestureUtil.h"
17
18
  #import "utils/PlatformUtil.h"
19
+ #import "utils/UIView+FirstResponder.h"
18
20
  #import "utils/WindowUtil.h"
19
21
 
20
22
  #import <React/RCTLog.h>
@@ -81,6 +83,8 @@ static char TrueSheetAccessibilityWindowPreviousElementsKey;
81
83
  // coordinator — started alongside the transition, stopped in its completion.
82
84
  CADisplayLink *_transitionLink;
83
85
  BOOL _isTransitioning;
86
+ // Where the sheet rests during the transition: the target on present, the start on dismiss
87
+ CGFloat _transitionRestPosition;
84
88
 
85
89
  BOOL _pendingContentSizeChange;
86
90
  BOOL _pendingDetentsChange;
@@ -91,6 +95,7 @@ static char TrueSheetAccessibilityWindowPreviousElementsKey;
91
95
  // for the next present (beats the float-threshold guess for re-presents).
92
96
  BOOL _hasObservedBottomInset;
93
97
  CGFloat _observedBottomInset;
98
+ BOOL _isRefreshingSafeArea;
94
99
 
95
100
  BOOL _isInteractiveDismiss;
96
101
  CGFloat _interactiveStartPosition;
@@ -99,6 +104,7 @@ static char TrueSheetAccessibilityWindowPreviousElementsKey;
99
104
  CADisplayLink *_interactivePositionLink;
100
105
 
101
106
  __weak TrueSheetViewController *_parentSheetController;
107
+ __weak UIView *_presenterFirstResponder;
102
108
 
103
109
  UIView *_anchorView;
104
110
 
@@ -107,6 +113,7 @@ static char TrueSheetAccessibilityWindowPreviousElementsKey;
107
113
  TrueSheetBlurView *_blurView;
108
114
  TrueSheetGrabberView *_grabberView;
109
115
  TrueSheetDetentCalculator *_detentCalculator;
116
+ TrueSheetDimView *_dimView;
110
117
  }
111
118
 
112
119
  #pragma mark - Initialization
@@ -126,6 +133,8 @@ static char TrueSheetAccessibilityWindowPreviousElementsKey;
126
133
  _footerAvoidsKeyboard = YES;
127
134
  _dismissible = YES;
128
135
  _dimmed = YES;
136
+ _dimColor = nil;
137
+ _dimOpacity = nil;
129
138
  _dimmedDetentIndex = @(0);
130
139
  _presentation = facebook::react::TrueSheetViewPresentation::Page;
131
140
  _lastEmittedPositionState = (TrueSheetPositionState){0, 0, 0};
@@ -316,6 +325,12 @@ static BOOL TrueSheetIsPhoneIdiom(void) {
316
325
  // wrong leaves the safe-area region unfilled (gap) or double-padded.
317
326
  UIView *view = self.viewIfLoaded;
318
327
  if (view.window) {
328
+ // Mid-present, hold the expected inset over a stale zero so the footer
329
+ // doesn't shrink and regrow — viewDidAppear forces the real value.
330
+ if (!_isPresented && [self hasStaleBottomSafeArea]) {
331
+ return [self bottomSafeAreaForHeight:view.bounds.size.height];
332
+ }
333
+
319
334
  _hasObservedBottomInset = YES;
320
335
  _observedBottomInset = view.safeAreaInsets.bottom;
321
336
  return _observedBottomInset;
@@ -430,6 +445,67 @@ static BOOL TrueSheetIsPhoneIdiom(void) {
430
445
  [_grabberView addToView:self.view];
431
446
  }
432
447
 
448
+ #pragma mark - Custom Dimming
449
+
450
+ // UISheetPresentationController has no API for the dim's color or opacity, so a custom
451
+ // color/opacity turns the native dim off and fades in our own view behind the sheet.
452
+ - (BOOL)useCustomDimming {
453
+ return self.dimmed && (self.dimColor != nil || self.dimOpacity != nil);
454
+ }
455
+
456
+ - (BOOL)isDimmedAtEveryDetent {
457
+ return self.dimmed && [self.dimmedDetentIndex integerValue] == 0;
458
+ }
459
+
460
+ // Like the system dim, fades in between the detent below `dimmedDetentIndex` and the dimmed detent.
461
+ - (CGFloat)dimAlphaForIndex:(CGFloat)index {
462
+ if (![self useCustomDimming]) {
463
+ return 0;
464
+ }
465
+
466
+ CGFloat progress = index - [self.dimmedDetentIndex integerValue] + 1;
467
+ return fmax(0, fmin(1, progress));
468
+ }
469
+
470
+ // Present and dismiss fade the dim over the whole travel below the resting position.
471
+ - (CGFloat)dimAlphaForPosition:(CGFloat)position index:(CGFloat)index {
472
+ if (!_isTransitioning || position <= _transitionRestPosition) {
473
+ return [self dimAlphaForIndex:index];
474
+ }
475
+
476
+ CGFloat travel = self.screenHeight - _transitionRestPosition;
477
+ CGFloat progress = travel > 0 ? fmax(0, (self.screenHeight - position) / travel) : 0;
478
+ return [self dimAlphaForIndex:[self interpolatedIndexForPosition:_transitionRestPosition]] * progress;
479
+ }
480
+
481
+ - (void)setupDimView {
482
+ UIView *container = self.presentationController.containerView;
483
+ if (!container || (!_dimView && ![self useCustomDimming])) {
484
+ return;
485
+ }
486
+
487
+ if (!_dimView) {
488
+ _dimView = [[TrueSheetDimView alloc] init];
489
+
490
+ __weak __typeof(self) weakSelf = self;
491
+ _dimView.onTap = ^{
492
+ [weakSelf handleDimViewTap];
493
+ };
494
+ }
495
+
496
+ // Keep opacity in the color so a transparent dim still receives touches.
497
+ UIColor *color = self.dimColor ?: [UIColor blackColor];
498
+ CGFloat opacity = self.dimOpacity ? [self.dimOpacity doubleValue] : 0.5;
499
+ _dimView.backgroundColor = [color colorWithAlphaComponent:CGColorGetAlpha(color.CGColor) * opacity];
500
+ [_dimView addToView:container];
501
+ }
502
+
503
+ - (void)handleDimViewTap {
504
+ if (self.dismissible) {
505
+ [self.presentingViewController dismissViewControllerAnimated:YES completion:nil];
506
+ }
507
+ }
508
+
433
509
  - (void)viewWillAppear:(BOOL)animated {
434
510
  [super viewWillAppear:animated];
435
511
 
@@ -443,6 +519,17 @@ static BOOL TrueSheetIsPhoneIdiom(void) {
443
519
  [_parentSheetController setAccessibilityContentElement:self.accessibilityContentView ?: self.view];
444
520
  }
445
521
 
522
+ [self setupDimView];
523
+
524
+ // UIKit hides the presenter's keyboard under a sheet dimmed at every detent and
525
+ // brings it back on dismiss, but custom dimming leaves the native sheet undimmed.
526
+ if ([self useCustomDimming] && [self isDimmedAtEveryDetent]) {
527
+ UIView *firstResponder = [presenter.view findFirstResponder];
528
+ if ([firstResponder resignFirstResponder]) {
529
+ _presenterFirstResponder = firstResponder;
530
+ }
531
+ }
532
+
446
533
  dispatch_async(dispatch_get_main_queue(), ^{
447
534
  NSInteger index = self.currentDetentIndex;
448
535
  CGFloat position = self.currentPosition;
@@ -475,11 +562,41 @@ static BOOL TrueSheetIsPhoneIdiom(void) {
475
562
 
476
563
  [self setupGestureRecognizer];
477
564
  _isPresented = YES;
565
+ [self refreshStaleBottomSafeArea];
478
566
  }
479
567
 
480
568
  [self setupAccessibilityContainer];
481
569
  }
482
570
 
571
+ /**
572
+ * A present that overlaps another transition (e.g. re-presenting while a
573
+ * native stack pops) can leave the root view's bottom safe area at zero, and
574
+ * UIKit doesn't recompute it until the sheet's frame next changes. Suspect a
575
+ * zero the presented height says shouldn't be there — a small floating sheet
576
+ * legitimately has none.
577
+ */
578
+ - (BOOL)hasStaleBottomSafeArea {
579
+ UIView *view = self.viewIfLoaded;
580
+ return view.window && view.safeAreaInsets.bottom <= 0 && [self bottomSafeAreaForHeight:view.bounds.size.height] > 0;
581
+ }
582
+
583
+ // Toggling additionalSafeAreaInsets forces UIKit to recompute the safe area.
584
+ // The toggle's intermediate value isn't reported, so the footer only sees the
585
+ // settled one.
586
+ - (void)refreshStaleBottomSafeArea {
587
+ if (![self hasStaleBottomSafeArea]) {
588
+ return;
589
+ }
590
+
591
+ _isRefreshingSafeArea = YES;
592
+ UIEdgeInsets insets = self.additionalSafeAreaInsets;
593
+ self.additionalSafeAreaInsets = UIEdgeInsetsMake(insets.top, insets.left, insets.bottom + 1, insets.right);
594
+ self.additionalSafeAreaInsets = insets;
595
+ _isRefreshingSafeArea = NO;
596
+
597
+ [self.delegate viewControllerSafeAreaInsetsDidChange];
598
+ }
599
+
483
600
  - (void)setupAccessibilityContainer {
484
601
  UIView *contentView = self.accessibilityContentView;
485
602
  if (!contentView) {
@@ -656,6 +773,13 @@ static BOOL TrueSheetIsPhoneIdiom(void) {
656
773
  [self.delegate viewControllerWillBlur];
657
774
  [self.delegate viewControllerWillDismiss];
658
775
  [_parentSheetController.delegate viewControllerWillFocus];
776
+
777
+ // Restored once the dismiss commits, like UIKit's own keyboard preservation.
778
+ UIView *firstResponder = _presenterFirstResponder;
779
+ _presenterFirstResponder = nil;
780
+ if (!self.presentingViewController.isBeingDismissed) {
781
+ [firstResponder becomeFirstResponder];
782
+ }
659
783
  }
660
784
  }
661
785
 
@@ -666,6 +790,9 @@ static BOOL TrueSheetIsPhoneIdiom(void) {
666
790
  _isWillDismissEmitted = NO;
667
791
  _hasObservedBottomInset = NO;
668
792
 
793
+ [_dimView removeFromSuperview];
794
+ _dimView = nil;
795
+
669
796
  [_anchorView removeFromSuperview];
670
797
  _anchorView = nil;
671
798
 
@@ -684,6 +811,15 @@ static BOOL TrueSheetIsPhoneIdiom(void) {
684
811
  [self restoreWindowAccessibilityElements];
685
812
  [self setSheetAccessibilityElementsHidden:YES];
686
813
 
814
+ // Dismissing with a child on top only animates the child out — this sheet
815
+ // doesn't move and isn't part of that transition, so fade its dim over it.
816
+ if (self.isBeingDismissed && self.presentedViewController) {
817
+ [UIView animateWithDuration:self.transitionCoordinator.transitionDuration
818
+ animations:^{
819
+ self->_dimView.alpha = 0;
820
+ }];
821
+ }
822
+
687
823
  // Dispatch to allow pan gesture to set _isDragging before checking;
688
824
  // the transition tracker emits when the sheet is transitioning to dismiss
689
825
  dispatch_async(dispatch_get_main_queue(), ^{
@@ -779,7 +915,7 @@ static BOOL TrueSheetIsPhoneIdiom(void) {
779
915
 
780
916
  // Mid-dismiss the frame moves off-screen and reports transient insets —
781
917
  // don't let them pollute the observed value (matches viewDidLayoutSubviews).
782
- if (!self.isBeingDismissed) {
918
+ if (!self.isBeingDismissed && !_isRefreshingSafeArea) {
783
919
  [self.delegate viewControllerSafeAreaInsetsDidChange];
784
920
  }
785
921
  }
@@ -928,6 +1064,7 @@ static BOOL TrueSheetIsPhoneIdiom(void) {
928
1064
  }
929
1065
 
930
1066
  _isTransitioning = YES;
1067
+ _transitionRestPosition = self.isBeingDismissed ? self.livePosition : self.currentPosition;
931
1068
 
932
1069
  // Learn the resolver-vs-actual offset before emitting transition positions so
933
1070
  // the interpolated index lands exactly on the target detent. The presented
@@ -963,10 +1100,13 @@ static BOOL TrueSheetIsPhoneIdiom(void) {
963
1100
  // Settle after a present, cancelled dismiss, or detent-snap transition.
964
1101
  // Delayed because the presentedView frame isn't final until UIKit
965
1102
  // completes its layout pass after the transition animation — learning
966
- // here absorbs any sub-pixel drift since the earlier learns.
1103
+ // here absorbs any sub-pixel drift since the earlier learns. A dismiss
1104
+ // cancelled mid-drag leaves the finger down, so the drag end settles instead.
967
1105
  if (strongSelf->_isPresented && !strongSelf.isBeingDismissed) {
968
1106
  dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.1 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
969
- [strongSelf settleAtDetentIndex:strongSelf.currentDetentIndex debug:@"transition end"];
1107
+ if (!strongSelf->_isDragging) {
1108
+ [strongSelf settleAtDetentIndex:strongSelf.currentDetentIndex debug:@"transition end"];
1109
+ }
970
1110
  });
971
1111
  }
972
1112
  }];
@@ -1145,6 +1285,11 @@ static BOOL TrueSheetIsPhoneIdiom(void) {
1145
1285
  .index = [self interpolatedIndexForPosition:position],
1146
1286
  };
1147
1287
 
1288
+ // A child on top only pushes this sheet back, its detent stays.
1289
+ if (!presented) {
1290
+ _dimView.alpha = [self dimAlphaForPosition:position index:state.index];
1291
+ }
1292
+
1148
1293
  // Settle (non-realtime) emits are authoritative and bypass the dedupe — a
1149
1294
  // learn right before them can correct the interpolated index by less than
1150
1295
  // the tolerance, and the corrected value must still reach JS.
@@ -1263,7 +1408,9 @@ static BOOL TrueSheetIsPhoneIdiom(void) {
1263
1408
 
1264
1409
  sheet.detents = detents;
1265
1410
 
1266
- if (self.dimmed && [self.dimmedDetentIndex integerValue] == 0) {
1411
+ if ([self useCustomDimming]) {
1412
+ sheet.largestUndimmedDetentIdentifier = sheet.detents.lastObject.identifier;
1413
+ } else if ([self isDimmedAtEveryDetent]) {
1267
1414
  sheet.largestUndimmedDetentIdentifier = nil;
1268
1415
  } else {
1269
1416
  sheet.largestUndimmedDetentIdentifier = UISheetPresentationControllerDetentIdentifierLarge;
@@ -1702,6 +1849,13 @@ static BOOL TrueSheetIsPhoneIdiom(void) {
1702
1849
 
1703
1850
  [self setupBackground];
1704
1851
  [self setupGrabber];
1852
+
1853
+ // Dim props changed while presented. A hidden custom dim stays until dismiss;
1854
+ // at alpha 0 it no longer takes touches.
1855
+ if (_isPresented) {
1856
+ [self setupDimView];
1857
+ _dimView.alpha = [self dimAlphaForIndex:self.currentDetentIndex];
1858
+ }
1705
1859
  }
1706
1860
 
1707
1861
  #pragma mark - UISheetPresentationControllerDelegate
@@ -272,7 +272,24 @@ using namespace facebook::react;
272
272
 
273
273
  // Dismiss if presenter is no longer top of nav stack (pushed/popped)
274
274
  // Skip if still top (e.g. modal dismiss - sheet dismisses naturally)
275
- return navController.topViewController != _presenterScreenController;
275
+ if (navController.topViewController != _presenterScreenController) {
276
+ return YES;
277
+ }
278
+
279
+ // A presenter inside a screen container (e.g. a bottom tab) stays the top of
280
+ // that container while an enclosing stack pushes a screen over it.
281
+ for (UINavigationController *outerNav = navController.navigationController; outerNav;
282
+ outerNav = outerNav.navigationController) {
283
+ UIViewController *ancestor = _presenterScreenController;
284
+ while (ancestor && ancestor != outerNav.topViewController) {
285
+ ancestor = ancestor.parentViewController;
286
+ }
287
+ if (!ancestor) {
288
+ return YES;
289
+ }
290
+ }
291
+
292
+ return NO;
276
293
  }
277
294
 
278
295
  @end