@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.
- package/README.md +10 -3
- package/android/src/main/java/com/lodev09/truesheet/TrueSheetViewController.kt +196 -88
- package/android/src/main/java/com/lodev09/truesheet/core/TrueSheetGrabberView.kt +44 -0
- package/android/src/main/java/com/lodev09/truesheet/utils/ScreenUtils.kt +37 -33
- package/ios/TrueSheetView.mm +10 -28
- package/ios/TrueSheetViewController.h +9 -10
- package/ios/TrueSheetViewController.mm +192 -147
- package/lib/module/TrueSheet.js +16 -3
- package/lib/module/TrueSheet.js.map +1 -1
- package/lib/module/fabric/TrueSheetViewNativeComponent.ts +1 -2
- package/lib/module/index.js +0 -1
- package/lib/module/index.js.map +1 -1
- package/lib/module/reanimated/ReanimatedTrueSheet.js +5 -4
- package/lib/module/reanimated/ReanimatedTrueSheet.js.map +1 -1
- package/lib/module/reanimated/ReanimatedTrueSheetProvider.js +3 -3
- package/lib/module/reanimated/ReanimatedTrueSheetProvider.js.map +1 -1
- package/lib/typescript/src/TrueSheet.d.ts.map +1 -1
- package/lib/typescript/src/fabric/TrueSheetViewNativeComponent.d.ts +2 -2
- package/lib/typescript/src/fabric/TrueSheetViewNativeComponent.d.ts.map +1 -1
- package/lib/typescript/src/index.d.ts +0 -1
- package/lib/typescript/src/index.d.ts.map +1 -1
- package/lib/typescript/src/reanimated/ReanimatedTrueSheetProvider.d.ts +2 -2
- package/lib/typescript/src/reanimated/ReanimatedTrueSheetProvider.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/TrueSheet.tsx +16 -3
- package/src/__mocks__/index.js +0 -5
- package/src/fabric/TrueSheetViewNativeComponent.ts +1 -2
- package/src/index.ts +0 -1
- package/src/reanimated/ReanimatedTrueSheet.tsx +5 -5
- package/src/reanimated/ReanimatedTrueSheetProvider.tsx +5 -5
- package/lib/module/TrueSheetGrabber.js +0 -51
- package/lib/module/TrueSheetGrabber.js.map +0 -1
- package/lib/typescript/src/TrueSheetGrabber.d.ts +0 -39
- package/lib/typescript/src/TrueSheetGrabber.d.ts.map +0 -1
- 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.
|
|
28
|
-
- New Architecture enabled (
|
|
27
|
+
- React Native >= 0.76 (Expo SDK 52+)
|
|
28
|
+
- New Architecture enabled (default in RN 0.76+)
|
|
29
29
|
|
|
30
|
-
###
|
|
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 ->
|
|
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
|
-
|
|
444
|
-
|
|
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 =
|
|
543
|
+
isFitToContents = false
|
|
486
544
|
maxWidth = DEFAULT_MAX_WIDTH.dpToPx().toInt()
|
|
487
545
|
|
|
488
546
|
when (detents.size) {
|
|
489
547
|
1 -> {
|
|
490
|
-
|
|
491
|
-
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
782
|
+
val count = resolvedDetentPositions.size
|
|
663
783
|
if (count == 0) return -1f
|
|
664
784
|
if (count == 1) return 0f
|
|
665
785
|
|
|
666
|
-
|
|
667
|
-
val
|
|
786
|
+
val segment = findSegmentForPosition(positionPx) ?: return 0f
|
|
787
|
+
val (fromIndex, _, progress) = segment
|
|
668
788
|
|
|
669
|
-
//
|
|
670
|
-
|
|
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
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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 -
|
|
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
|
-
|
|
102
|
-
windowMetrics.bounds.width()
|
|
106
|
+
windowManager.currentWindowMetrics.bounds.width()
|
|
103
107
|
} else {
|
|
104
108
|
val displayMetrics = DisplayMetrics()
|
|
105
109
|
@Suppress("DEPRECATION")
|