@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
package/README.md CHANGED
@@ -34,6 +34,7 @@ The true native bottom sheet experience for your React Native Apps. 💩
34
34
  * 🧭 **Expo Router** - First-class support via the `Sheet` layout from `/navigation/expo-router`
35
35
  * 🍞 **`TrueSheetOverlay`** - Render toasts, dialogs, and other [overlays above your sheets](https://sheet.lodev09.com/guides/overlays) — no more `FullWindowOverlay`/`Modal` workarounds
36
36
  * 🔍 **Element inspector** - React Native's dev menu inspector now works inside a presented sheet
37
+ * 🎨 **Dim color and opacity** - [Customize the dim](https://sheet.lodev09.com/guides/dimming#dim-color-and-opacity) behind the sheet with `dimColor` and `dimOpacity`
37
38
 
38
39
  ```sh
39
40
  npx expo install @lodev09/react-native-true-sheet@beta
@@ -125,6 +125,9 @@ class TrueSheetModule(reactContext: ReactApplicationContext) :
125
125
  fun dismissAll(animated: Boolean, promise: Promise) {
126
126
  Handler(Looper.getMainLooper()).post {
127
127
  try {
128
+ // Drop presents still waiting on the keyboard
129
+ viewRegistry.values.forEach { it.cancelPendingPresent() }
130
+
128
131
  val rootSheet = TrueSheetStackManager.getRootSheet()
129
132
  if (rootSheet == null) {
130
133
  promise.resolve(null)
@@ -221,5 +224,15 @@ class TrueSheetModule(reactContext: ReactApplicationContext) :
221
224
  */
222
225
  @JvmStatic
223
226
  fun getSheetByTag(tag: Int): TrueSheetView? = viewRegistry[tag]
227
+
228
+ /**
229
+ * Drop presents still waiting on the keyboard that would stack on [parent]
230
+ */
231
+ @JvmStatic
232
+ fun cancelPendingPresents(parent: TrueSheetView, restoreFocus: Boolean = true) {
233
+ viewRegistry.values.forEach {
234
+ if (it.pendingPresentParent === parent) it.cancelPendingPresent(restoreFocus)
235
+ }
236
+ }
224
237
  }
225
238
  }
@@ -3,6 +3,7 @@ package com.lodev09.truesheet
3
3
  import android.annotation.SuppressLint
4
4
  import android.view.View
5
5
  import android.view.ViewGroup
6
+ import android.view.ViewTreeObserver
6
7
  import android.view.accessibility.AccessibilityEvent
7
8
  import com.facebook.react.uimanager.StateWrapper
8
9
  import com.facebook.react.uimanager.ThemedReactContext
@@ -50,12 +51,22 @@ class TrueSheetOverlayView(private val reactContext: ThemedReactContext) :
50
51
 
51
52
  // ==================== View Hierarchy Management ====================
52
53
 
54
+ // When the whole window goes away (e.g. activity destroyed), the container is mid-way through
55
+ // detaching its children by then, so removing the root view in onDetachedFromWindow crashes.
56
+ // This fires before that pass begins.
57
+ private val windowAttachListener = object : ViewTreeObserver.OnWindowAttachListener {
58
+ override fun onWindowAttached() {}
59
+ override fun onWindowDetached() = detachRootView()
60
+ }
61
+
53
62
  override fun onAttachedToWindow() {
54
63
  super.onAttachedToWindow()
64
+ viewTreeObserver.addOnWindowAttachListener(windowAttachListener)
55
65
  attachRootView()
56
66
  }
57
67
 
58
68
  override fun onDetachedFromWindow() {
69
+ viewTreeObserver.removeOnWindowAttachListener(windowAttachListener)
59
70
  detachRootView()
60
71
  super.onDetachedFromWindow()
61
72
  }
@@ -1,6 +1,7 @@
1
1
  package com.lodev09.truesheet
2
2
 
3
3
  import android.annotation.SuppressLint
4
+ import android.graphics.Color
4
5
  import android.view.View
5
6
  import android.view.ViewGroup
6
7
  import android.view.accessibility.AccessibilityEvent
@@ -49,16 +50,23 @@ class TrueSheetView(private val reactContext: ThemedReactContext) :
49
50
 
50
51
  private var lastContainerWidth: Int = 0
51
52
  private var lastContainerHeight: Int = 0
53
+ private var hasCommittedContainerSize: Boolean = false
52
54
 
53
55
  var stateWrapper: StateWrapper? = null
54
56
  set(value) {
55
57
  field = value
58
+ if (value == null || hasCommittedContainerSize) return
56
59
 
57
- // On first state wrapper assignment, immediately update state with screen dimensions.
58
- // This ensures Yoga has initial width/height for content layout before presenting.
59
- if (value != null && lastContainerWidth == 0 && lastContainerHeight == 0) {
60
- updateState(viewController.screenWidth, viewController.screenHeight)
60
+ // A preallocated view gets its initial state before the node is committed, so the size
61
+ // update is dropped. Resend until a committed state has it, or Yoga sizes the sheet to its parent.
62
+ if ((value.stateData?.getDouble("containerHeight") ?: 0.0) > 0.0) {
63
+ hasCommittedContainerSize = true
64
+ return
61
65
  }
66
+
67
+ lastContainerWidth = 0
68
+ lastContainerHeight = 0
69
+ updateState(viewController.screenWidth, viewController.screenHeight)
62
70
  }
63
71
 
64
72
  // Debounce flag to coalesce rapid layout changes into a single sheet update
@@ -70,6 +78,14 @@ class TrueSheetView(private val reactContext: ThemedReactContext) :
70
78
  // Screen event observer for react-native-screens integration
71
79
  internal var screensEventObserver: RNScreensEventObserver? = null
72
80
 
81
+ // Present deferred until the keyboard hides; a dismiss before then cancels it
82
+ private var pendingPresentCallback: (() -> Unit)? = null
83
+ private var pendingPresentGeneration = 0
84
+
85
+ // Sheet the deferred present will stack on — dismissing it drops the present, like iOS
86
+ internal var pendingPresentParent: TrueSheetView? = null
87
+ private set
88
+
73
89
  // ==================== Initialization ====================
74
90
 
75
91
  init {
@@ -189,6 +205,7 @@ class TrueSheetView(private val reactContext: ThemedReactContext) :
189
205
 
190
206
  cleanupScreenEventObserver()
191
207
  didInitiallyPresent = false
208
+ cancelPendingPresent()
192
209
 
193
210
  viewController.dismissPromise = { viewController.delegate = null }
194
211
 
@@ -247,6 +264,24 @@ class TrueSheetView(private val reactContext: ThemedReactContext) :
247
264
  }
248
265
  }
249
266
 
267
+ fun setDimColor(color: Int?) {
268
+ val resolved = color ?: Color.BLACK
269
+ if (viewController.dimColor == resolved) return
270
+ viewController.dimColor = resolved
271
+ if (viewController.isPresented) {
272
+ viewController.updateDimAmount()
273
+ }
274
+ }
275
+
276
+ fun setDimOpacity(opacity: Float) {
277
+ val resolved = if (opacity < 0f) TrueSheetViewController.DEFAULT_DIM_OPACITY else opacity.coerceIn(0f, 1f)
278
+ if (viewController.dimOpacity == resolved) return
279
+ viewController.dimOpacity = resolved
280
+ if (viewController.isPresented) {
281
+ viewController.updateDimAmount()
282
+ }
283
+ }
284
+
250
285
  fun setDimmedDetentIndex(index: Int) {
251
286
  if (viewController.dimmedDetentIndex == index) return
252
287
  viewController.dimmedDetentIndex = index
@@ -364,10 +399,12 @@ class TrueSheetView(private val reactContext: ThemedReactContext) :
364
399
 
365
400
  // For stacked sheets on the same screen, inherit parent's presenter screen tag.
366
401
  // If parent was hidden by screen navigation, this sheet is on a different screen.
367
- val parentScreenTag = viewController.parentSheetView?.screensEventObserver?.presenterScreenTag ?: 0
402
+ val parentObserver = viewController.parentSheetView?.screensEventObserver
403
+ val parentScreenTag = parentObserver?.presenterScreenTag ?: 0
368
404
  val parentHiddenByScreen = viewController.parentSheetView?.viewController?.wasHiddenByScreen == true
369
405
  if (parentScreenTag != 0 && !parentHiddenByScreen) {
370
406
  presenterScreenTag = parentScreenTag
407
+ ancestorScreenTags = parentObserver?.ancestorScreenTags ?: emptySet()
371
408
  } else {
372
409
  capturePresenterScreenFromView(this@TrueSheetView)
373
410
  }
@@ -399,7 +436,7 @@ class TrueSheetView(private val reactContext: ThemedReactContext) :
399
436
 
400
437
  @UiThread
401
438
  fun present(detentIndex: Int, animated: Boolean = true, promiseCallback: () -> Unit) {
402
- if (viewController.isPresented) {
439
+ if (viewController.isPresented || pendingPresentCallback != null) {
403
440
  RNLog.w(reactContext, "TrueSheet: sheet is already presented. Use resize() to change detent.")
404
441
  promiseCallback()
405
442
  return
@@ -414,8 +451,18 @@ class TrueSheetView(private val reactContext: ThemedReactContext) :
414
451
  val shouldDismissKeyboard = isFocusedViewWithinSheet || viewController.isDimmedAtDetentIndex(detentIndex)
415
452
  if (shouldDismissKeyboard && KeyboardUtils.isKeyboardVisible(reactContext)) {
416
453
  viewController.saveFocusedView()
454
+ pendingPresentCallback = promiseCallback
455
+ pendingPresentParent = parentSheet
456
+ // A cancelled request's callback can still land (a timer below API 30) — only the latest may present
457
+ val generation = ++pendingPresentGeneration
417
458
  KeyboardUtils.dismiss(this) {
418
- post { presentSheet(detentIndex, animated, promiseCallback) }
459
+ post {
460
+ if (generation != pendingPresentGeneration) return@post
461
+ val callback = pendingPresentCallback ?: return@post
462
+ pendingPresentCallback = null
463
+ pendingPresentParent = null
464
+ presentSheet(detentIndex, animated, callback)
465
+ }
419
466
  }
420
467
  // Blur like iOS so JS focus state stays in sync until restoreFocusedView() refocuses it
421
468
  rootView.findFocus()?.clearFocus()
@@ -447,8 +494,31 @@ class TrueSheetView(private val reactContext: ThemedReactContext) :
447
494
  viewController.handleBackPress()
448
495
  }
449
496
 
497
+ /**
498
+ * Drops a present still waiting on the keyboard and resolves its caller. Returns whether one was pending.
499
+ * Skip [restoreFocus] when the focused view is going away with its dismissing sheet.
500
+ */
501
+ fun cancelPendingPresent(restoreFocus: Boolean = true): Boolean {
502
+ val callback = pendingPresentCallback ?: return false
503
+ pendingPresentCallback = null
504
+ pendingPresentParent = null
505
+ if (restoreFocus) {
506
+ viewController.restoreFocusedView()
507
+ } else {
508
+ viewController.clearFocusedView()
509
+ }
510
+ callback()
511
+ return true
512
+ }
513
+
450
514
  @UiThread
451
515
  fun dismiss(animated: Boolean = true, promiseCallback: () -> Unit) {
516
+ // Dismissing before a deferred present starts cancels it — nothing was presented
517
+ if (cancelPendingPresent()) {
518
+ promiseCallback()
519
+ return
520
+ }
521
+
452
522
  if (viewController.isBeingDismissed || !viewController.isPresented) {
453
523
  RNLog.w(reactContext, "TrueSheet: sheet is already dismissed. No need to dismiss it again.")
454
524
  promiseCallback()
@@ -471,6 +541,9 @@ class TrueSheetView(private val reactContext: ThemedReactContext) :
471
541
  return
472
542
  }
473
543
 
544
+ // A present waiting on the keyboard would stack above this sheet — drop it with the rest
545
+ TrueSheetModule.cancelPendingPresents(parent = this)
546
+
474
547
  val sheetsAbove = TrueSheetStackManager.getSheetsAbove(this)
475
548
  // Create snapshot only for topmost sheet (first in reversed list)
476
549
  sheetsAbove.firstOrNull()?.viewController?.createSheetSnapshot()
@@ -599,6 +672,9 @@ class TrueSheetView(private val reactContext: ThemedReactContext) :
599
672
  // ==================== TrueSheetViewControllerDelegate ====================
600
673
 
601
674
  override fun viewControllerWillPresent(index: Int, position: Float, detent: Float) {
675
+ // Observe from the start so navigating away mid-present still hides the sheet
676
+ setupScreenEventObserver()
677
+
602
678
  // Update parent sheet translation now that content is measured
603
679
  TrueSheetStackManager.updateParentTranslation(this)
604
680
 
@@ -607,13 +683,14 @@ class TrueSheetView(private val reactContext: ThemedReactContext) :
607
683
  }
608
684
 
609
685
  override fun viewControllerDidPresent(index: Int, position: Float, detent: Float) {
610
- setupScreenEventObserver()
611
-
612
686
  val surfaceId = UIManagerHelper.getSurfaceId(this)
613
687
  eventDispatcher?.dispatchEvent(DidPresentEvent(surfaceId, id, index, position, detent))
614
688
  }
615
689
 
616
690
  override fun viewControllerWillDismiss() {
691
+ // Covers every dismiss path (swipe, back press, stack); the focused view leaves with this sheet
692
+ TrueSheetModule.cancelPendingPresents(parent = this, restoreFocus = false)
693
+
617
694
  val surfaceId = UIManagerHelper.getSurfaceId(this)
618
695
  eventDispatcher?.dispatchEvent(WillDismissEvent(surfaceId, id))
619
696
  }
@@ -635,6 +712,9 @@ class TrueSheetView(private val reactContext: ThemedReactContext) :
635
712
 
636
713
  TrueSheetStackManager.unregisterSheet(this)
637
714
 
715
+ // The sheets below stop blending this sheet's dim
716
+ parent?.viewController?.takeIf { it.isPresented }?.updateDimAmount()
717
+
638
718
  parent?.resetTranslation {
639
719
  val parentController = parent.viewController
640
720
  if (parentController.isPresented && !parentController.isBeingDismissed) {
@@ -3,6 +3,7 @@ package com.lodev09.truesheet
3
3
  import android.animation.ValueAnimator
4
4
  import android.annotation.SuppressLint
5
5
  import android.graphics.Canvas
6
+ import android.graphics.Color
6
7
  import android.os.Build
7
8
  import android.view.MotionEvent
8
9
  import android.view.View
@@ -11,6 +12,7 @@ import android.view.accessibility.AccessibilityEvent
11
12
  import android.view.accessibility.AccessibilityNodeInfo
12
13
  import android.widget.ImageView
13
14
  import androidx.coordinatorlayout.widget.CoordinatorLayout
15
+ import androidx.core.graphics.ColorUtils
14
16
  import androidx.core.graphics.createBitmap
15
17
  import androidx.core.view.isNotEmpty
16
18
  import com.facebook.react.R
@@ -43,6 +45,7 @@ import com.lodev09.truesheet.utils.Insets
43
45
  import com.lodev09.truesheet.utils.KeyboardUtils
44
46
  import com.lodev09.truesheet.utils.ScreenUtils
45
47
  import com.lodev09.truesheet.utils.TouchEventDeduper
48
+ import kotlin.math.roundToInt
46
49
 
47
50
  // =============================================================================
48
51
  // MARK: - Data Types & Delegate Protocol
@@ -122,6 +125,7 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
122
125
  TrueSheetBottomSheetViewDelegate {
123
126
 
124
127
  companion object {
128
+ const val DEFAULT_DIM_OPACITY = 0.5f
125
129
  private const val DEFAULT_MAX_WIDTH = 640 // dp
126
130
  private const val DEFAULT_CORNER_RADIUS = 16 // dp
127
131
  private const val DEFAULT_PLACEMENT_OFFSET = 16 // dp
@@ -171,6 +175,9 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
171
175
  // animation doesn't snap the sheet back to the stale currentDetentIndex.
172
176
  private var pendingDetentIndex: Int = -1
173
177
 
178
+ private val isSettlingInPlace: Boolean
179
+ get() = !isBeingDismissed && pendingDetentIndex >= 0 && pendingDetentIndex == currentDetentIndex
180
+
174
181
  private var interactionState: InteractionState = InteractionState.Idle
175
182
 
176
183
  // A content/header/footer size change arrived mid-drag — reconfiguring then
@@ -190,6 +197,12 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
190
197
  private var detentIndexBeforeKeyboard: Int = -1
191
198
  private var isKeyboardDismissProgrammatic = false
192
199
 
200
+ // The keyboard hid under a touch (e.g. keyboardDismissMode="on-drag") — the
201
+ // keyboard stops stay until it lifts. Settling or moving the stops under a
202
+ // scrolling finger makes the behavior's nested pre-scroll snap the sheet and
203
+ // jump the content.
204
+ private var isKeyboardSettleDeferred = false
205
+
193
206
  // Sheet top when a keyboard-floor drag was released; -1 once the settle target is known
194
207
  private var keyboardSettleStartTop = -1
195
208
  private var focusedViewBeforeBlur: View? = null
@@ -226,6 +239,9 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
226
239
  // Appearance Configuration
227
240
  var dimmed = true
228
241
  var dimmedDetentIndex = 0
242
+ var dimColor: Int = Color.BLACK
243
+ var dimOpacity: Float = DEFAULT_DIM_OPACITY
244
+ private var dimProgress = 0f
229
245
  override var grabber: Boolean = true
230
246
  override var grabberOptions: GrabberOptions? = null
231
247
  override var accessibilityOptions: AccessibilityOptions? = null
@@ -473,6 +489,7 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
473
489
  detentIndexBeforeKeyboard = -1
474
490
  pendingDetentIndex = -1
475
491
  isKeyboardDismissProgrammatic = false
492
+ isKeyboardSettleDeferred = false
476
493
  focusedViewBeforeBlur = null
477
494
  shouldAnimatePresent = true
478
495
  }
@@ -552,6 +569,12 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
552
569
  delegate?.viewControllerDidAttemptDismiss()
553
570
  }
554
571
 
572
+ override fun coordinatorLayoutDidEndTouch() {
573
+ if (!isKeyboardSettleDeferred) return
574
+ isKeyboardSettleDeferred = false
575
+ setupSheetDetentsForSizeChange()
576
+ }
577
+
555
578
  // =============================================================================
556
579
  // MARK: - TrueSheetDimViewDelegate
557
580
  // =============================================================================
@@ -640,7 +663,8 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
640
663
  return
641
664
  }
642
665
 
643
- if (!isPresented) return
666
+ // The slide-out owns the sheet now — a present settle landing mid-dismiss must not finish the present
667
+ if (!isPresented || isBeingDismissed) return
644
668
 
645
669
  when (newState) {
646
670
  BottomSheetBehavior.STATE_DRAGGING -> handleDragBegin(sheetView)
@@ -968,6 +992,9 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
968
992
  return
969
993
  }
970
994
 
995
+ // Bring the parent back alongside the slide-out (like iOS) instead of after it
996
+ parentSheetView?.resetTranslation()
997
+
971
998
  sheet.animate()
972
999
  .y(realScreenHeight.toFloat())
973
1000
  .setDuration(DISMISS_DURATION)
@@ -1101,8 +1128,9 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
1101
1128
 
1102
1129
  fun setupSheetDetentsForSizeChange() {
1103
1130
  // Reconfiguring while the container tracks the sheet can interrupt its
1104
- // motion. Apply content size changes once the drag or resize settles.
1105
- if (interactionState is InteractionState.Dragging || isSettling) {
1131
+ // motion. Apply content size changes once the drag or resize settles, or
1132
+ // the touch holding a deferred keyboard settle lifts.
1133
+ if (interactionState is InteractionState.Dragging || isSettling || isKeyboardSettleDeferred) {
1106
1134
  hasPendingSizeChange = true
1107
1135
  return
1108
1136
  }
@@ -1156,6 +1184,7 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
1156
1184
  if (dimView == null) {
1157
1185
  dimView = TrueSheetDimView(reactContext).apply {
1158
1186
  delegate = this@TrueSheetViewController
1187
+ setDimColor(dimColor)
1159
1188
  }
1160
1189
  }
1161
1190
  if (!parentDimVisible) {
@@ -1169,6 +1198,7 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
1169
1198
  if (parentDimView == null) {
1170
1199
  parentDimView = TrueSheetDimView(reactContext).apply {
1171
1200
  delegate = this@TrueSheetViewController
1201
+ setDimColor(dimColor)
1172
1202
  }
1173
1203
  }
1174
1204
  parentDimView?.attach(parentBottomSheet, parentController.sheetCornerRadius)
@@ -1182,25 +1212,57 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
1182
1212
  }
1183
1213
 
1184
1214
  fun updateDimAmount(sheetTop: Int? = null, animated: Boolean = false) {
1185
- if (!dimmed) return
1186
-
1187
- // While keyboard is active or transitioning, use the target detent position for dim
1188
- val top = if (isKeyboardActive) {
1189
- detentCalculator.getSheetTopForDetentIndex(currentDetentIndex)
1190
- } else {
1191
- val keyboardOffset = if (isBeingDismissed) 0 else currentKeyboardInset
1192
- (sheetTop ?: sheetView?.top ?: return) + keyboardOffset
1193
- }
1215
+ if (dimmed) {
1216
+ // While keyboard is active or transitioning, or settling in place after a content
1217
+ // resize (the detent top moves before the sheet does), use the target detent position
1218
+ val top = if (isKeyboardActive || isSettlingInPlace) {
1219
+ detentCalculator.getSheetTopForDetentIndex(currentDetentIndex)
1220
+ } else {
1221
+ val keyboardOffset = if (isBeingDismissed) 0 else currentKeyboardInset
1222
+ (sheetTop ?: sheetView?.top ?: return) + keyboardOffset
1223
+ }
1194
1224
 
1195
- if (animated) {
1196
- val targetAlpha = dimView?.calculateAlpha(
1225
+ dimProgress = dimView?.calculateProgress(
1197
1226
  top,
1198
1227
  dimmedDetentIndex,
1199
1228
  detentCalculator::getSheetTopForDetentIndex
1200
1229
  ) ?: 0f
1201
- dimViews.forEach { it.animate().alpha(targetAlpha).setDuration(200).start() }
1202
- } else {
1203
- dimViews.forEach { it.interpolateAlpha(top, dimmedDetentIndex, detentCalculator::getSheetTopForDetentIndex) }
1230
+ applyDimAmount(animated)
1231
+ }
1232
+
1233
+ // The sheets below blend this sheet's dim into theirs
1234
+ var parent = parentSheetView?.viewController
1235
+ while (parent != null) {
1236
+ parent.applyDimAmount(animated)
1237
+ parent = parent.parentSheetView?.viewController
1238
+ }
1239
+ }
1240
+
1241
+ /**
1242
+ * Dims by this sheet's progress, blending in the dim of the sheets presented on top by theirs,
1243
+ * so everything below the topmost sheet dims with its color and opacity.
1244
+ */
1245
+ private fun applyDimAmount(animated: Boolean) {
1246
+ if (!dimmed) return
1247
+
1248
+ var color = dimColor
1249
+ var opacity = dimOpacity
1250
+ (delegate as? TrueSheetView)?.let { hostView ->
1251
+ TrueSheetStackManager.getSheetsAbove(hostView).asReversed().forEach {
1252
+ val child = it.viewController
1253
+ if (!child.dimmed) return@forEach
1254
+ val below = (1f - child.dimProgress) * opacity
1255
+ val above = child.dimProgress * child.dimOpacity
1256
+ if (below + above > 0f) color = ColorUtils.blendARGB(color, child.dimColor, above / (below + above))
1257
+ opacity = below + above
1258
+ }
1259
+ }
1260
+
1261
+ // Keep opacity in the color so a transparent dim still receives touches.
1262
+ color = ColorUtils.setAlphaComponent(color, (Color.alpha(color) * opacity).roundToInt())
1263
+ dimViews.forEach {
1264
+ it.setDimColor(color)
1265
+ if (animated) it.animate().alpha(dimProgress).setDuration(200).start() else it.alpha = dimProgress
1204
1266
  }
1205
1267
  }
1206
1268
 
@@ -1272,6 +1334,10 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
1272
1334
  ?: reactContext.currentActivity?.currentFocus
1273
1335
  }
1274
1336
 
1337
+ fun clearFocusedView() {
1338
+ focusedViewBeforeBlur = null
1339
+ }
1340
+
1275
1341
  fun restoreFocusedView() {
1276
1342
  val viewToFocus = focusedViewBeforeBlur ?: return
1277
1343
  focusedViewBeforeBlur = null
@@ -1314,13 +1380,16 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
1314
1380
  return
1315
1381
  }
1316
1382
  val restoring = !isBeingDismissed && detentIndexBeforeKeyboard >= 0
1383
+ if (restoring) {
1384
+ currentDetentIndex = detentIndexBeforeKeyboard
1385
+ }
1317
1386
 
1318
- // Skip reconfigure during interactive keyboard dismiss (e.g. keyboardDismissMode="on-drag")
1319
- // to prevent the sheet from jumping. keyboardDidHide will reconfigure after.
1320
- if (restoring || isKeyboardDismissProgrammatic) {
1321
- if (restoring) {
1322
- currentDetentIndex = detentIndexBeforeKeyboard
1323
- }
1387
+ // A scroll-committed keyboard detent still holds the keyboard-shifted
1388
+ // top too — reconfigure so the sheet settles with the keyboard, once
1389
+ // any touch on the sheet lifts
1390
+ if (!isBeingDismissed && coordinatorLayout?.isTouchingSheet == true) {
1391
+ isKeyboardSettleDeferred = true
1392
+ } else if (!isBeingDismissed || isKeyboardDismissProgrammatic) {
1324
1393
  setupSheetDetents()
1325
1394
  }
1326
1395
 
@@ -1343,7 +1412,9 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
1343
1412
  return
1344
1413
  }
1345
1414
  detentIndexBeforeKeyboard = -1
1346
- setupSheetDetents(applyState = false)
1415
+ if (!isKeyboardSettleDeferred) {
1416
+ setupSheetDetents(applyState = false)
1417
+ }
1347
1418
  positionFooter()
1348
1419
  updateDimAmount(
1349
1420
  sheetTop = detentCalculator.getSheetTopForDetentIndex(currentDetentIndex),
@@ -1458,6 +1529,11 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
1458
1529
  delegate?.viewControllerWillBlur()
1459
1530
  delegate?.viewControllerWillDismiss()
1460
1531
  parentSheetView?.viewControllerWillFocus()
1532
+
1533
+ // Dismissed mid-present: finishPresent() won't run. Settle the pending present well
1534
+ // before didDismiss so JS clears isPresenting and unmounts the lazy native view
1535
+ presentPromise?.invoke()
1536
+ presentPromise = null
1461
1537
  }
1462
1538
 
1463
1539
  private fun emitDidDismissEvents() {
@@ -1,10 +1,8 @@
1
1
  package com.lodev09.truesheet
2
2
 
3
3
  import android.view.WindowManager
4
- import com.facebook.react.bridge.ColorPropConverter
5
4
  import com.facebook.react.bridge.ReadableArray
6
5
  import com.facebook.react.bridge.ReadableMap
7
- import com.facebook.react.bridge.ReadableType
8
6
  import com.facebook.react.module.annotations.ReactModule
9
7
  import com.facebook.react.uimanager.PixelUtil.dpToPx
10
8
  import com.facebook.react.uimanager.PointerEvents
@@ -20,6 +18,7 @@ import com.facebook.react.viewmanagers.TrueSheetViewManagerInterface
20
18
  import com.lodev09.truesheet.core.AccessibilityOptions
21
19
  import com.lodev09.truesheet.core.GrabberOptions
22
20
  import com.lodev09.truesheet.events.*
21
+ import com.lodev09.truesheet.utils.getColorOrNull
23
22
 
24
23
  /**
25
24
  * ViewManager for TrueSheetView - Fabric architecture
@@ -120,13 +119,7 @@ class TrueSheetViewManager :
120
119
  override fun setDetentBackgrounds(view: TrueSheetView, value: ReadableArray?) {
121
120
  view.setDetentBackgrounds(
122
121
  (0 until (value?.size() ?: 0)).map { index ->
123
- val entry = value?.getMap(index)
124
- if (entry != null && entry.hasKey("color") && !entry.isNull("color")) {
125
- val color = if (entry.getType("color") == ReadableType.Map) entry.getMap("color") else entry.getDouble("color")
126
- ColorPropConverter.getColor(color, view.context)
127
- } else {
128
- null
129
- }
122
+ value?.getMap(index)?.getColorOrNull("color", view.context)
130
123
  }
131
124
  )
132
125
  }
@@ -164,7 +157,7 @@ class TrueSheetViewManager :
164
157
  } else {
165
158
  null
166
159
  },
167
- color = if (options.hasKey("color") && !options.isNull("color")) options.getInt("color") else null,
160
+ color = options.getColorOrNull("color", view.context),
168
161
  adaptive = if (options.hasKey("adaptive")) options.getBoolean("adaptive") else true
169
162
  )
170
163
  view.setGrabberOptions(grabberOptions)
@@ -209,6 +202,16 @@ class TrueSheetViewManager :
209
202
  view.setDimmed(dimmed)
210
203
  }
211
204
 
205
+ @ReactProp(name = "dimColor", customType = "Color")
206
+ override fun setDimColor(view: TrueSheetView, color: Int?) {
207
+ view.setDimColor(color)
208
+ }
209
+
210
+ @ReactProp(name = "dimOpacity", defaultDouble = -1.0)
211
+ override fun setDimOpacity(view: TrueSheetView, opacity: Double) {
212
+ view.setDimOpacity(opacity.toFloat())
213
+ }
214
+
212
215
  @ReactProp(name = "dimmedDetentIndex", defaultInt = 0)
213
216
  override fun setDimmedDetentIndex(view: TrueSheetView, index: Int) {
214
217
  view.setDimmedDetentIndex(index)
@@ -22,6 +22,15 @@ class RNScreensEventObserver : EventDispatcherListener {
22
22
  private var eventDispatcher: EventDispatcher? = null
23
23
  var presenterScreenTag: Int = 0
24
24
 
25
+ // Screens enclosing the presenter. react-native-screens only dispatches lifecycle
26
+ // events for stack screens, so a presenter inside a plain screen container (e.g. a
27
+ // bottom tab) never receives them and an enclosing stack screen has to stand in.
28
+ var ancestorScreenTags: Set<Int> = emptySet()
29
+
30
+ // Only the screen that hid the sheet brings it back. An enclosing screen reappearing
31
+ // must not reveal a sheet whose own screen is still covered inside it.
32
+ private var disappearedScreenTag: Int = 0
33
+
25
34
  fun startObserving(dispatcher: EventDispatcher?) {
26
35
  if (eventDispatcher != null || dispatcher == null) return
27
36
 
@@ -36,24 +45,40 @@ class RNScreensEventObserver : EventDispatcherListener {
36
45
 
37
46
  fun capturePresenterScreenFromView(view: View?) {
38
47
  presenterScreenTag = 0
48
+ val ancestors = mutableSetOf<Int>()
39
49
 
40
50
  var current: View? = view
41
51
  while (current != null) {
42
52
  if (isScreenView(current)) {
43
- presenterScreenTag = current.id
44
- break
53
+ if (presenterScreenTag == 0) {
54
+ presenterScreenTag = current.id
55
+ } else {
56
+ ancestors.add(current.id)
57
+ }
45
58
  }
46
59
  current = (current.parent as? View)
47
60
  }
61
+
62
+ ancestorScreenTags = ancestors
48
63
  }
49
64
 
50
65
  override fun onEventDispatch(event: Event<*>) {
51
- // Only process events for the presenter screen
52
- if (presenterScreenTag == 0 || event.viewTag != presenterScreenTag) return
66
+ // Only process events for the presenter screen and the screens enclosing it
67
+ if (presenterScreenTag == 0) return
68
+ if (event.viewTag != presenterScreenTag && event.viewTag !in ancestorScreenTags) return
53
69
 
54
70
  when (event.eventName) {
55
- "topWillDisappear" -> delegate?.presenterScreenWillDisappear()
56
- "topWillAppear" -> delegate?.presenterScreenWillAppear()
71
+ "topWillDisappear" -> {
72
+ if (disappearedScreenTag != 0) return
73
+ disappearedScreenTag = event.viewTag
74
+ delegate?.presenterScreenWillDisappear()
75
+ }
76
+
77
+ "topWillAppear" -> {
78
+ if (event.viewTag != disappearedScreenTag) return
79
+ disappearedScreenTag = 0
80
+ delegate?.presenterScreenWillAppear()
81
+ }
57
82
  }
58
83
  }
59
84