@lodev09/react-native-true-sheet 3.0.0-beta.17 → 3.0.0-beta.19

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 (35) hide show
  1. package/README.md +10 -3
  2. package/android/src/main/java/com/lodev09/truesheet/TrueSheetViewController.kt +196 -88
  3. package/android/src/main/java/com/lodev09/truesheet/core/TrueSheetGrabberView.kt +44 -0
  4. package/android/src/main/java/com/lodev09/truesheet/utils/ScreenUtils.kt +37 -33
  5. package/ios/TrueSheetView.mm +10 -28
  6. package/ios/TrueSheetViewController.h +9 -10
  7. package/ios/TrueSheetViewController.mm +192 -147
  8. package/lib/module/TrueSheet.js +16 -3
  9. package/lib/module/TrueSheet.js.map +1 -1
  10. package/lib/module/fabric/TrueSheetViewNativeComponent.ts +1 -2
  11. package/lib/module/index.js +0 -1
  12. package/lib/module/index.js.map +1 -1
  13. package/lib/module/reanimated/ReanimatedTrueSheet.js +5 -4
  14. package/lib/module/reanimated/ReanimatedTrueSheet.js.map +1 -1
  15. package/lib/module/reanimated/ReanimatedTrueSheetProvider.js +3 -3
  16. package/lib/module/reanimated/ReanimatedTrueSheetProvider.js.map +1 -1
  17. package/lib/typescript/src/TrueSheet.d.ts.map +1 -1
  18. package/lib/typescript/src/fabric/TrueSheetViewNativeComponent.d.ts +2 -2
  19. package/lib/typescript/src/fabric/TrueSheetViewNativeComponent.d.ts.map +1 -1
  20. package/lib/typescript/src/index.d.ts +0 -1
  21. package/lib/typescript/src/index.d.ts.map +1 -1
  22. package/lib/typescript/src/reanimated/ReanimatedTrueSheetProvider.d.ts +2 -2
  23. package/lib/typescript/src/reanimated/ReanimatedTrueSheetProvider.d.ts.map +1 -1
  24. package/package.json +1 -1
  25. package/src/TrueSheet.tsx +16 -3
  26. package/src/__mocks__/index.js +0 -5
  27. package/src/fabric/TrueSheetViewNativeComponent.ts +1 -2
  28. package/src/index.ts +0 -1
  29. package/src/reanimated/ReanimatedTrueSheet.tsx +5 -5
  30. package/src/reanimated/ReanimatedTrueSheetProvider.tsx +5 -5
  31. package/lib/module/TrueSheetGrabber.js +0 -51
  32. package/lib/module/TrueSheetGrabber.js.map +0 -1
  33. package/lib/typescript/src/TrueSheetGrabber.d.ts +0 -39
  34. package/lib/typescript/src/TrueSheetGrabber.d.ts.map +0 -1
  35. package/src/TrueSheetGrabber.tsx +0 -82
package/README.md CHANGED
@@ -24,13 +24,20 @@ The true native bottom sheet experience for your React Native Apps. 💩
24
24
 
25
25
  ### Prerequisites
26
26
 
27
- - React Native >= 0.81
28
- - New Architecture enabled (`RCT_NEW_ARCH_ENABLED=1` for iOS, `newArchEnabled=true` for Android)
27
+ - React Native >= 0.76 (Expo SDK 52+)
28
+ - New Architecture enabled (default in RN 0.76+)
29
29
 
30
- ### Install
30
+ ### Expo
31
+
32
+ ```sh
33
+ npx expo install @lodev09/react-native-true-sheet
34
+ ```
35
+
36
+ ### Bare React Native
31
37
 
32
38
  ```sh
33
39
  yarn add @lodev09/react-native-true-sheet
40
+ cd ios && pod install
34
41
  ```
35
42
 
36
43
  ## Documentation
@@ -2,17 +2,16 @@ package com.lodev09.truesheet
2
2
 
3
3
  import android.annotation.SuppressLint
4
4
  import android.graphics.Color
5
- import android.graphics.drawable.GradientDrawable
6
5
  import android.graphics.drawable.ShapeDrawable
7
6
  import android.graphics.drawable.shapes.RoundRectShape
8
7
  import android.util.TypedValue
9
- import android.view.Gravity
10
8
  import android.view.MotionEvent
11
9
  import android.view.View
12
10
  import android.view.WindowManager
13
11
  import android.view.accessibility.AccessibilityNodeInfo
14
12
  import android.widget.FrameLayout
15
13
  import androidx.core.view.isNotEmpty
14
+ import androidx.core.view.isVisible
16
15
  import com.facebook.react.R
17
16
  import com.facebook.react.uimanager.JSPointerDispatcher
18
17
  import com.facebook.react.uimanager.JSTouchDispatcher
@@ -26,6 +25,7 @@ import com.facebook.react.views.view.ReactViewGroup
26
25
  import com.google.android.material.bottomsheet.BottomSheetBehavior
27
26
  import com.google.android.material.bottomsheet.BottomSheetDialog
28
27
  import com.lodev09.truesheet.core.RNScreensFragmentObserver
28
+ import com.lodev09.truesheet.core.TrueSheetGrabberView
29
29
  import com.lodev09.truesheet.utils.ScreenUtils
30
30
 
31
31
  data class DetentInfo(val index: Int, val position: Float)
@@ -62,10 +62,6 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
62
62
  private const val GRABBER_TAG = "TrueSheetGrabber"
63
63
  private const val DEFAULT_MAX_WIDTH = 640 // dp
64
64
  private const val DEFAULT_CORNER_RADIUS = 16 // dp
65
- private const val GRABBER_WIDTH = 32f // dp
66
- private const val GRABBER_HEIGHT = 4f // dp
67
- private const val GRABBER_TOP_MARGIN = 16f // dp
68
- private val GRABBER_COLOR = Color.argb((0.4 * 255).toInt(), 73, 69, 79) // #49454F @ 40%
69
65
 
70
66
  /**
71
67
  * Gets the effective sheet height by subtracting headerHeight * 2.
@@ -117,7 +113,11 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
117
113
  var currentDetentIndex: Int = -1
118
114
  private set
119
115
 
116
+ // Resolved detent positions (Y coordinate when sheet rests at each detent)
117
+ private val resolvedDetentPositions = mutableListOf<Int>()
118
+
120
119
  private var isDragging = false
120
+ private var isReconfiguring = false
121
121
  private var windowAnimation: Int = 0
122
122
  private var lastEmittedPositionPx: Int = -1
123
123
 
@@ -166,6 +166,13 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
166
166
  val statusBarHeight: Int
167
167
  get() = ScreenUtils.getStatusBarHeight(reactContext)
168
168
 
169
+ /**
170
+ * The bottom inset (navigation bar height) to add to sheet height.
171
+ * This matches iOS behavior where the system adds bottom safe area inset internally.
172
+ */
173
+ private val bottomInset: Int
174
+ get() = ScreenUtils.getNavigationBarHeight(reactContext)
175
+
169
176
  /**
170
177
  * Edge-to-edge is enabled by default on API 36+, or when explicitly configured.
171
178
  */
@@ -273,7 +280,11 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
273
280
  val detentInfo = getDetentInfoForIndex(currentDetentIndex)
274
281
  val detent = getDetentValueForIndex(detentInfo.index)
275
282
  val positionPx = bottomSheetView?.let { ScreenUtils.getScreenY(it) } ?: screenHeight
283
+
276
284
  delegate?.viewControllerDidPresent(detentInfo.index, detentInfo.position, detent)
285
+
286
+ // Store resolved position for initial detent
287
+ storeResolvedPosition(detentInfo.index)
277
288
  emitChangePositionDelegate(detentInfo.index, positionPx, realtime = false)
278
289
 
279
290
  // Notify parent sheet that it has lost focus (after this sheet appeared)
@@ -340,7 +351,38 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
340
351
 
341
352
  BottomSheetBehavior.STATE_EXPANDED,
342
353
  BottomSheetBehavior.STATE_COLLAPSED,
343
- BottomSheetBehavior.STATE_HALF_EXPANDED -> handleDragEnd(newState)
354
+ BottomSheetBehavior.STATE_HALF_EXPANDED -> {
355
+ // Ignore state changes triggered by content size reconfiguration
356
+ if (isReconfiguring) return
357
+
358
+ getDetentInfoForState(newState)?.let { detentInfo ->
359
+ // Store resolved position when sheet settles
360
+ storeResolvedPosition(detentInfo.index)
361
+
362
+ if (isDragging) {
363
+ // Handle drag end
364
+ val detent = getDetentValueForIndex(detentInfo.index)
365
+ delegate?.viewControllerDidDragEnd(detentInfo.index, detentInfo.position, detent)
366
+
367
+ if (detentInfo.index != currentDetentIndex) {
368
+ presentPromise?.invoke()
369
+ presentPromise = null
370
+ currentDetentIndex = detentInfo.index
371
+ setupDimmedBackground(detentInfo.index)
372
+ delegate?.viewControllerDidChangeDetent(detentInfo.index, detentInfo.position, detent)
373
+ }
374
+
375
+ isDragging = false
376
+ } else {
377
+ // Handle programmatic resize - emit detent change after sheet settles
378
+ if (detentInfo.index != currentDetentIndex) {
379
+ val detent = getDetentValueForIndex(detentInfo.index)
380
+ currentDetentIndex = detentInfo.index
381
+ delegate?.viewControllerDidChangeDetent(detentInfo.index, detentInfo.position, detent)
382
+ }
383
+ }
384
+ }
385
+ }
344
386
 
345
387
  else -> {}
346
388
  }
@@ -436,15 +478,14 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
436
478
  return
437
479
  }
438
480
 
439
- currentDetentIndex = detentIndex
440
481
  setupDimmedBackground(detentIndex)
441
482
 
442
483
  if (isPresented) {
443
- val detentInfo = getDetentInfoForIndex(detentIndex)
444
- val detent = getDetentValueForIndex(detentInfo.index)
445
- delegate?.viewControllerDidChangeDetent(detentInfo.index, detentInfo.position, detent)
484
+ // Detent change will be emitted when sheet settles in onStateChanged
485
+ // Don't update currentDetentIndex here - it will be updated when sheet settles
446
486
  setStateForDetentIndex(detentIndex)
447
487
  } else {
488
+ currentDetentIndex = detentIndex
448
489
  isDragging = false
449
490
  setupSheetDetents()
450
491
  setStateForDetentIndex(detentIndex)
@@ -480,39 +521,53 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
480
521
  fun setupSheetDetents() {
481
522
  val behavior = this.behavior ?: return
482
523
 
524
+ // Reset resolved positions if detents count changed
525
+ if (resolvedDetentPositions.size != detents.size) {
526
+ resolvedDetentPositions.clear()
527
+ repeat(detents.size) { resolvedDetentPositions.add(0) }
528
+ }
529
+
530
+ // Always update auto detent positions based on current content height
531
+ for (i in detents.indices) {
532
+ if (detents[i] == -1.0) {
533
+ val detentHeight = getDetentHeight(detents[i])
534
+ resolvedDetentPositions[i] = screenHeight - detentHeight
535
+ }
536
+ }
537
+
538
+ // Flag to prevent state change callbacks from updating detent index during reconfiguration
539
+ isReconfiguring = true
540
+
483
541
  behavior.apply {
484
542
  skipCollapsed = false
485
- isFitToContents = true
543
+ isFitToContents = false
486
544
  maxWidth = DEFAULT_MAX_WIDTH.dpToPx().toInt()
487
545
 
488
546
  when (detents.size) {
489
547
  1 -> {
490
- maxHeight = getDetentHeight(detents[0])
491
- skipCollapsed = true
492
-
493
- if (isPresented && detents[0] == -1.0) {
494
- sheetContainer?.apply {
495
- val params = layoutParams
496
- params.height = maxHeight
497
- layoutParams = params
498
- }
499
- }
548
+ setPeekHeight(getDetentHeight(detents[0]), isPresented)
549
+ expandedOffset = screenHeight - peekHeight
500
550
  }
501
551
 
502
552
  2 -> {
503
553
  setPeekHeight(getDetentHeight(detents[0]), isPresented)
504
- maxHeight = getDetentHeight(detents[1])
554
+ expandedOffset = screenHeight - getDetentHeight(detents[1])
505
555
  }
506
556
 
507
557
  3 -> {
508
- isFitToContents = false
509
558
  setPeekHeight(getDetentHeight(detents[0]), isPresented)
510
- maxHeight = getDetentHeight(detents[2])
511
- expandedOffset = sheetTopInset
512
559
  halfExpandedRatio = minOf(getDetentHeight(detents[1]).toFloat() / screenHeight.toFloat(), 1.0f)
560
+ expandedOffset = screenHeight - getDetentHeight(detents[2])
513
561
  }
514
562
  }
515
563
  }
564
+
565
+ // Re-apply current state to update position after config changes
566
+ if (isPresented) {
567
+ setStateForDetentIndex(currentDetentIndex)
568
+ }
569
+
570
+ isReconfiguring = false
516
571
  }
517
572
 
518
573
  fun setupGrabber() {
@@ -524,21 +579,8 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
524
579
 
525
580
  if (!grabber || !draggable) return
526
581
 
527
- val grabberView = View(reactContext).apply {
582
+ val grabberView = TrueSheetGrabberView(reactContext).apply {
528
583
  tag = GRABBER_TAG
529
- layoutParams = FrameLayout.LayoutParams(
530
- GRABBER_WIDTH.dpToPx().toInt(),
531
- GRABBER_HEIGHT.dpToPx().toInt()
532
- ).apply {
533
- gravity = Gravity.CENTER_HORIZONTAL or Gravity.TOP
534
- topMargin = GRABBER_TOP_MARGIN.dpToPx().toInt()
535
- }
536
- background = GradientDrawable().apply {
537
- shape = GradientDrawable.RECTANGLE
538
- cornerRadius = (GRABBER_HEIGHT / 2).dpToPx()
539
- setColor(GRABBER_COLOR)
540
- }
541
- elevation = 1f
542
584
  }
543
585
 
544
586
  bottomSheet.addView(grabberView)
@@ -650,49 +692,132 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
650
692
  lastEmittedPositionPx = positionPx
651
693
  val position = positionPx.pxToDp()
652
694
  val interpolatedIndex = getInterpolatedIndexForPosition(positionPx)
653
- val detent = getDetentValueForIndex(kotlin.math.round(interpolatedIndex).toInt())
695
+ val detent = getInterpolatedDetentForPosition(positionPx)
654
696
  delegate?.viewControllerDidChangePosition(interpolatedIndex, position, detent, realtime)
655
697
  }
656
698
 
699
+ /**
700
+ * Stores the current Y position as the resolved position for the given detent index.
701
+ * This is called when the sheet settles at a detent to capture the actual position
702
+ * which may differ from the calculated position due to system adjustments.
703
+ */
704
+ private fun storeResolvedPosition(index: Int) {
705
+ if (index < 0 || index >= resolvedDetentPositions.size) return
706
+ val positionPx = bottomSheetView?.let { ScreenUtils.getScreenY(it) } ?: return
707
+ if (positionPx in 1..<screenHeight) {
708
+ resolvedDetentPositions[index] = positionPx
709
+ }
710
+ }
711
+
712
+ /**
713
+ * Stores the resolved position for the current detent.
714
+ * Called from TrueSheetView when content size changes.
715
+ */
716
+ fun storeCurrentResolvedPosition() {
717
+ storeResolvedPosition(currentDetentIndex)
718
+ }
719
+
720
+ /**
721
+ * Returns the estimated Y position for a detent index, using stored positions when available.
722
+ */
723
+ private fun getEstimatedPositionForIndex(index: Int): Int {
724
+ if (index < 0 || index >= resolvedDetentPositions.size) return screenHeight
725
+
726
+ val storedPos = resolvedDetentPositions[index]
727
+ if (storedPos > 0) return storedPos
728
+
729
+ // Estimate based on getDetentHeight which accounts for bottomInset and maxAllowedHeight
730
+ if (index < detents.size) {
731
+ val detentHeight = getDetentHeight(detents[index])
732
+ return screenHeight - detentHeight
733
+ }
734
+
735
+ return screenHeight
736
+ }
737
+
738
+ /**
739
+ * Finds the segment index and interpolation progress for a given position.
740
+ * Returns a Triple of (fromIndex, toIndex, progress) where progress is 0-1 within that segment.
741
+ * Returns null if position count is less than 2.
742
+ */
743
+ private fun findSegmentForPosition(positionPx: Int): Triple<Int, Int, Float>? {
744
+ val count = resolvedDetentPositions.size
745
+ if (count < 2) return null
746
+
747
+ val firstPos = getEstimatedPositionForIndex(0)
748
+ val lastPos = getEstimatedPositionForIndex(count - 1)
749
+
750
+ // Below first detent
751
+ if (positionPx > firstPos) {
752
+ val range = screenHeight - firstPos
753
+ val progress = if (range > 0) (positionPx - firstPos).toFloat() / range else 0f
754
+ return Triple(-1, 0, progress) // Special index -1 for below first
755
+ }
756
+
757
+ // Above last detent
758
+ if (positionPx < lastPos) {
759
+ return Triple(count - 1, count - 1, 0f)
760
+ }
761
+
762
+ // Find segment (positions decrease as index increases)
763
+ for (i in 0 until count - 1) {
764
+ val pos = getEstimatedPositionForIndex(i)
765
+ val nextPos = getEstimatedPositionForIndex(i + 1)
766
+
767
+ if (positionPx <= pos && positionPx >= nextPos) {
768
+ val range = pos - nextPos
769
+ val progress = if (range > 0) (pos - positionPx).toFloat() / range else 0f
770
+ return Triple(i, i + 1, maxOf(0f, minOf(1f, progress)))
771
+ }
772
+ }
773
+
774
+ return Triple(count - 1, count - 1, 0f)
775
+ }
776
+
657
777
  /**
658
778
  * Calculates the interpolated index based on position.
659
779
  * Returns a continuous value (e.g., 0.5 means halfway between detent 0 and 1).
660
780
  */
661
781
  private fun getInterpolatedIndexForPosition(positionPx: Int): Float {
662
- val count = detents.size
782
+ val count = resolvedDetentPositions.size
663
783
  if (count == 0) return -1f
664
784
  if (count == 1) return 0f
665
785
 
666
- // Convert position to detent fraction
667
- val currentDetent = (screenHeight - positionPx).toFloat() / screenHeight.toFloat()
786
+ val segment = findSegmentForPosition(positionPx) ?: return 0f
787
+ val (fromIndex, _, progress) = segment
668
788
 
669
- // Handle below first detent (interpolate from -1 to 0)
670
- val firstDetentValue = getDetentValueForIndex(0)
671
- if (currentDetent < firstDetentValue) {
672
- if (firstDetentValue <= 0) return 0f
673
- val progress = currentDetent / firstDetentValue
674
- return progress - 1f
675
- }
789
+ // Below first detent
790
+ if (fromIndex == -1) return -progress
676
791
 
677
- // Find which segment the current detent falls into and interpolate
678
- for (i in 0 until count - 1) {
679
- val detentValue = getDetentValueForIndex(i)
680
- val nextDetentValue = getDetentValueForIndex(i + 1)
681
-
682
- if (currentDetent <= nextDetentValue) {
683
- val range = nextDetentValue - detentValue
684
- if (range <= 0) return i.toFloat()
685
- val progress = (currentDetent - detentValue) / range
686
- return i + maxOf(0f, minOf(1f, progress))
687
- }
792
+ return fromIndex + progress
793
+ }
794
+
795
+ /**
796
+ * Calculates the interpolated detent value based on position.
797
+ * Returns the actual screen fraction, clamped to valid detent range.
798
+ */
799
+ private fun getInterpolatedDetentForPosition(positionPx: Int): Float {
800
+ val count = resolvedDetentPositions.size
801
+ if (count == 0) return 0f
802
+
803
+ val segment = findSegmentForPosition(positionPx) ?: return getDetentValueForIndex(0)
804
+ val (fromIndex, toIndex, progress) = segment
805
+
806
+ // Below first detent
807
+ if (fromIndex == -1) {
808
+ val firstDetent = getDetentValueForIndex(0)
809
+ return maxOf(0f, firstDetent * (1 - progress))
688
810
  }
689
811
 
690
- return (count - 1).toFloat()
812
+ val fromDetent = getDetentValueForIndex(fromIndex)
813
+ val toDetent = getDetentValueForIndex(toIndex)
814
+ return fromDetent + progress * (toDetent - fromDetent)
691
815
  }
692
816
 
693
817
  /**
694
818
  * Gets the detent value (fraction) for a given index.
695
- * For auto (-1), calculates the actual fraction from content + header height.
819
+ * Returns the raw screen fraction without bottomInset for interpolation calculations.
820
+ * Note: bottomInset is only added in getDetentHeight() for actual sheet sizing.
696
821
  */
697
822
  private fun getDetentValueForIndex(index: Int): Float {
698
823
  if (index < 0 || index >= detents.size) return 0f
@@ -749,39 +874,21 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
749
874
  delegate?.viewControllerDidDragChange(detentInfo.index, detentInfo.position, detent)
750
875
  }
751
876
 
752
- private fun handleDragEnd(state: Int) {
753
- if (!isDragging) return
754
-
755
- val detentInfo = getDetentInfoForState(state)
756
- detentInfo?.let {
757
- val detent = getDetentValueForIndex(it.index)
758
- delegate?.viewControllerDidDragEnd(it.index, it.position, detent)
759
-
760
- if (it.index != currentDetentIndex) {
761
- presentPromise?.invoke()
762
- presentPromise = null
763
-
764
- currentDetentIndex = it.index
765
- setupDimmedBackground(it.index)
766
- delegate?.viewControllerDidChangeDetent(it.index, it.position, detent)
767
- }
768
- }
769
-
770
- isDragging = false
771
- }
772
-
773
877
  // ====================================================================
774
878
  // MARK: - Detent Calculations
775
879
  // ====================================================================
776
880
 
777
881
  private fun getDetentHeight(detent: Double): Int {
778
882
  val height: Int = if (detent == -1.0) {
779
- contentHeight + headerHeight
883
+ // For auto detent, add bottomInset to match iOS behavior where the system
884
+ // adds bottom safe area inset internally to the sheet height
885
+ contentHeight + headerHeight + bottomInset
780
886
  } else {
781
887
  if (detent <= 0.0 || detent > 1.0) {
782
888
  throw IllegalArgumentException("TrueSheet: detent fraction ($detent) must be between 0 and 1")
783
889
  }
784
- (detent * screenHeight).toInt()
890
+ // For fractional detents, add bottomInset to match iOS behavior
891
+ (detent * screenHeight).toInt() + bottomInset
785
892
  }
786
893
 
787
894
  val maxAllowedHeight = screenHeight - sheetTopInset
@@ -869,6 +976,7 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
869
976
  setupSheetDetents()
870
977
  this.post {
871
978
  positionFooter()
979
+ storeResolvedPosition(currentDetentIndex)
872
980
  val positionPx = bottomSheetView?.let { ScreenUtils.getScreenY(it) } ?: screenHeight
873
981
  emitChangePositionDelegate(currentDetentIndex, positionPx, realtime = false)
874
982
  }
@@ -890,7 +998,7 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
890
998
  */
891
999
  override fun dispatchTouchEvent(event: MotionEvent): Boolean {
892
1000
  val footer = containerView?.footerView
893
- if (footer != null && footer.visibility == View.VISIBLE) {
1001
+ if (footer != null && footer.isVisible) {
894
1002
  val footerLocation = ScreenUtils.getScreenLocation(footer)
895
1003
  val touchScreenX = event.rawX.toInt()
896
1004
  val touchScreenY = event.rawY.toInt()
@@ -0,0 +1,44 @@
1
+ package com.lodev09.truesheet.core
2
+
3
+ import android.annotation.SuppressLint
4
+ import android.content.Context
5
+ import android.graphics.Color
6
+ import android.graphics.drawable.GradientDrawable
7
+ import android.view.Gravity
8
+ import android.view.View
9
+ import android.widget.FrameLayout
10
+ import com.facebook.react.uimanager.PixelUtil.dpToPx
11
+
12
+ /**
13
+ * Native grabber (drag handle) view for the bottom sheet.
14
+ * Displays a small pill-shaped indicator at the top of the sheet.
15
+ */
16
+ @SuppressLint("ViewConstructor")
17
+ class TrueSheetGrabberView(context: Context) : View(context) {
18
+
19
+ companion object {
20
+ private const val GRABBER_WIDTH = 32f // dp
21
+ private const val GRABBER_HEIGHT = 4f // dp
22
+ private const val GRABBER_TOP_MARGIN = 16f // dp
23
+ private val GRABBER_COLOR = Color.argb((0.4 * 255).toInt(), 73, 69, 79) // #49454F @ 40%
24
+ }
25
+
26
+ init {
27
+ layoutParams = FrameLayout.LayoutParams(
28
+ GRABBER_WIDTH.dpToPx().toInt(),
29
+ GRABBER_HEIGHT.dpToPx().toInt()
30
+ ).apply {
31
+ gravity = Gravity.CENTER_HORIZONTAL or Gravity.TOP
32
+ topMargin = GRABBER_TOP_MARGIN.dpToPx().toInt()
33
+ }
34
+
35
+ background = GradientDrawable().apply {
36
+ shape = GradientDrawable.RECTANGLE
37
+ cornerRadius = (GRABBER_HEIGHT / 2).dpToPx()
38
+ setColor(GRABBER_COLOR)
39
+ }
40
+
41
+ // High elevation to ensure grabber appears above content views
42
+ elevation = 100f
43
+ }
44
+ }
@@ -19,6 +19,18 @@ object ScreenUtils {
19
19
  context.resources.getIdentifier(name, "dimen", "android")
20
20
  ).takeIf { it > 0 } ?: 0
21
21
 
22
+ /**
23
+ * Get the WindowInsets for API 30+, or null for older APIs
24
+ */
25
+ private fun getWindowInsets(context: ReactContext): WindowInsets? {
26
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
27
+ return context.getSystemService(WindowManager::class.java)
28
+ ?.currentWindowMetrics
29
+ ?.windowInsets
30
+ }
31
+ return null
32
+ }
33
+
22
34
  /**
23
35
  * Get the status bar height
24
36
  *
@@ -26,20 +38,29 @@ object ScreenUtils {
26
38
  * @return Status bar height in pixels
27
39
  */
28
40
  fun getStatusBarHeight(context: ReactContext): Int {
29
- // Modern approach using WindowInsets (API 30+)
30
41
  if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
31
- val windowManager = context.getSystemService(WindowManager::class.java)
32
- val windowMetrics = windowManager?.currentWindowMetrics
33
- val insets = windowMetrics?.windowInsets?.getInsetsIgnoringVisibility(WindowInsets.Type.statusBars())
34
- if (insets != null) {
35
- return insets.top
36
- }
42
+ getWindowInsets(context)
43
+ ?.getInsetsIgnoringVisibility(WindowInsets.Type.statusBars())
44
+ ?.let { return it.top }
37
45
  }
38
-
39
- // Fallback to legacy approach for older APIs
40
46
  return getIdentifierHeight(context, "status_bar_height")
41
47
  }
42
48
 
49
+ /**
50
+ * Get the navigation bar height (bottom inset)
51
+ *
52
+ * @param context React context
53
+ * @return Navigation bar height in pixels
54
+ */
55
+ fun getNavigationBarHeight(context: ReactContext): Int {
56
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
57
+ getWindowInsets(context)
58
+ ?.getInsetsIgnoringVisibility(WindowInsets.Type.navigationBars())
59
+ ?.let { return it.bottom }
60
+ }
61
+ return getIdentifierHeight(context, "navigation_bar_height")
62
+ }
63
+
43
64
  /**
44
65
  * Calculate the screen height
45
66
  *
@@ -55,36 +76,20 @@ object ScreenUtils {
55
76
  if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
56
77
  context.display?.getRealMetrics(displayMetrics)
57
78
  } else {
79
+ @Suppress("DEPRECATION")
58
80
  windowManager.defaultDisplay.getMetrics(displayMetrics)
59
81
  }
60
82
 
61
83
  val screenHeight = displayMetrics.heightPixels
62
- val statusBarHeight = getStatusBarHeight(context)
63
-
64
- val hasNavigationBar = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
65
- context.getSystemService(WindowManager::class.java)
66
- ?.currentWindowMetrics
67
- ?.windowInsets
68
- ?.isVisible(WindowInsets.Type.navigationBars()) ?: false
69
- } else {
70
- context.resources.getIdentifier("navigation_bar_height", "dimen", "android") > 0
71
- }
72
-
73
- val navigationBarHeight = if (hasNavigationBar) {
74
- getIdentifierHeight(context, "navigation_bar_height")
75
- } else {
76
- 0
77
- }
78
84
 
79
85
  return if (edgeToEdge) {
80
- // getRealMetrics includes navigation bar height
81
- // windowManager.defaultDisplay.getMetrics doesn't
82
- when (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
83
- true -> screenHeight
84
- false -> screenHeight + navigationBarHeight
86
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
87
+ screenHeight
88
+ } else {
89
+ screenHeight + getNavigationBarHeight(context)
85
90
  }
86
91
  } else {
87
- screenHeight - statusBarHeight - navigationBarHeight
92
+ screenHeight - getStatusBarHeight(context) - getNavigationBarHeight(context)
88
93
  }
89
94
  }
90
95
 
@@ -98,8 +103,7 @@ object ScreenUtils {
98
103
  val windowManager = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
99
104
 
100
105
  return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
101
- val windowMetrics = windowManager.currentWindowMetrics
102
- windowMetrics.bounds.width()
106
+ windowManager.currentWindowMetrics.bounds.width()
103
107
  } else {
104
108
  val displayMetrics = DisplayMetrics()
105
109
  @Suppress("DEPRECATION")