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

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 (88) hide show
  1. package/README.md +2 -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 +12 -1
  4. package/android/src/main/java/com/lodev09/truesheet/TrueSheetView.kt +120 -19
  5. package/android/src/main/java/com/lodev09/truesheet/TrueSheetViewController.kt +103 -27
  6. package/android/src/main/java/com/lodev09/truesheet/TrueSheetViewManager.kt +18 -10
  7. package/android/src/main/java/com/lodev09/truesheet/core/RNScreensEventObserver.kt +31 -14
  8. package/android/src/main/java/com/lodev09/truesheet/core/TrueSheetBottomSheetBehavior.kt +9 -0
  9. package/android/src/main/java/com/lodev09/truesheet/core/TrueSheetBottomSheetView.kt +3 -16
  10. package/android/src/main/java/com/lodev09/truesheet/core/TrueSheetCoordinatorLayout.kt +14 -1
  11. package/android/src/main/java/com/lodev09/truesheet/core/TrueSheetDimView.kt +11 -17
  12. package/android/src/main/java/com/lodev09/truesheet/core/TrueSheetGrabberView.kt +2 -4
  13. package/android/src/main/java/com/lodev09/truesheet/core/TrueSheetStackManager.kt +29 -38
  14. package/android/src/main/java/com/lodev09/truesheet/utils/ColorUtils.kt +19 -0
  15. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetContentViewShadowNode.cpp +6 -2
  16. package/ios/TrueSheetContainerView.h +1 -0
  17. package/ios/TrueSheetContainerView.mm +6 -0
  18. package/ios/TrueSheetContentView.h +1 -0
  19. package/ios/TrueSheetContentView.mm +2 -0
  20. package/ios/TrueSheetFooterView.mm +5 -0
  21. package/ios/TrueSheetModule.mm +11 -2
  22. package/ios/TrueSheetView.h +2 -0
  23. package/ios/TrueSheetView.mm +62 -9
  24. package/ios/TrueSheetViewController.h +14 -0
  25. package/ios/TrueSheetViewController.mm +490 -33
  26. package/ios/core/RNScreensEventObserver.mm +18 -1
  27. package/ios/core/TrueSheetDetentCalculator.h +11 -0
  28. package/ios/core/TrueSheetDetentCalculator.mm +17 -5
  29. package/ios/core/TrueSheetDimView.h +27 -0
  30. package/ios/core/TrueSheetDimView.mm +41 -0
  31. package/ios/core/TrueSheetKeyboardObserver.mm +98 -15
  32. package/lib/module/TrueSheet.js +88 -17
  33. package/lib/module/TrueSheet.js.map +1 -1
  34. package/lib/module/TrueSheet.web.js +150 -37
  35. package/lib/module/TrueSheet.web.js.map +1 -1
  36. package/lib/module/TrueSheetProvider.web.js +13 -9
  37. package/lib/module/TrueSheetProvider.web.js.map +1 -1
  38. package/lib/module/fabric/TrueSheetViewNativeComponent.ts +8 -0
  39. package/lib/module/scrollable.js +28 -0
  40. package/lib/module/scrollable.js.map +1 -0
  41. package/lib/module/web/keyboard.js +69 -0
  42. package/lib/module/web/keyboard.js.map +1 -0
  43. package/lib/module/web/layout.js +1 -1
  44. package/lib/module/web/layout.js.map +1 -1
  45. package/lib/module/web/vaul/context.js.map +1 -1
  46. package/lib/module/web/vaul/index.js +18 -8
  47. package/lib/module/web/vaul/index.js.map +1 -1
  48. package/lib/module/web/vaul/use-snap-points.js +15 -10
  49. package/lib/module/web/vaul/use-snap-points.js.map +1 -1
  50. package/lib/typescript/src/TrueSheet.d.ts +14 -1
  51. package/lib/typescript/src/TrueSheet.d.ts.map +1 -1
  52. package/lib/typescript/src/TrueSheet.types.d.ts +53 -2
  53. package/lib/typescript/src/TrueSheet.types.d.ts.map +1 -1
  54. package/lib/typescript/src/TrueSheet.web.d.ts.map +1 -1
  55. package/lib/typescript/src/TrueSheetProvider.web.d.ts +5 -1
  56. package/lib/typescript/src/TrueSheetProvider.web.d.ts.map +1 -1
  57. package/lib/typescript/src/fabric/TrueSheetViewNativeComponent.d.ts +7 -0
  58. package/lib/typescript/src/fabric/TrueSheetViewNativeComponent.d.ts.map +1 -1
  59. package/lib/typescript/src/navigation/types.d.ts +1 -1
  60. package/lib/typescript/src/navigation/types.d.ts.map +1 -1
  61. package/lib/typescript/src/scrollable.d.ts +2 -0
  62. package/lib/typescript/src/scrollable.d.ts.map +1 -0
  63. package/lib/typescript/src/web/keyboard.d.ts +15 -0
  64. package/lib/typescript/src/web/keyboard.d.ts.map +1 -0
  65. package/lib/typescript/src/web/layout.d.ts +1 -0
  66. package/lib/typescript/src/web/layout.d.ts.map +1 -1
  67. package/lib/typescript/src/web/vaul/context.d.ts +2 -1
  68. package/lib/typescript/src/web/vaul/context.d.ts.map +1 -1
  69. package/lib/typescript/src/web/vaul/index.d.ts +8 -1
  70. package/lib/typescript/src/web/vaul/index.d.ts.map +1 -1
  71. package/lib/typescript/src/web/vaul/types.d.ts +9 -0
  72. package/lib/typescript/src/web/vaul/types.d.ts.map +1 -1
  73. package/lib/typescript/src/web/vaul/use-snap-points.d.ts +3 -2
  74. package/lib/typescript/src/web/vaul/use-snap-points.d.ts.map +1 -1
  75. package/package.json +1 -1
  76. package/src/TrueSheet.tsx +91 -14
  77. package/src/TrueSheet.types.ts +60 -2
  78. package/src/TrueSheet.web.tsx +170 -41
  79. package/src/TrueSheetProvider.web.tsx +22 -13
  80. package/src/fabric/TrueSheetViewNativeComponent.ts +8 -0
  81. package/src/navigation/types.ts +3 -0
  82. package/src/scrollable.ts +25 -0
  83. package/src/web/keyboard.ts +79 -0
  84. package/src/web/layout.ts +1 -1
  85. package/src/web/vaul/context.ts +2 -1
  86. package/src/web/vaul/index.tsx +20 -5
  87. package/src/web/vaul/types.ts +10 -0
  88. package/src/web/vaul/use-snap-points.ts +19 -9
package/README.md CHANGED
@@ -34,6 +34,8 @@ 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`
38
+ * 📱 **Foldables** - [Rest the sheet on the hinge](https://sheet.lodev09.com/guides/foldables) of a half-folded iPhone with `foldOptions` (iOS 27.1+)
37
39
 
38
40
  ```sh
39
41
  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,13 +51,23 @@ 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() {
59
- detachRootView()
69
+ viewTreeObserver.removeOnWindowAttachListener(windowAttachListener)
70
+ rootContainerView?.post { if (!isAttachedToWindow) detachRootView() }
60
71
  super.onDetachedFromWindow()
61
72
  }
62
73
 
@@ -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,27 +50,45 @@ 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
65
73
  private var isSheetUpdatePending: Boolean = false
66
74
 
75
+ // didFocus waiting for the slide-back after a child dismissed
76
+ private var isDidFocusPending: Boolean = false
77
+
67
78
  // Root container for the coordinator layout (activity or Modal dialog content view)
68
79
  internal var rootContainerView: ViewGroup? = null
69
80
 
70
81
  // Screen event observer for react-native-screens integration
71
82
  internal var screensEventObserver: RNScreensEventObserver? = null
72
83
 
84
+ // Present deferred until the keyboard hides; a dismiss before then cancels it
85
+ private var pendingPresentCallback: (() -> Unit)? = null
86
+ private var pendingPresentGeneration = 0
87
+
88
+ // Sheet the deferred present will stack on — dismissing it drops the present, like iOS
89
+ internal var pendingPresentParent: TrueSheetView? = null
90
+ private set
91
+
73
92
  // ==================== Initialization ====================
74
93
 
75
94
  init {
@@ -189,6 +208,7 @@ class TrueSheetView(private val reactContext: ThemedReactContext) :
189
208
 
190
209
  cleanupScreenEventObserver()
191
210
  didInitiallyPresent = false
211
+ cancelPendingPresent()
192
212
 
193
213
  viewController.dismissPromise = { viewController.delegate = null }
194
214
 
@@ -247,6 +267,24 @@ class TrueSheetView(private val reactContext: ThemedReactContext) :
247
267
  }
248
268
  }
249
269
 
270
+ fun setDimColor(color: Int?) {
271
+ val resolved = color ?: Color.BLACK
272
+ if (viewController.dimColor == resolved) return
273
+ viewController.dimColor = resolved
274
+ if (viewController.isPresented) {
275
+ viewController.updateDimAmount()
276
+ }
277
+ }
278
+
279
+ fun setDimOpacity(opacity: Float) {
280
+ val resolved = if (opacity < 0f) TrueSheetViewController.DEFAULT_DIM_OPACITY else opacity.coerceIn(0f, 1f)
281
+ if (viewController.dimOpacity == resolved) return
282
+ viewController.dimOpacity = resolved
283
+ if (viewController.isPresented) {
284
+ viewController.updateDimAmount()
285
+ }
286
+ }
287
+
250
288
  fun setDimmedDetentIndex(index: Int) {
251
289
  if (viewController.dimmedDetentIndex == index) return
252
290
  viewController.dimmedDetentIndex = index
@@ -364,10 +402,12 @@ class TrueSheetView(private val reactContext: ThemedReactContext) :
364
402
 
365
403
  // For stacked sheets on the same screen, inherit parent's presenter screen tag.
366
404
  // If parent was hidden by screen navigation, this sheet is on a different screen.
367
- val parentScreenTag = viewController.parentSheetView?.screensEventObserver?.presenterScreenTag ?: 0
405
+ val parentObserver = viewController.parentSheetView?.screensEventObserver
406
+ val parentScreenTag = parentObserver?.presenterScreenTag ?: 0
368
407
  val parentHiddenByScreen = viewController.parentSheetView?.viewController?.wasHiddenByScreen == true
369
408
  if (parentScreenTag != 0 && !parentHiddenByScreen) {
370
409
  presenterScreenTag = parentScreenTag
410
+ ancestorScreenTags = parentObserver?.ancestorScreenTags ?: emptySet()
371
411
  } else {
372
412
  capturePresenterScreenFromView(this@TrueSheetView)
373
413
  }
@@ -399,7 +439,7 @@ class TrueSheetView(private val reactContext: ThemedReactContext) :
399
439
 
400
440
  @UiThread
401
441
  fun present(detentIndex: Int, animated: Boolean = true, promiseCallback: () -> Unit) {
402
- if (viewController.isPresented) {
442
+ if (viewController.isPresented || pendingPresentCallback != null) {
403
443
  RNLog.w(reactContext, "TrueSheet: sheet is already presented. Use resize() to change detent.")
404
444
  promiseCallback()
405
445
  return
@@ -414,8 +454,18 @@ class TrueSheetView(private val reactContext: ThemedReactContext) :
414
454
  val shouldDismissKeyboard = isFocusedViewWithinSheet || viewController.isDimmedAtDetentIndex(detentIndex)
415
455
  if (shouldDismissKeyboard && KeyboardUtils.isKeyboardVisible(reactContext)) {
416
456
  viewController.saveFocusedView()
457
+ pendingPresentCallback = promiseCallback
458
+ pendingPresentParent = parentSheet
459
+ // A cancelled request's callback can still land (a timer below API 30) — only the latest may present
460
+ val generation = ++pendingPresentGeneration
417
461
  KeyboardUtils.dismiss(this) {
418
- post { presentSheet(detentIndex, animated, promiseCallback) }
462
+ post {
463
+ if (generation != pendingPresentGeneration) return@post
464
+ val callback = pendingPresentCallback ?: return@post
465
+ pendingPresentCallback = null
466
+ pendingPresentParent = null
467
+ presentSheet(detentIndex, animated, callback)
468
+ }
419
469
  }
420
470
  // Blur like iOS so JS focus state stays in sync until restoreFocusedView() refocuses it
421
471
  rootView.findFocus()?.clearFocus()
@@ -447,8 +497,31 @@ class TrueSheetView(private val reactContext: ThemedReactContext) :
447
497
  viewController.handleBackPress()
448
498
  }
449
499
 
500
+ /**
501
+ * Drops a present still waiting on the keyboard and resolves its caller. Returns whether one was pending.
502
+ * Skip [restoreFocus] when the focused view is going away with its dismissing sheet.
503
+ */
504
+ fun cancelPendingPresent(restoreFocus: Boolean = true): Boolean {
505
+ val callback = pendingPresentCallback ?: return false
506
+ pendingPresentCallback = null
507
+ pendingPresentParent = null
508
+ if (restoreFocus) {
509
+ viewController.restoreFocusedView()
510
+ } else {
511
+ viewController.clearFocusedView()
512
+ }
513
+ callback()
514
+ return true
515
+ }
516
+
450
517
  @UiThread
451
518
  fun dismiss(animated: Boolean = true, promiseCallback: () -> Unit) {
519
+ // Dismissing before a deferred present starts cancels it — nothing was presented
520
+ if (cancelPendingPresent()) {
521
+ promiseCallback()
522
+ return
523
+ }
524
+
452
525
  if (viewController.isBeingDismissed || !viewController.isPresented) {
453
526
  RNLog.w(reactContext, "TrueSheet: sheet is already dismissed. No need to dismiss it again.")
454
527
  promiseCallback()
@@ -471,6 +544,9 @@ class TrueSheetView(private val reactContext: ThemedReactContext) :
471
544
  return
472
545
  }
473
546
 
547
+ // A present waiting on the keyboard would stack above this sheet — drop it with the rest
548
+ TrueSheetModule.cancelPendingPresents(parent = this)
549
+
474
550
  val sheetsAbove = TrueSheetStackManager.getSheetsAbove(this)
475
551
  // Create snapshot only for topmost sheet (first in reversed list)
476
552
  sheetsAbove.firstOrNull()?.viewController?.createSheetSnapshot()
@@ -537,7 +613,7 @@ class TrueSheetView(private val reactContext: ThemedReactContext) :
537
613
 
538
614
  // Propagate any additional translation up the stack
539
615
  if (additionalTranslation > 0) {
540
- TrueSheetStackManager.getParentSheet(this)?.addTranslation(additionalTranslation)
616
+ viewController.parentSheetView?.addTranslation(additionalTranslation)
541
617
  }
542
618
  }
543
619
 
@@ -569,7 +645,7 @@ class TrueSheetView(private val reactContext: ThemedReactContext) :
569
645
 
570
646
  val additionalTranslation = newTranslation - currentTranslation
571
647
  if (additionalTranslation > 0) {
572
- TrueSheetStackManager.getParentSheet(this)?.addTranslation(additionalTranslation, animated = false)
648
+ viewController.parentSheetView?.addTranslation(additionalTranslation, animated = false)
573
649
  }
574
650
  }
575
651
 
@@ -580,7 +656,7 @@ class TrueSheetView(private val reactContext: ThemedReactContext) :
580
656
  if (viewController.isExpanded) return
581
657
 
582
658
  viewController.translateSheet(viewController.currentTranslationY + amount, animated)
583
- TrueSheetStackManager.getParentSheet(this)?.addTranslation(amount, animated)
659
+ viewController.parentSheetView?.addTranslation(amount, animated)
584
660
  }
585
661
 
586
662
  /**
@@ -593,12 +669,33 @@ class TrueSheetView(private val reactContext: ThemedReactContext) :
593
669
 
594
670
  // Parent should recalculate its translation based on this sheet's position
595
671
  val mySheetTop = viewController.detentCalculator.getSheetTopForDetentIndex(viewController.currentDetentIndex)
596
- TrueSheetStackManager.getParentSheet(this)?.updateTranslationForChild(mySheetTop)
672
+ viewController.parentSheetView?.updateTranslationForChild(mySheetTop)
673
+ }
674
+
675
+ /**
676
+ * Resets translation after a child dismissed and emits didFocus once the sheet settles.
677
+ * A sheet presented on top before then cancels the slide-back, so the next blur flushes it.
678
+ */
679
+ private fun focusAfterResetTranslation() {
680
+ isDidFocusPending = true
681
+ resetTranslation { emitPendingDidFocus() }
682
+ }
683
+
684
+ private fun emitPendingDidFocus() {
685
+ if (!isDidFocusPending) return
686
+ isDidFocusPending = false
687
+
688
+ if (viewController.isPresented && !viewController.isBeingDismissed) {
689
+ viewControllerDidFocus()
690
+ }
597
691
  }
598
692
 
599
693
  // ==================== TrueSheetViewControllerDelegate ====================
600
694
 
601
695
  override fun viewControllerWillPresent(index: Int, position: Float, detent: Float) {
696
+ // Observe from the start so navigating away mid-present still hides the sheet
697
+ setupScreenEventObserver()
698
+
602
699
  // Update parent sheet translation now that content is measured
603
700
  TrueSheetStackManager.updateParentTranslation(this)
604
701
 
@@ -607,13 +704,14 @@ class TrueSheetView(private val reactContext: ThemedReactContext) :
607
704
  }
608
705
 
609
706
  override fun viewControllerDidPresent(index: Int, position: Float, detent: Float) {
610
- setupScreenEventObserver()
611
-
612
707
  val surfaceId = UIManagerHelper.getSurfaceId(this)
613
708
  eventDispatcher?.dispatchEvent(DidPresentEvent(surfaceId, id, index, position, detent))
614
709
  }
615
710
 
616
711
  override fun viewControllerWillDismiss() {
712
+ // Covers every dismiss path (swipe, back press, stack); the focused view leaves with this sheet
713
+ TrueSheetModule.cancelPendingPresents(parent = this, restoreFocus = false)
714
+
617
715
  val surfaceId = UIManagerHelper.getSurfaceId(this)
618
716
  eventDispatcher?.dispatchEvent(WillDismissEvent(surfaceId, id))
619
717
  }
@@ -624,6 +722,9 @@ class TrueSheetView(private val reactContext: ThemedReactContext) :
624
722
  }
625
723
 
626
724
  override fun viewControllerDidDismiss(parent: TrueSheetView?) {
725
+ // A slide-back cut short by this sheet's own dismissal never emits; don't carry it over
726
+ isDidFocusPending = false
727
+
627
728
  // Detach coordinator from the root container view
628
729
  viewController.coordinatorLayout?.let { rootContainerView?.removeView(it) }
629
730
  rootContainerView = null
@@ -635,12 +736,10 @@ class TrueSheetView(private val reactContext: ThemedReactContext) :
635
736
 
636
737
  TrueSheetStackManager.unregisterSheet(this)
637
738
 
638
- parent?.resetTranslation {
639
- val parentController = parent.viewController
640
- if (parentController.isPresented && !parentController.isBeingDismissed) {
641
- parent.viewControllerDidFocus()
642
- }
643
- }
739
+ // The sheets below stop blending this sheet's dim
740
+ parent?.viewController?.takeIf { it.isPresented }?.updateDimAmount()
741
+
742
+ parent?.focusAfterResetTranslation()
644
743
  }
645
744
 
646
745
  override fun viewControllerDidChangeDetent(index: Int, position: Float, detent: Float) {
@@ -683,6 +782,8 @@ class TrueSheetView(private val reactContext: ThemedReactContext) :
683
782
  }
684
783
 
685
784
  override fun viewControllerWillBlur() {
785
+ emitPendingDidFocus()
786
+
686
787
  val surfaceId = UIManagerHelper.getSurfaceId(this)
687
788
  eventDispatcher?.dispatchEvent(WillBlurEvent(surfaceId, id))
688
789
  }
@@ -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)
@@ -862,6 +886,8 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
862
886
  setupSheetDetents()
863
887
  setupDimmedBackground()
864
888
  setupKeyboardObserver()
889
+ // Before the slide, so an input focused in onWillPresent insets the scrollable
890
+ containerView?.setupKeyboardHandler()
865
891
 
866
892
  displayedBackgroundIndex = detentIndex
867
893
  setupBackground(animated = false)
@@ -968,6 +994,9 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
968
994
  return
969
995
  }
970
996
 
997
+ // Bring the parent back alongside the slide-out (like iOS) instead of after it
998
+ parentSheetView?.resetTranslation()
999
+
971
1000
  sheet.animate()
972
1001
  .y(realScreenHeight.toFloat())
973
1002
  .setDuration(DISMISS_DURATION)
@@ -981,11 +1010,9 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
981
1010
  // Restore isHideable to actual value after present animation
982
1011
  behavior?.isHideable = dismissible
983
1012
 
984
- containerView?.setupKeyboardHandler()
985
-
986
1013
  val (index, position, detent) = getDetentInfoWithValue(currentDetentIndex)
987
- delegate?.viewControllerDidPresent(index, position, detent)
988
1014
  parentSheetView?.viewControllerDidBlur()
1015
+ delegate?.viewControllerDidPresent(index, position, detent)
989
1016
  delegate?.viewControllerDidFocus()
990
1017
  sheetView?.updateGrabberAccessibilityValue(index, detents.size)
991
1018
 
@@ -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() {