@lodev09/react-native-true-sheet 3.7.0 → 3.7.2

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 (45) hide show
  1. package/android/src/main/java/com/lodev09/truesheet/TrueSheetModule.kt +25 -0
  2. package/android/src/main/java/com/lodev09/truesheet/TrueSheetView.kt +40 -15
  3. package/android/src/main/java/com/lodev09/truesheet/TrueSheetViewController.kt +84 -14
  4. package/android/src/main/java/com/lodev09/truesheet/core/RNScreensFragmentObserver.kt +26 -1
  5. package/android/src/main/java/com/lodev09/truesheet/core/TrueSheetBottomSheetView.kt +32 -0
  6. package/android/src/main/java/com/lodev09/truesheet/core/TrueSheetGrabberView.kt +26 -10
  7. package/android/src/main/java/com/lodev09/truesheet/core/TrueSheetKeyboardObserver.kt +2 -2
  8. package/android/src/main/java/com/lodev09/truesheet/core/TrueSheetStackManager.kt +23 -0
  9. package/ios/TrueSheetModule.mm +39 -0
  10. package/ios/TrueSheetView.h +4 -0
  11. package/ios/TrueSheetView.mm +49 -0
  12. package/ios/TrueSheetViewController.h +16 -0
  13. package/ios/TrueSheetViewController.mm +16 -0
  14. package/lib/module/TrueSheet.js +10 -0
  15. package/lib/module/TrueSheet.js.map +1 -1
  16. package/lib/module/TrueSheet.web.js +34 -7
  17. package/lib/module/TrueSheet.web.js.map +1 -1
  18. package/lib/module/TrueSheetProvider.js +2 -1
  19. package/lib/module/TrueSheetProvider.js.map +1 -1
  20. package/lib/module/TrueSheetProvider.web.js +24 -1
  21. package/lib/module/TrueSheetProvider.web.js.map +1 -1
  22. package/lib/module/mocks/index.js +3 -1
  23. package/lib/module/mocks/index.js.map +1 -1
  24. package/lib/module/specs/NativeTrueSheetModule.js.map +1 -1
  25. package/lib/typescript/src/TrueSheet.d.ts +7 -0
  26. package/lib/typescript/src/TrueSheet.d.ts.map +1 -1
  27. package/lib/typescript/src/TrueSheet.types.d.ts +4 -0
  28. package/lib/typescript/src/TrueSheet.types.d.ts.map +1 -1
  29. package/lib/typescript/src/TrueSheet.web.d.ts +9 -1
  30. package/lib/typescript/src/TrueSheet.web.d.ts.map +1 -1
  31. package/lib/typescript/src/TrueSheetProvider.d.ts.map +1 -1
  32. package/lib/typescript/src/TrueSheetProvider.web.d.ts +5 -0
  33. package/lib/typescript/src/TrueSheetProvider.web.d.ts.map +1 -1
  34. package/lib/typescript/src/mocks/index.d.ts +1 -0
  35. package/lib/typescript/src/mocks/index.d.ts.map +1 -1
  36. package/lib/typescript/src/specs/NativeTrueSheetModule.d.ts +6 -0
  37. package/lib/typescript/src/specs/NativeTrueSheetModule.d.ts.map +1 -1
  38. package/package.json +1 -1
  39. package/src/TrueSheet.tsx +10 -0
  40. package/src/TrueSheet.types.ts +4 -0
  41. package/src/TrueSheet.web.tsx +50 -6
  42. package/src/TrueSheetProvider.tsx +1 -0
  43. package/src/TrueSheetProvider.web.tsx +26 -0
  44. package/src/mocks/index.ts +2 -0
  45. package/src/specs/NativeTrueSheetModule.ts +7 -0
@@ -95,6 +95,31 @@ class TrueSheetModule(reactContext: ReactApplicationContext) :
95
95
  }
96
96
  }
97
97
 
98
+ /**
99
+ * Dismiss all presented sheets by dismissing from the bottom of the stack
100
+ *
101
+ * @param animated Whether to animate the dismissals
102
+ * @param promise Promise that resolves when all sheets are dismissed
103
+ */
104
+ @ReactMethod
105
+ fun dismissAll(animated: Boolean, promise: Promise) {
106
+ Handler(Looper.getMainLooper()).post {
107
+ try {
108
+ val rootSheet = TrueSheetStackManager.getRootSheet()
109
+ if (rootSheet == null) {
110
+ promise.resolve(null)
111
+ return@post
112
+ }
113
+
114
+ rootSheet.dismissAll(animated) {
115
+ promise.resolve(null)
116
+ }
117
+ } catch (e: Exception) {
118
+ promise.reject("OPERATION_FAILED", "Failed to dismiss all sheets: ${e.message}", e)
119
+ }
120
+ }
121
+ }
122
+
98
123
  /**
99
124
  * Helper method to get TrueSheetView by tag and execute closure
100
125
  */
@@ -3,7 +3,6 @@ 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.ViewStructure
7
6
  import android.view.accessibility.AccessibilityEvent
8
7
  import androidx.annotation.UiThread
9
8
  import com.facebook.react.bridge.LifecycleEventListener
@@ -32,10 +31,6 @@ class TrueSheetView(private val reactContext: ThemedReactContext) :
32
31
  TrueSheetViewControllerDelegate,
33
32
  TrueSheetContainerViewDelegate {
34
33
 
35
- companion object {
36
- const val TAG_NAME = "TrueSheet"
37
- }
38
-
39
34
  // ==================== Properties ====================
40
35
 
41
36
  internal val viewController: TrueSheetViewController = TrueSheetViewController(reactContext)
@@ -109,6 +104,10 @@ class TrueSheetView(private val reactContext: ThemedReactContext) :
109
104
  }
110
105
 
111
106
  override fun addView(child: View?, index: Int) {
107
+ if (child is TrueSheetContainerView) {
108
+ viewController.removeSheetSnapshot()
109
+ }
110
+
112
111
  viewController.addView(child, index)
113
112
 
114
113
  if (child is TrueSheetContainerView) {
@@ -128,10 +127,11 @@ class TrueSheetView(private val reactContext: ThemedReactContext) :
128
127
  val child = getChildAt(index)
129
128
  if (child is TrueSheetContainerView) {
130
129
  child.delegate = null
130
+ viewController.createSheetSnapshot()
131
131
 
132
- // Dismiss the sheet when container is removed
132
+ // Dismiss when container is removed
133
133
  if (viewController.isPresented) {
134
- viewController.dismiss(animated = false)
134
+ dismissAll(true) {}
135
135
  }
136
136
  }
137
137
  viewController.removeView(child)
@@ -157,13 +157,19 @@ class TrueSheetView(private val reactContext: ThemedReactContext) :
157
157
  fun onDropInstance() {
158
158
  reactContext.removeLifecycleEventListener(this)
159
159
 
160
- viewController.dismiss()
161
- viewController.delegate = null
162
-
163
160
  TrueSheetModule.unregisterView(id)
164
161
  TrueSheetStackManager.removeSheet(this)
165
162
 
166
163
  didInitiallyPresent = false
164
+
165
+ if (viewController.isPresented) {
166
+ viewController.dismissPromise = {
167
+ viewController.delegate = null
168
+ }
169
+ viewController.dismiss()
170
+ } else {
171
+ viewController.delegate = null
172
+ }
167
173
  }
168
174
 
169
175
  /**
@@ -189,7 +195,7 @@ class TrueSheetView(private val reactContext: ThemedReactContext) :
189
195
  if (viewController.dimmed == dimmed) return
190
196
  viewController.dimmed = dimmed
191
197
  if (viewController.isPresented) {
192
- viewController.setupDimmedBackground(viewController.currentDetentIndex)
198
+ viewController.setupDimmedBackground()
193
199
  viewController.updateDimAmount()
194
200
  }
195
201
  }
@@ -198,7 +204,7 @@ class TrueSheetView(private val reactContext: ThemedReactContext) :
198
204
  if (viewController.dimmedDetentIndex == index) return
199
205
  viewController.dimmedDetentIndex = index
200
206
  if (viewController.isPresented) {
201
- viewController.setupDimmedBackground(viewController.currentDetentIndex)
207
+ viewController.setupDimmedBackground()
202
208
  viewController.updateDimAmount()
203
209
  }
204
210
  }
@@ -310,6 +316,16 @@ class TrueSheetView(private val reactContext: ThemedReactContext) :
310
316
  viewController.dismiss(animated)
311
317
  }
312
318
 
319
+ @UiThread
320
+ fun dismissAll(animated: Boolean = true, promiseCallback: () -> Unit) {
321
+ // Dismiss all sheets above first
322
+ dismiss(animated) {}
323
+
324
+ // Then dismiss itself
325
+ viewController.dismissPromise = promiseCallback
326
+ viewController.dismiss(animated)
327
+ }
328
+
313
329
  @UiThread
314
330
  fun resize(detentIndex: Int, promiseCallback: () -> Unit) {
315
331
  if (!viewController.isPresented) {
@@ -326,12 +342,13 @@ class TrueSheetView(private val reactContext: ThemedReactContext) :
326
342
  * Uses post() to ensure all layout passes complete before reconfiguring.
327
343
  */
328
344
  fun updateSheetIfNeeded() {
329
- if (!viewController.isPresented) return
330
- if (isSheetUpdatePending) return
345
+ if (!viewController.isPresented || isSheetUpdatePending) return
331
346
 
332
347
  isSheetUpdatePending = true
333
348
  viewController.post {
334
349
  isSheetUpdatePending = false
350
+ if (viewController.containerView == null) return@post
351
+
335
352
  viewController.setupSheetDetentsForSizeChange()
336
353
  TrueSheetStackManager.onSheetSizeChanged(this)
337
354
  }
@@ -466,6 +483,14 @@ class TrueSheetView(private val reactContext: ThemedReactContext) :
466
483
  eventDispatcher?.dispatchEvent(BackPressEvent(surfaceId, id))
467
484
  }
468
485
 
486
+ override fun viewControllerDidDetectScreenDisappear() {
487
+ dismissAll(animated = true) {}
488
+ }
489
+
490
+ override fun viewControllerDidDetectScreenDismiss() {
491
+ resetTranslation()
492
+ }
493
+
469
494
  // ==================== TrueSheetContainerViewDelegate ====================
470
495
 
471
496
  override fun containerViewContentDidChangeSize(width: Int, height: Int) {
@@ -489,7 +514,7 @@ class TrueSheetView(private val reactContext: ThemedReactContext) :
489
514
  * of whichever window this view is in - whether that's the activity's window or a
490
515
  * Modal's dialog window.
491
516
  */
492
- private fun findRootContainerView(): ViewGroup? {
517
+ override fun findRootContainerView(): ViewGroup? {
493
518
  var current: android.view.ViewParent? = parent
494
519
 
495
520
  while (current != null) {
@@ -1,16 +1,17 @@
1
1
  package com.lodev09.truesheet
2
2
 
3
3
  import android.annotation.SuppressLint
4
+ import android.graphics.Canvas
4
5
  import android.os.Build
5
6
  import android.view.MotionEvent
6
7
  import android.view.View
7
8
  import android.view.ViewGroup
8
9
  import android.view.accessibility.AccessibilityNodeInfo
10
+ import android.widget.ImageView
9
11
  import androidx.activity.OnBackPressedCallback
10
12
  import androidx.appcompat.app.AppCompatActivity
11
- import androidx.coordinatorlayout.widget.CoordinatorLayout
13
+ import androidx.core.graphics.createBitmap
12
14
  import androidx.core.view.isNotEmpty
13
- import androidx.core.view.isVisible
14
15
  import com.facebook.react.R
15
16
  import com.facebook.react.uimanager.JSPointerDispatcher
16
17
  import com.facebook.react.uimanager.JSTouchDispatcher
@@ -46,6 +47,7 @@ data class DetentInfo(val index: Int, val position: Float)
46
47
 
47
48
  interface TrueSheetViewControllerDelegate {
48
49
  val eventDispatcher: EventDispatcher?
50
+ fun findRootContainerView(): ViewGroup?
49
51
  fun viewControllerWillPresent(index: Int, position: Float, detent: Float)
50
52
  fun viewControllerDidPresent(index: Int, position: Float, detent: Float)
51
53
  fun viewControllerWillDismiss()
@@ -61,6 +63,8 @@ interface TrueSheetViewControllerDelegate {
61
63
  fun viewControllerWillBlur()
62
64
  fun viewControllerDidBlur()
63
65
  fun viewControllerDidBackPress()
66
+ fun viewControllerDidDetectScreenDisappear()
67
+ fun viewControllerDidDetectScreenDismiss()
64
68
  }
65
69
 
66
70
  // =============================================================================
@@ -84,8 +88,6 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
84
88
  TrueSheetBottomSheetViewDelegate {
85
89
 
86
90
  companion object {
87
- const val TAG_NAME = "TrueSheet"
88
-
89
91
  private const val DEFAULT_MAX_WIDTH = 640 // dp
90
92
  private const val DEFAULT_CORNER_RADIUS = 16 // dp
91
93
  private const val TRANSLATE_ANIMATION_DURATION = 200L
@@ -130,7 +132,7 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
130
132
 
131
133
  private var interactionState: InteractionState = InteractionState.Idle
132
134
  private var isDismissing = false
133
- private var wasHiddenByModal = false
135
+ internal var wasHiddenByModal = false
134
136
  private var shouldAnimatePresent = false
135
137
  private var isPresentAnimating = false
136
138
 
@@ -213,7 +215,7 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
213
215
  private val behavior: BottomSheetBehavior<TrueSheetBottomSheetView>?
214
216
  get() = sheetView?.behavior
215
217
 
216
- private val containerView: TrueSheetContainerView?
218
+ internal val containerView: TrueSheetContainerView?
217
219
  get() = if (this.isNotEmpty()) getChildAt(0) as? TrueSheetContainerView else null
218
220
 
219
221
  // Screen Measurements
@@ -228,11 +230,15 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
228
230
  get() = ScreenUtils.getRealScreenHeight(reactContext)
229
231
 
230
232
  // Content Measurements
233
+ // Cached values used during dismiss when container is unmounted
234
+ private var cachedContentHeight: Int = 0
235
+ private var cachedHeaderHeight: Int = 0
236
+
231
237
  override val contentHeight: Int
232
- get() = containerView?.contentHeight ?: 0
238
+ get() = containerView?.contentHeight ?: cachedContentHeight
233
239
 
234
240
  override val headerHeight: Int
235
- get() = containerView?.headerHeight ?: 0
241
+ get() = containerView?.headerHeight ?: cachedHeaderHeight
236
242
 
237
243
  // Insets
238
244
  // Target keyboard height used for detent calculations
@@ -260,9 +266,10 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
260
266
  override val contentBottomInset: Int
261
267
  get() = if (insetAdjustment == "automatic") bottomInset else 0
262
268
 
269
+ @Suppress("KotlinConstantConditions", "SimplifyBooleanWithConstants")
263
270
  private val edgeToEdgeEnabled: Boolean
264
271
  get() {
265
- val defaultEnabled = android.os.Build.VERSION.SDK_INT >= 36
272
+ val defaultEnabled = Build.VERSION.SDK_INT >= 36
266
273
  return BuildConfig.EDGE_TO_EDGE_ENABLED || defaultEnabled
267
274
  }
268
275
 
@@ -320,6 +327,9 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
320
327
  parentDimView?.detach()
321
328
  parentDimView = null
322
329
 
330
+ // Cleanup snapshot
331
+ removeSheetSnapshot()
332
+
323
333
  // Detach content from sheet
324
334
  sheetView?.removeView(this)
325
335
 
@@ -331,6 +341,8 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
331
341
  isPresented = false
332
342
  isSheetVisible = false
333
343
  wasHiddenByModal = false
344
+ cachedContentHeight = 0
345
+ cachedHeaderHeight = 0
334
346
  isPresentAnimating = false
335
347
  lastEmittedPositionPx = -1
336
348
  detentIndexBeforeKeyboard = -1
@@ -338,6 +350,34 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
338
350
  shouldAnimatePresent = true
339
351
  }
340
352
 
353
+ private var snapshotView: View? = null
354
+
355
+ fun createSheetSnapshot() {
356
+ if (!isPresented) return
357
+ val sheet = sheetView ?: return
358
+ if (sheet.width <= 0 || sheet.height <= 0) return
359
+
360
+ val bitmap = createBitmap(sheet.width, sheet.height)
361
+ val canvas = Canvas(bitmap)
362
+ sheet.draw(canvas)
363
+
364
+ val snapshot = ImageView(reactContext).apply {
365
+ setImageBitmap(bitmap)
366
+ layoutParams = LayoutParams(sheet.width, sheet.height)
367
+ }
368
+
369
+ sheet.addView(snapshot, 0)
370
+ snapshotView = snapshot
371
+ }
372
+
373
+ fun removeSheetSnapshot() {
374
+ snapshotView?.let {
375
+ (it as? ImageView)?.setImageDrawable(null)
376
+ sheetView?.removeView(it)
377
+ }
378
+ snapshotView = null
379
+ }
380
+
341
381
  // =============================================================================
342
382
  // MARK: - Back Button Handling
343
383
  // =============================================================================
@@ -405,6 +445,19 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
405
445
  dismissOrCollapseToLowest()
406
446
  }
407
447
 
448
+ // =============================================================================
449
+ // MARK: - TrueSheetBottomSheetViewDelegate - Grabber Tap
450
+ // =============================================================================
451
+
452
+ override fun bottomSheetViewDidTapGrabber() {
453
+ val nextIndex = (currentDetentIndex + 1) % detents.size
454
+ if (nextIndex == 0 && detents.size == 1 && dismissible) {
455
+ dismiss(animated = true)
456
+ } else {
457
+ setStateForDetentIndex(nextIndex)
458
+ }
459
+ }
460
+
408
461
  // =============================================================================
409
462
  // MARK: - BottomSheetCallback
410
463
  // =============================================================================
@@ -489,7 +542,7 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
489
542
 
490
543
  if (detentInfo.index != currentDetentIndex) {
491
544
  currentDetentIndex = detentInfo.index
492
- setupDimmedBackground(detentInfo.index)
545
+ setupDimmedBackground()
493
546
  delegate?.viewControllerDidChangeDetent(detentInfo.index, detentInfo.position, detent)
494
547
  }
495
548
 
@@ -524,6 +577,7 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
524
577
  onModalWillDismiss = {
525
578
  if (isPresented && wasHiddenByModal && isTopmostSheet) {
526
579
  showAfterModal()
580
+ delegate?.viewControllerDidDetectScreenDismiss()
527
581
  }
528
582
  },
529
583
  onModalDidDismiss = {
@@ -534,6 +588,12 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
534
588
  post { parent.showAfterModal() }
535
589
  }
536
590
  }
591
+ },
592
+ onNonModalScreenPushed = {
593
+ // Only handle on root sheet (no parent) to trigger dismissAll
594
+ if (isPresented && isSheetVisible && parentSheetView == null) {
595
+ delegate?.viewControllerDidDetectScreenDisappear()
596
+ }
537
597
  }
538
598
  )
539
599
  rnScreensObserver?.start()
@@ -603,7 +663,7 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
603
663
  }
604
664
 
605
665
  if (isPresented) {
606
- setupDimmedBackground(detentIndex)
666
+ setupDimmedBackground()
607
667
  setStateForDetentIndex(detentIndex)
608
668
  } else {
609
669
  shouldAnimatePresent = animated
@@ -616,7 +676,7 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
616
676
  emitWillPresentEvents()
617
677
 
618
678
  setupSheetDetents()
619
- setupDimmedBackground(currentDetentIndex)
679
+ setupDimmedBackground()
620
680
  setupKeyboardObserver()
621
681
  setupModalObserver()
622
682
  setupBackCallback()
@@ -647,6 +707,8 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
647
707
 
648
708
  // Create layout params with behavior
649
709
  val params = sheet.createLayoutParams()
710
+
711
+ @Suppress("UNCHECKED_CAST")
650
712
  val behavior = params.behavior as BottomSheetBehavior<TrueSheetBottomSheetView>
651
713
 
652
714
  // Configure behavior
@@ -730,6 +792,11 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
730
792
  return
731
793
  }
732
794
 
795
+ containerView?.let {
796
+ cachedContentHeight = it.contentHeight
797
+ cachedHeaderHeight = it.headerHeight
798
+ }
799
+
733
800
  interactionState = InteractionState.Reconfiguring
734
801
 
735
802
  behavior.isFitToContents = false
@@ -801,7 +868,7 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
801
868
  // MARK: - Dimmed Background
802
869
  // =============================================================================
803
870
 
804
- fun setupDimmedBackground(detentIndex: Int) {
871
+ fun setupDimmedBackground() {
805
872
  val coordinator = this.coordinatorLayout ?: run {
806
873
  RNLog.e(reactContext, "TrueSheet: coordinatorLayout is null in setupDimmedBackground")
807
874
  return
@@ -840,6 +907,8 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
840
907
 
841
908
  fun updateDimAmount(sheetTop: Int? = null, animated: Boolean = false) {
842
909
  if (!dimmed) return
910
+ if (contentHeight == 0) return
911
+
843
912
  val keyboardOffset = if (isDismissing) 0 else currentKeyboardInset
844
913
  val top = (sheetTop ?: sheetView?.top ?: return) + keyboardOffset
845
914
 
@@ -892,7 +961,8 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
892
961
  }
893
962
 
894
963
  fun saveFocusedView() {
895
- focusedViewBeforeBlur = reactContext.currentActivity?.currentFocus
964
+ focusedViewBeforeBlur = delegate?.findRootContainerView()?.findFocus()
965
+ ?: reactContext.currentActivity?.currentFocus
896
966
  }
897
967
 
898
968
  fun restoreFocusedView() {
@@ -18,7 +18,8 @@ class RNScreensFragmentObserver(
18
18
  private val reactContext: ReactContext,
19
19
  private val onModalPresented: () -> Unit,
20
20
  private val onModalWillDismiss: () -> Unit,
21
- private val onModalDidDismiss: () -> Unit
21
+ private val onModalDidDismiss: () -> Unit,
22
+ private val onNonModalScreenPushed: () -> Unit
22
23
  ) {
23
24
  private var fragmentLifecycleCallback: FragmentManager.FragmentLifecycleCallbacks? = null
24
25
  private var activityLifecycleObserver: DefaultLifecycleObserver? = null
@@ -61,6 +62,9 @@ class RNScreensFragmentObserver(
61
62
  if (activeModalFragments.size == 1) {
62
63
  onModalPresented()
63
64
  }
65
+ } else if (activeModalFragments.isEmpty() && isNonModalScreenFragment(f)) {
66
+ // Only trigger non-modal push when no modals are active
67
+ onNonModalScreenPushed()
64
68
  }
65
69
  }
66
70
 
@@ -145,6 +149,27 @@ class RNScreensFragmentObserver(
145
149
  */
146
150
  private fun isScreensFragment(fragment: Fragment): Boolean = fragment.javaClass.name.startsWith(RN_SCREENS_PACKAGE)
147
151
 
152
+ /**
153
+ * Check if fragment is a non-modal screen (regular push presentation).
154
+ */
155
+ private fun isNonModalScreenFragment(fragment: Fragment): Boolean {
156
+ if (!isScreensFragment(fragment)) return false
157
+ // ScreenModalFragment is always a modal
158
+ if (fragment.javaClass.name.contains("ScreenModalFragment")) return false
159
+
160
+ try {
161
+ val getScreenMethod = fragment.javaClass.getMethod("getScreen")
162
+ val screen = getScreenMethod.invoke(fragment) ?: return false
163
+
164
+ val getStackPresentationMethod = screen.javaClass.getMethod("getStackPresentation")
165
+ val stackPresentation = getStackPresentationMethod.invoke(screen) ?: return false
166
+
167
+ return stackPresentation.toString() == "PUSH"
168
+ } catch (e: Exception) {
169
+ return false
170
+ }
171
+ }
172
+
148
173
  /**
149
174
  * Check if fragment is a react-native-screens modal (fullScreenModal, transparentModal, or formSheet).
150
175
  * Uses reflection to check the fragment's screen.stackPresentation property.
@@ -3,10 +3,13 @@ package com.lodev09.truesheet.core
3
3
  import android.annotation.SuppressLint
4
4
  import android.graphics.Color
5
5
  import android.graphics.Outline
6
+ import android.graphics.Rect
6
7
  import android.graphics.drawable.ShapeDrawable
7
8
  import android.graphics.drawable.shapes.RoundRectShape
8
9
  import android.util.TypedValue
10
+ import android.view.GestureDetector
9
11
  import android.view.Gravity
12
+ import android.view.MotionEvent
10
13
  import android.view.View
11
14
  import android.view.ViewOutlineProvider
12
15
  import android.widget.FrameLayout
@@ -22,6 +25,7 @@ interface TrueSheetBottomSheetViewDelegate {
22
25
  val sheetBackgroundColor: Int?
23
26
  val grabber: Boolean
24
27
  val grabberOptions: GrabberOptions?
28
+ fun bottomSheetViewDidTapGrabber()
25
29
  }
26
30
 
27
31
  /**
@@ -162,4 +166,32 @@ class TrueSheetBottomSheetView(private val reactContext: ThemedReactContext) : F
162
166
 
163
167
  addView(grabberView, 0)
164
168
  }
169
+
170
+ // =============================================================================
171
+ // MARK: - Grabber Tap Detection
172
+ // =============================================================================
173
+
174
+ private val grabberTapDetector = GestureDetector(
175
+ context,
176
+ object : GestureDetector.SimpleOnGestureListener() {
177
+ override fun onSingleTapUp(e: MotionEvent): Boolean {
178
+ delegate?.bottomSheetViewDidTapGrabber()
179
+ return true
180
+ }
181
+ }
182
+ )
183
+
184
+ override fun dispatchTouchEvent(ev: MotionEvent): Boolean {
185
+ val grabber = findViewWithTag<View>(GRABBER_TAG)
186
+ if (grabber != null) {
187
+ val rect = Rect()
188
+ grabber.getHitRect(rect)
189
+ if (rect.contains(ev.x.toInt(), ev.y.toInt())) {
190
+ if (grabberTapDetector.onTouchEvent(ev)) {
191
+ return true
192
+ }
193
+ }
194
+ }
195
+ return super.dispatchTouchEvent(ev)
196
+ }
165
197
  }
@@ -25,16 +25,18 @@ data class GrabberOptions(
25
25
 
26
26
  /**
27
27
  * Native grabber (drag handle) view for the bottom sheet.
28
- * Displays a small pill-shaped indicator at the top of the sheet.
28
+ * Displays a small pill-shaped indicator at the top of the sheet with a tappable hitbox.
29
29
  */
30
30
  @SuppressLint("ViewConstructor")
31
- class TrueSheetGrabberView(context: Context, private val options: GrabberOptions? = null) : View(context) {
31
+ class TrueSheetGrabberView(context: Context, private val options: GrabberOptions? = null) : FrameLayout(context) {
32
32
 
33
33
  companion object {
34
34
  private const val DEFAULT_WIDTH = 32f // dp
35
35
  private const val DEFAULT_HEIGHT = 4f // dp
36
36
  private const val DEFAULT_TOP_MARGIN = 16f // dp
37
37
  private const val DEFAULT_ALPHA = 0.4f
38
+ private const val HITBOX_PADDING_HORIZONTAL = 16f // dp
39
+ private const val HITBOX_PADDING_VERTICAL = 16f // dp
38
40
  private val DEFAULT_COLOR = Color.argb((DEFAULT_ALPHA * 255).toInt(), 73, 69, 79) // #49454F @ 40%
39
41
  }
40
42
 
@@ -57,19 +59,33 @@ class TrueSheetGrabberView(context: Context, private val options: GrabberOptions
57
59
  get() = if (isAdaptive) getAdaptiveColor(options?.color) else options?.color ?: DEFAULT_COLOR
58
60
 
59
61
  init {
60
- layoutParams = FrameLayout.LayoutParams(
61
- grabberWidth.dpToPx().toInt(),
62
- grabberHeight.dpToPx().toInt()
62
+ val hitboxWidth = grabberWidth + (HITBOX_PADDING_HORIZONTAL * 2)
63
+ val hitboxHeight = grabberHeight + (HITBOX_PADDING_VERTICAL * 2)
64
+
65
+ layoutParams = LayoutParams(
66
+ hitboxWidth.dpToPx().toInt(),
67
+ hitboxHeight.dpToPx().toInt()
63
68
  ).apply {
64
69
  gravity = Gravity.CENTER_HORIZONTAL or Gravity.TOP
65
- topMargin = grabberTopMargin.dpToPx().toInt()
70
+ topMargin = (grabberTopMargin - HITBOX_PADDING_VERTICAL).dpToPx().toInt()
66
71
  }
67
72
 
68
- background = GradientDrawable().apply {
69
- shape = GradientDrawable.RECTANGLE
70
- cornerRadius = grabberCornerRadius.dpToPx()
71
- setColor(grabberColor)
73
+ // The visible pill centered inside the hitbox
74
+ val pillView = View(context).apply {
75
+ layoutParams = LayoutParams(
76
+ grabberWidth.dpToPx().toInt(),
77
+ grabberHeight.dpToPx().toInt()
78
+ ).apply {
79
+ gravity = Gravity.CENTER
80
+ }
81
+ background = GradientDrawable().apply {
82
+ shape = GradientDrawable.RECTANGLE
83
+ cornerRadius = grabberCornerRadius.dpToPx()
84
+ setColor(grabberColor)
85
+ }
72
86
  }
87
+
88
+ addView(pillView)
73
89
  }
74
90
 
75
91
  private fun getAdaptiveColor(baseColor: Int? = null): Int {
@@ -35,7 +35,7 @@ class TrueSheetKeyboardObserver(private val targetView: View, private val reactC
35
35
  private set
36
36
 
37
37
  fun isFocusedViewWithinSheet(sheetView: View): Boolean {
38
- val focusedView = reactContext.currentActivity?.currentFocus ?: return false
38
+ val focusedView = targetView.rootView?.findFocus() ?: return false
39
39
  var current: View? = focusedView
40
40
  while (current != null && current !== targetView) {
41
41
  if (current === sheetView) return true
@@ -131,7 +131,7 @@ class TrueSheetKeyboardObserver(private val targetView: View, private val reactC
131
131
  // Ensure we don't add duplicate listeners
132
132
  if (globalLayoutListener != null) return
133
133
 
134
- val rootView = reactContext.currentActivity?.window?.decorView?.rootView ?: return
134
+ val rootView = targetView.rootView ?: return
135
135
  activityRootView = rootView
136
136
 
137
137
  globalLayoutListener = ViewTreeObserver.OnGlobalLayoutListener {
@@ -151,4 +151,27 @@ object TrueSheetStackManager {
151
151
  return findTopmostSheet()
152
152
  }
153
153
  }
154
+
155
+ /**
156
+ * Returns the root presented sheet for dismissAll.
157
+ * Starts from the topmost sheet and walks up to find the root of its stack.
158
+ * Stops at modal boundary (parent hidden by modal) or when there's no parent.
159
+ */
160
+ @JvmStatic
161
+ fun getRootSheet(): TrueSheetView? {
162
+ synchronized(presentedSheetStack) {
163
+ val topmost = presentedSheetStack.lastOrNull { it.viewController.isPresented } ?: return null
164
+
165
+ var current: TrueSheetView = topmost
166
+ while (true) {
167
+ val parent = current.viewController.parentSheetView ?: return current
168
+
169
+ if (parent.viewController.wasHiddenByModal) {
170
+ return current
171
+ }
172
+
173
+ current = parent
174
+ }
175
+ }
176
+ }
154
177
  }