@lodev09/react-native-true-sheet 3.0.0-beta.9 → 3.0.1

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 (101) hide show
  1. package/README.md +16 -6
  2. package/android/src/main/java/com/lodev09/truesheet/TrueSheetContainerView.kt +29 -33
  3. package/android/src/main/java/com/lodev09/truesheet/TrueSheetModule.kt +3 -1
  4. package/android/src/main/java/com/lodev09/truesheet/TrueSheetView.kt +53 -43
  5. package/android/src/main/java/com/lodev09/truesheet/TrueSheetViewController.kt +390 -89
  6. package/android/src/main/java/com/lodev09/truesheet/TrueSheetViewManager.kt +42 -4
  7. package/android/src/main/java/com/lodev09/truesheet/core/RNScreensFragmentObserver.kt +0 -5
  8. package/android/src/main/java/com/lodev09/truesheet/core/TrueSheetDialogObserver.kt +67 -0
  9. package/android/src/main/java/com/lodev09/truesheet/core/TrueSheetGrabberView.kt +70 -0
  10. package/android/src/main/java/com/lodev09/truesheet/events/TrueSheetDragEvents.kt +71 -0
  11. package/android/src/main/java/com/lodev09/truesheet/events/TrueSheetFocusEvents.kt +65 -0
  12. package/android/src/main/java/com/lodev09/truesheet/events/TrueSheetLifecycleEvents.kt +94 -0
  13. package/android/src/main/java/com/lodev09/truesheet/events/TrueSheetStateEvents.kt +56 -0
  14. package/android/src/main/java/com/lodev09/truesheet/utils/ScreenUtils.kt +37 -33
  15. package/android/src/main/res/anim/true_sheet_slide_in.xml +13 -0
  16. package/android/src/main/res/anim/true_sheet_slide_out.xml +13 -0
  17. package/android/src/main/res/values/styles.xml +13 -1
  18. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetViewShadowNode.cpp +5 -3
  19. package/ios/TrueSheetContainerView.mm +4 -0
  20. package/ios/TrueSheetContentView.h +2 -1
  21. package/ios/TrueSheetContentView.mm +91 -11
  22. package/ios/TrueSheetView.mm +94 -41
  23. package/ios/TrueSheetViewController.h +22 -10
  24. package/ios/TrueSheetViewController.mm +360 -173
  25. package/ios/core/TrueSheetBlurView.h +26 -0
  26. package/ios/{utils/ConversionUtil.mm → core/TrueSheetBlurView.mm} +64 -3
  27. package/ios/core/TrueSheetGrabberView.h +42 -0
  28. package/ios/core/TrueSheetGrabberView.mm +107 -0
  29. package/ios/events/TrueSheetDragEvents.h +39 -0
  30. package/ios/events/TrueSheetDragEvents.mm +62 -0
  31. package/ios/events/{OnPositionChangeEvent.h → TrueSheetFocusEvents.h} +8 -5
  32. package/ios/events/TrueSheetFocusEvents.mm +49 -0
  33. package/ios/events/TrueSheetLifecycleEvents.h +40 -0
  34. package/ios/events/TrueSheetLifecycleEvents.mm +71 -0
  35. package/ios/events/TrueSheetStateEvents.h +35 -0
  36. package/ios/events/TrueSheetStateEvents.mm +49 -0
  37. package/ios/utils/GestureUtil.h +7 -0
  38. package/ios/utils/GestureUtil.mm +12 -0
  39. package/lib/module/TrueSheet.js +72 -12
  40. package/lib/module/TrueSheet.js.map +1 -1
  41. package/lib/module/fabric/TrueSheetViewNativeComponent.ts +28 -5
  42. package/lib/module/index.js +0 -1
  43. package/lib/module/index.js.map +1 -1
  44. package/lib/module/reanimated/ReanimatedTrueSheet.js +13 -7
  45. package/lib/module/reanimated/ReanimatedTrueSheet.js.map +1 -1
  46. package/lib/module/reanimated/ReanimatedTrueSheetProvider.js +4 -2
  47. package/lib/module/reanimated/ReanimatedTrueSheetProvider.js.map +1 -1
  48. package/lib/typescript/src/TrueSheet.d.ts +4 -0
  49. package/lib/typescript/src/TrueSheet.d.ts.map +1 -1
  50. package/lib/typescript/src/TrueSheet.types.d.ts +105 -6
  51. package/lib/typescript/src/TrueSheet.types.d.ts.map +1 -1
  52. package/lib/typescript/src/fabric/TrueSheetViewNativeComponent.d.ts +25 -5
  53. package/lib/typescript/src/fabric/TrueSheetViewNativeComponent.d.ts.map +1 -1
  54. package/lib/typescript/src/index.d.ts +0 -1
  55. package/lib/typescript/src/index.d.ts.map +1 -1
  56. package/lib/typescript/src/reanimated/ReanimatedTrueSheet.d.ts.map +1 -1
  57. package/lib/typescript/src/reanimated/ReanimatedTrueSheetProvider.d.ts +8 -2
  58. package/lib/typescript/src/reanimated/ReanimatedTrueSheetProvider.d.ts.map +1 -1
  59. package/package.json +8 -2
  60. package/src/TrueSheet.tsx +87 -10
  61. package/src/TrueSheet.types.ts +114 -6
  62. package/src/__mocks__/index.js +0 -5
  63. package/src/fabric/TrueSheetViewNativeComponent.ts +28 -5
  64. package/src/index.ts +0 -1
  65. package/src/reanimated/ReanimatedTrueSheet.tsx +12 -7
  66. package/src/reanimated/ReanimatedTrueSheetProvider.tsx +11 -3
  67. package/android/src/main/java/com/lodev09/truesheet/events/DetentChangeEvent.kt +0 -26
  68. package/android/src/main/java/com/lodev09/truesheet/events/DidDismissEvent.kt +0 -20
  69. package/android/src/main/java/com/lodev09/truesheet/events/DidPresentEvent.kt +0 -26
  70. package/android/src/main/java/com/lodev09/truesheet/events/DragBeginEvent.kt +0 -26
  71. package/android/src/main/java/com/lodev09/truesheet/events/DragChangeEvent.kt +0 -26
  72. package/android/src/main/java/com/lodev09/truesheet/events/DragEndEvent.kt +0 -26
  73. package/android/src/main/java/com/lodev09/truesheet/events/MountEvent.kt +0 -20
  74. package/android/src/main/java/com/lodev09/truesheet/events/PositionChangeEvent.kt +0 -32
  75. package/android/src/main/java/com/lodev09/truesheet/events/WillDismissEvent.kt +0 -20
  76. package/android/src/main/java/com/lodev09/truesheet/events/WillPresentEvent.kt +0 -26
  77. package/ios/events/OnDetentChangeEvent.h +0 -28
  78. package/ios/events/OnDetentChangeEvent.mm +0 -30
  79. package/ios/events/OnDidDismissEvent.h +0 -26
  80. package/ios/events/OnDidDismissEvent.mm +0 -25
  81. package/ios/events/OnDidPresentEvent.h +0 -28
  82. package/ios/events/OnDidPresentEvent.mm +0 -30
  83. package/ios/events/OnDragBeginEvent.h +0 -28
  84. package/ios/events/OnDragBeginEvent.mm +0 -30
  85. package/ios/events/OnDragChangeEvent.h +0 -28
  86. package/ios/events/OnDragChangeEvent.mm +0 -30
  87. package/ios/events/OnDragEndEvent.h +0 -28
  88. package/ios/events/OnDragEndEvent.mm +0 -30
  89. package/ios/events/OnMountEvent.h +0 -26
  90. package/ios/events/OnMountEvent.mm +0 -25
  91. package/ios/events/OnPositionChangeEvent.mm +0 -32
  92. package/ios/events/OnWillDismissEvent.h +0 -26
  93. package/ios/events/OnWillDismissEvent.mm +0 -25
  94. package/ios/events/OnWillPresentEvent.h +0 -28
  95. package/ios/events/OnWillPresentEvent.mm +0 -30
  96. package/ios/utils/ConversionUtil.h +0 -24
  97. package/lib/module/TrueSheetGrabber.js +0 -51
  98. package/lib/module/TrueSheetGrabber.js.map +0 -1
  99. package/lib/typescript/src/TrueSheetGrabber.d.ts +0 -39
  100. package/lib/typescript/src/TrueSheetGrabber.d.ts.map +0 -1
  101. package/src/TrueSheetGrabber.tsx +0 -82
@@ -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
@@ -25,22 +24,28 @@ import com.facebook.react.util.RNLog
25
24
  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
27
+ import com.lodev09.truesheet.core.GrabberOptions
28
28
  import com.lodev09.truesheet.core.RNScreensFragmentObserver
29
+ import com.lodev09.truesheet.core.TrueSheetGrabberView
29
30
  import com.lodev09.truesheet.utils.ScreenUtils
30
31
 
31
32
  data class DetentInfo(val index: Int, val position: Float)
32
33
 
33
34
  interface TrueSheetViewControllerDelegate {
34
- fun viewControllerWillPresent(index: Int, position: Float)
35
- fun viewControllerDidPresent(index: Int, position: Float)
35
+ fun viewControllerWillPresent(index: Int, position: Float, detent: Float)
36
+ fun viewControllerDidPresent(index: Int, position: Float, detent: Float)
36
37
  fun viewControllerWillDismiss()
37
- fun viewControllerDidDismiss()
38
- fun viewControllerDidChangeDetent(index: Int, position: Float)
39
- fun viewControllerDidDragBegin(index: Int, position: Float)
40
- fun viewControllerDidDragChange(index: Int, position: Float)
41
- fun viewControllerDidDragEnd(index: Int, position: Float)
42
- fun viewControllerDidChangePosition(index: Int, position: Float, transitioning: Boolean)
38
+ fun viewControllerDidDismiss(hadParent: Boolean)
39
+ fun viewControllerDidChangeDetent(index: Int, position: Float, detent: Float)
40
+ fun viewControllerDidDragBegin(index: Int, position: Float, detent: Float)
41
+ fun viewControllerDidDragChange(index: Int, position: Float, detent: Float)
42
+ fun viewControllerDidDragEnd(index: Int, position: Float, detent: Float)
43
+ fun viewControllerDidChangePosition(index: Float, position: Float, detent: Float, realtime: Boolean)
43
44
  fun viewControllerDidChangeSize(width: Int, height: Int)
45
+ fun viewControllerWillFocus()
46
+ fun viewControllerDidFocus()
47
+ fun viewControllerWillBlur()
48
+ fun viewControllerDidBlur()
44
49
  }
45
50
 
46
51
  /**
@@ -58,10 +63,6 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
58
63
  private const val GRABBER_TAG = "TrueSheetGrabber"
59
64
  private const val DEFAULT_MAX_WIDTH = 640 // dp
60
65
  private const val DEFAULT_CORNER_RADIUS = 16 // dp
61
- private const val GRABBER_WIDTH = 32f // dp
62
- private const val GRABBER_HEIGHT = 4f // dp
63
- private const val GRABBER_TOP_MARGIN = 16f // dp
64
- private val GRABBER_COLOR = Color.argb((0.4 * 255).toInt(), 73, 69, 79) // #49454F @ 40%
65
66
 
66
67
  /**
67
68
  * Gets the effective sheet height by subtracting headerHeight * 2.
@@ -107,15 +108,26 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
107
108
  var isPresented = false
108
109
  private set
109
110
 
111
+ var isDialogVisible = false
112
+ private set
113
+
110
114
  var currentDetentIndex: Int = -1
111
115
  private set
112
116
 
117
+ // Resolved detent positions (Y coordinate when sheet rests at each detent)
118
+ private val resolvedDetentPositions = mutableListOf<Int>()
119
+
113
120
  private var isDragging = false
121
+ private var isReconfiguring = false
114
122
  private var windowAnimation: Int = 0
123
+ private var lastEmittedPositionPx: Int = -1
115
124
 
116
125
  var presentPromise: (() -> Unit)? = null
117
126
  var dismissPromise: (() -> Unit)? = null
118
127
 
128
+ // Reference to parent TrueSheetView (if presented from another sheet)
129
+ var parentSheetView: TrueSheetView? = null
130
+
119
131
  // ====================================================================
120
132
  // MARK: - Configuration Properties
121
133
  // ====================================================================
@@ -128,6 +140,7 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
128
140
  var dimmed = true
129
141
  var dimmedDetentIndex = 0
130
142
  var grabber: Boolean = true
143
+ var grabberOptions: GrabberOptions? = null
131
144
  var sheetCornerRadius: Float = -1f
132
145
  var sheetBackgroundColor: Int = 0
133
146
  var edgeToEdgeFullScreen: Boolean = false
@@ -142,6 +155,12 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
142
155
  }
143
156
  }
144
157
 
158
+ var draggable: Boolean = true
159
+ set(value) {
160
+ field = value
161
+ behavior?.isDraggable = value
162
+ }
163
+
145
164
  // ====================================================================
146
165
  // MARK: - Computed Properties
147
166
  // ====================================================================
@@ -149,6 +168,13 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
149
168
  val statusBarHeight: Int
150
169
  get() = ScreenUtils.getStatusBarHeight(reactContext)
151
170
 
171
+ /**
172
+ * The bottom inset (navigation bar height) to add to sheet height.
173
+ * This matches iOS behavior where the system adds bottom safe area inset internally.
174
+ */
175
+ private val bottomInset: Int
176
+ get() = ScreenUtils.getNavigationBarHeight(reactContext)
177
+
152
178
  /**
153
179
  * Edge-to-edge is enabled by default on API 36+, or when explicitly configured.
154
180
  */
@@ -186,8 +212,6 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
186
212
  */
187
213
  private var rnScreensObserver: RNScreensFragmentObserver? = null
188
214
 
189
- fun hasActiveModals(): Boolean = rnScreensObserver?.hasActiveModals() ?: false
190
-
191
215
  // ====================================================================
192
216
  // MARK: - Initialization
193
217
  // ====================================================================
@@ -208,7 +232,7 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
208
232
  val style = if (edgeToEdgeEnabled) {
209
233
  com.lodev09.truesheet.R.style.TrueSheetEdgeToEdgeEnabledDialog
210
234
  } else {
211
- 0
235
+ com.lodev09.truesheet.R.style.TrueSheetDialog
212
236
  }
213
237
 
214
238
  dialog = BottomSheetDialog(reactContext, style).apply {
@@ -225,6 +249,7 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
225
249
  setCanceledOnTouchOutside(dismissible)
226
250
  setCancelable(dismissible)
227
251
  behavior.isHideable = dismissible
252
+ behavior.isDraggable = draggable
228
253
  }
229
254
  }
230
255
 
@@ -241,19 +266,31 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
241
266
  dialog = null
242
267
  isDragging = false
243
268
  isPresented = false
269
+ isDialogVisible = false
270
+ lastEmittedPositionPx = -1
244
271
  }
245
272
 
246
273
  private fun setupDialogListeners(dialog: BottomSheetDialog) {
247
274
  dialog.setOnShowListener {
248
275
  isPresented = true
276
+ isDialogVisible = true
249
277
  resetAnimation()
250
278
  setupBackground()
251
279
  setupGrabber()
252
280
 
253
281
  sheetContainer?.post {
254
282
  val detentInfo = getDetentInfoForIndex(currentDetentIndex)
255
- delegate?.viewControllerDidPresent(detentInfo.index, detentInfo.position)
256
- delegate?.viewControllerDidChangePosition(detentInfo.index, detentInfo.position, transitioning = true)
283
+ val detent = getDetentValueForIndex(detentInfo.index)
284
+ val positionPx = bottomSheetView?.let { ScreenUtils.getScreenY(it) } ?: screenHeight
285
+
286
+ delegate?.viewControllerDidPresent(detentInfo.index, detentInfo.position, detent)
287
+
288
+ // Store resolved position for initial detent
289
+ storeResolvedPosition(detentInfo.index)
290
+ emitChangePositionDelegate(detentInfo.index, positionPx, realtime = false)
291
+
292
+ // Notify parent sheet that it has lost focus (after this sheet appeared)
293
+ parentSheetView?.viewControllerDidBlur()
257
294
 
258
295
  presentPromise?.invoke()
259
296
  presentPromise = null
@@ -263,13 +300,22 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
263
300
  }
264
301
 
265
302
  dialog.setOnCancelListener {
303
+ // Notify parent sheet that it is about to regain focus
304
+ parentSheetView?.viewControllerWillFocus()
305
+
266
306
  delegate?.viewControllerWillDismiss()
267
307
  }
268
308
 
269
309
  dialog.setOnDismissListener {
310
+ val hadParent = parentSheetView != null
311
+
312
+ // Notify parent sheet that it has regained focus
313
+ parentSheetView?.viewControllerDidFocus()
314
+ parentSheetView = null
315
+
270
316
  dismissPromise?.invoke()
271
317
  dismissPromise = null
272
- delegate?.viewControllerDidDismiss()
318
+ delegate?.viewControllerDidDismiss(hadParent)
273
319
  cleanupDialog()
274
320
  }
275
321
  }
@@ -279,8 +325,10 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
279
325
  object : BottomSheetBehavior.BottomSheetCallback() {
280
326
  override fun onSlide(sheetView: View, slideOffset: Float) {
281
327
  val behavior = behavior ?: return
282
- val detentInfo = getCurrentDetentInfo(sheetView)
283
- delegate?.viewControllerDidChangePosition(detentInfo.index, detentInfo.position, transitioning = false)
328
+ val positionPx = getCurrentPositionPx(sheetView)
329
+ val detentIndex = getDetentIndexForPosition(positionPx)
330
+
331
+ emitChangePositionDelegate(detentIndex, positionPx, realtime = true)
284
332
 
285
333
  when (behavior.state) {
286
334
  BottomSheetBehavior.STATE_DRAGGING,
@@ -305,7 +353,38 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
305
353
 
306
354
  BottomSheetBehavior.STATE_EXPANDED,
307
355
  BottomSheetBehavior.STATE_COLLAPSED,
308
- BottomSheetBehavior.STATE_HALF_EXPANDED -> handleDragEnd(newState)
356
+ BottomSheetBehavior.STATE_HALF_EXPANDED -> {
357
+ // Ignore state changes triggered by content size reconfiguration
358
+ if (isReconfiguring) return
359
+
360
+ getDetentInfoForState(newState)?.let { detentInfo ->
361
+ // Store resolved position when sheet settles
362
+ storeResolvedPosition(detentInfo.index)
363
+
364
+ if (isDragging) {
365
+ // Handle drag end
366
+ val detent = getDetentValueForIndex(detentInfo.index)
367
+ delegate?.viewControllerDidDragEnd(detentInfo.index, detentInfo.position, detent)
368
+
369
+ if (detentInfo.index != currentDetentIndex) {
370
+ presentPromise?.invoke()
371
+ presentPromise = null
372
+ currentDetentIndex = detentInfo.index
373
+ setupDimmedBackground(detentInfo.index)
374
+ delegate?.viewControllerDidChangeDetent(detentInfo.index, detentInfo.position, detent)
375
+ }
376
+
377
+ isDragging = false
378
+ } else {
379
+ // Handle programmatic resize - emit detent change after sheet settles
380
+ if (detentInfo.index != currentDetentIndex) {
381
+ val detent = getDetentValueForIndex(detentInfo.index)
382
+ currentDetentIndex = detentInfo.index
383
+ delegate?.viewControllerDidChangeDetent(detentInfo.index, detentInfo.position, detent)
384
+ }
385
+ }
386
+ }
387
+ }
309
388
 
310
389
  else -> {}
311
390
  }
@@ -319,12 +398,12 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
319
398
  reactContext = reactContext,
320
399
  onModalPresented = {
321
400
  if (isPresented) {
322
- dialog?.window?.decorView?.visibility = View.INVISIBLE
401
+ hideDialog()
323
402
  }
324
403
  },
325
404
  onModalDismissed = {
326
405
  if (isPresented) {
327
- dialog?.window?.decorView?.visibility = View.VISIBLE
406
+ showDialog()
328
407
  }
329
408
  }
330
409
  )
@@ -336,6 +415,61 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
336
415
  rnScreensObserver = null
337
416
  }
338
417
 
418
+ // ====================================================================
419
+ // MARK: - Dialog Visibility (for stacking)
420
+ // ====================================================================
421
+
422
+ /**
423
+ * Returns true if the sheet's top is at or above the status bar.
424
+ */
425
+ val isExpanded: Boolean
426
+ get() {
427
+ val sheetTop = bottomSheetView?.top ?: return false
428
+ return sheetTop <= statusBarHeight
429
+ }
430
+
431
+ /**
432
+ * Returns the current top position of the sheet (Y coordinate from screen top).
433
+ * Used for comparing sheet positions during stacking.
434
+ */
435
+ val currentSheetTop: Int
436
+ get() = bottomSheetView?.let { ScreenUtils.getScreenY(it) } ?: screenHeight
437
+
438
+ /**
439
+ * Returns the expected top position of the sheet when presented at the given detent index.
440
+ * Used for comparing sheet positions before presentation.
441
+ */
442
+ fun getExpectedSheetTop(detentIndex: Int): Int {
443
+ if (detentIndex < 0 || detentIndex >= detents.size) return screenHeight
444
+ val detentHeight = getDetentHeight(detents[detentIndex])
445
+ return screenHeight - detentHeight
446
+ }
447
+
448
+ /**
449
+ * Hides the dialog without dismissing it.
450
+ * Used when another TrueSheet presents on top or when RN screen is presented.
451
+ */
452
+ fun hideDialog() {
453
+ isDialogVisible = false
454
+ dialog?.window?.decorView?.visibility = View.INVISIBLE
455
+
456
+ // Emit off-screen position (detent = 0 since sheet is fully hidden)
457
+ emitChangePositionDelegate(currentDetentIndex, screenHeight, realtime = false)
458
+ }
459
+
460
+ /**
461
+ * Shows a previously hidden dialog.
462
+ * Used when the sheet on top dismisses.
463
+ */
464
+ fun showDialog() {
465
+ isDialogVisible = true
466
+ dialog?.window?.decorView?.visibility = View.VISIBLE
467
+
468
+ // Emit current position
469
+ val positionPx = bottomSheetView?.let { ScreenUtils.getScreenY(it) } ?: screenHeight
470
+ emitChangePositionDelegate(currentDetentIndex, positionPx, realtime = false)
471
+ }
472
+
339
473
  // ====================================================================
340
474
  // MARK: - Presentation
341
475
  // ====================================================================
@@ -346,20 +480,25 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
346
480
  return
347
481
  }
348
482
 
349
- currentDetentIndex = detentIndex
350
483
  setupDimmedBackground(detentIndex)
351
484
 
352
485
  if (isPresented) {
353
- val detentInfo = getDetentInfoForIndex(detentIndex)
354
- delegate?.viewControllerDidChangeDetent(detentInfo.index, detentInfo.position)
486
+ // Detent change will be emitted when sheet settles in onStateChanged
487
+ // Don't update currentDetentIndex here - it will be updated when sheet settles
355
488
  setStateForDetentIndex(detentIndex)
356
489
  } else {
490
+ currentDetentIndex = detentIndex
357
491
  isDragging = false
358
492
  setupSheetDetents()
359
493
  setStateForDetentIndex(detentIndex)
360
494
 
361
495
  val detentInfo = getDetentInfoForIndex(detentIndex)
362
- delegate?.viewControllerWillPresent(detentInfo.index, detentInfo.position)
496
+ val detent = getDetentValueForIndex(detentInfo.index)
497
+
498
+ // Notify parent sheet that it is about to lose focus (before this sheet appears)
499
+ parentSheetView?.viewControllerWillBlur()
500
+
501
+ delegate?.viewControllerWillPresent(detentInfo.index, detentInfo.position, detent)
363
502
 
364
503
  if (!animated) {
365
504
  dialog.window?.setWindowAnimations(0)
@@ -371,8 +510,8 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
371
510
 
372
511
  fun dismiss() {
373
512
  this.post {
374
- val offScreenPosition = screenHeight.pxToDp()
375
- delegate?.viewControllerDidChangePosition(currentDetentIndex, offScreenPosition, transitioning = true)
513
+ // Emit off-screen position (detent = 0 since sheet is fully hidden)
514
+ emitChangePositionDelegate(currentDetentIndex, screenHeight, realtime = false)
376
515
  }
377
516
  dialog?.dismiss()
378
517
  }
@@ -384,39 +523,53 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
384
523
  fun setupSheetDetents() {
385
524
  val behavior = this.behavior ?: return
386
525
 
526
+ // Reset resolved positions if detents count changed
527
+ if (resolvedDetentPositions.size != detents.size) {
528
+ resolvedDetentPositions.clear()
529
+ repeat(detents.size) { resolvedDetentPositions.add(0) }
530
+ }
531
+
532
+ // Always update auto detent positions based on current content height
533
+ for (i in detents.indices) {
534
+ if (detents[i] == -1.0) {
535
+ val detentHeight = getDetentHeight(detents[i])
536
+ resolvedDetentPositions[i] = screenHeight - detentHeight
537
+ }
538
+ }
539
+
540
+ // Flag to prevent state change callbacks from updating detent index during reconfiguration
541
+ isReconfiguring = true
542
+
387
543
  behavior.apply {
388
544
  skipCollapsed = false
389
- isFitToContents = true
545
+ isFitToContents = false
390
546
  maxWidth = DEFAULT_MAX_WIDTH.dpToPx().toInt()
391
547
 
392
548
  when (detents.size) {
393
549
  1 -> {
394
- maxHeight = getDetentHeight(detents[0])
395
- skipCollapsed = true
396
-
397
- if (isPresented && detents[0] == -1.0) {
398
- sheetContainer?.apply {
399
- val params = layoutParams
400
- params.height = maxHeight
401
- layoutParams = params
402
- }
403
- }
550
+ setPeekHeight(getDetentHeight(detents[0]), isPresented)
551
+ expandedOffset = screenHeight - peekHeight
404
552
  }
405
553
 
406
554
  2 -> {
407
555
  setPeekHeight(getDetentHeight(detents[0]), isPresented)
408
- maxHeight = getDetentHeight(detents[1])
556
+ expandedOffset = screenHeight - getDetentHeight(detents[1])
409
557
  }
410
558
 
411
559
  3 -> {
412
- isFitToContents = false
413
560
  setPeekHeight(getDetentHeight(detents[0]), isPresented)
414
- maxHeight = getDetentHeight(detents[2])
415
- expandedOffset = sheetTopInset
416
561
  halfExpandedRatio = minOf(getDetentHeight(detents[1]).toFloat() / screenHeight.toFloat(), 1.0f)
562
+ expandedOffset = screenHeight - getDetentHeight(detents[2])
417
563
  }
418
564
  }
419
565
  }
566
+
567
+ // Re-apply current state to update position after config changes
568
+ if (isPresented) {
569
+ setStateForDetentIndex(currentDetentIndex)
570
+ }
571
+
572
+ isReconfiguring = false
420
573
  }
421
574
 
422
575
  fun setupGrabber() {
@@ -426,23 +579,10 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
426
579
  bottomSheet.removeView(it)
427
580
  }
428
581
 
429
- if (!grabber) return
582
+ if (!grabber || !draggable) return
430
583
 
431
- val grabberView = View(reactContext).apply {
584
+ val grabberView = TrueSheetGrabberView(reactContext, grabberOptions).apply {
432
585
  tag = GRABBER_TAG
433
- layoutParams = FrameLayout.LayoutParams(
434
- GRABBER_WIDTH.dpToPx().toInt(),
435
- GRABBER_HEIGHT.dpToPx().toInt()
436
- ).apply {
437
- gravity = Gravity.CENTER_HORIZONTAL or Gravity.TOP
438
- topMargin = GRABBER_TOP_MARGIN.dpToPx().toInt()
439
- }
440
- background = GradientDrawable().apply {
441
- shape = GradientDrawable.RECTANGLE
442
- cornerRadius = (GRABBER_HEIGHT / 2).dpToPx()
443
- setColor(GRABBER_COLOR)
444
- }
445
- elevation = 1f
446
586
  }
447
587
 
448
588
  bottomSheet.addView(grabberView)
@@ -538,6 +678,159 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
538
678
  }
539
679
  }
540
680
 
681
+ // ====================================================================
682
+ // MARK: - Position Change Delegate
683
+ // ====================================================================
684
+
685
+ /**
686
+ * Emits position change to the delegate if the position has changed.
687
+ * @param index The current detent index (discrete, used as fallback)
688
+ * @param positionPx The current position in pixels (screen Y coordinate)
689
+ * @param realtime Whether the position is a real-time value (during drag or animation tracking)
690
+ */
691
+ private fun emitChangePositionDelegate(index: Int, positionPx: Int, realtime: Boolean) {
692
+ if (positionPx == lastEmittedPositionPx) return
693
+
694
+ lastEmittedPositionPx = positionPx
695
+ val position = positionPx.pxToDp()
696
+ val interpolatedIndex = getInterpolatedIndexForPosition(positionPx)
697
+ val detent = getInterpolatedDetentForPosition(positionPx)
698
+ delegate?.viewControllerDidChangePosition(interpolatedIndex, position, detent, realtime)
699
+ }
700
+
701
+ /**
702
+ * Stores the current Y position as the resolved position for the given detent index.
703
+ * This is called when the sheet settles at a detent to capture the actual position
704
+ * which may differ from the calculated position due to system adjustments.
705
+ */
706
+ private fun storeResolvedPosition(index: Int) {
707
+ if (index < 0 || index >= resolvedDetentPositions.size) return
708
+ val positionPx = bottomSheetView?.let { ScreenUtils.getScreenY(it) } ?: return
709
+ if (positionPx in 1..<screenHeight) {
710
+ resolvedDetentPositions[index] = positionPx
711
+ }
712
+ }
713
+
714
+ /**
715
+ * Stores the resolved position for the current detent.
716
+ * Called from TrueSheetView when content size changes.
717
+ */
718
+ fun storeCurrentResolvedPosition() {
719
+ storeResolvedPosition(currentDetentIndex)
720
+ }
721
+
722
+ /**
723
+ * Returns the estimated Y position for a detent index, using stored positions when available.
724
+ */
725
+ private fun getEstimatedPositionForIndex(index: Int): Int {
726
+ if (index < 0 || index >= resolvedDetentPositions.size) return screenHeight
727
+
728
+ val storedPos = resolvedDetentPositions[index]
729
+ if (storedPos > 0) return storedPos
730
+
731
+ // Estimate based on getDetentHeight which accounts for bottomInset and maxAllowedHeight
732
+ if (index < detents.size) {
733
+ val detentHeight = getDetentHeight(detents[index])
734
+ return screenHeight - detentHeight
735
+ }
736
+
737
+ return screenHeight
738
+ }
739
+
740
+ /**
741
+ * Finds the segment index and interpolation progress for a given position.
742
+ * Returns a Triple of (fromIndex, toIndex, progress) where progress is 0-1 within that segment.
743
+ * Returns null if position count is less than 2.
744
+ */
745
+ private fun findSegmentForPosition(positionPx: Int): Triple<Int, Int, Float>? {
746
+ val count = resolvedDetentPositions.size
747
+ if (count < 2) return null
748
+
749
+ val firstPos = getEstimatedPositionForIndex(0)
750
+ val lastPos = getEstimatedPositionForIndex(count - 1)
751
+
752
+ // Below first detent
753
+ if (positionPx > firstPos) {
754
+ val range = screenHeight - firstPos
755
+ val progress = if (range > 0) (positionPx - firstPos).toFloat() / range else 0f
756
+ return Triple(-1, 0, progress) // Special index -1 for below first
757
+ }
758
+
759
+ // Above last detent
760
+ if (positionPx < lastPos) {
761
+ return Triple(count - 1, count - 1, 0f)
762
+ }
763
+
764
+ // Find segment (positions decrease as index increases)
765
+ for (i in 0 until count - 1) {
766
+ val pos = getEstimatedPositionForIndex(i)
767
+ val nextPos = getEstimatedPositionForIndex(i + 1)
768
+
769
+ if (positionPx <= pos && positionPx >= nextPos) {
770
+ val range = pos - nextPos
771
+ val progress = if (range > 0) (pos - positionPx).toFloat() / range else 0f
772
+ return Triple(i, i + 1, maxOf(0f, minOf(1f, progress)))
773
+ }
774
+ }
775
+
776
+ return Triple(count - 1, count - 1, 0f)
777
+ }
778
+
779
+ /**
780
+ * Calculates the interpolated index based on position.
781
+ * Returns a continuous value (e.g., 0.5 means halfway between detent 0 and 1).
782
+ */
783
+ private fun getInterpolatedIndexForPosition(positionPx: Int): Float {
784
+ val count = resolvedDetentPositions.size
785
+ if (count == 0) return -1f
786
+ if (count == 1) return 0f
787
+
788
+ val segment = findSegmentForPosition(positionPx) ?: return 0f
789
+ val (fromIndex, _, progress) = segment
790
+
791
+ // Below first detent
792
+ if (fromIndex == -1) return -progress
793
+
794
+ return fromIndex + progress
795
+ }
796
+
797
+ /**
798
+ * Calculates the interpolated detent value based on position.
799
+ * Returns the actual screen fraction, clamped to valid detent range.
800
+ */
801
+ private fun getInterpolatedDetentForPosition(positionPx: Int): Float {
802
+ val count = resolvedDetentPositions.size
803
+ if (count == 0) return 0f
804
+
805
+ val segment = findSegmentForPosition(positionPx) ?: return getDetentValueForIndex(0)
806
+ val (fromIndex, toIndex, progress) = segment
807
+
808
+ // Below first detent
809
+ if (fromIndex == -1) {
810
+ val firstDetent = getDetentValueForIndex(0)
811
+ return maxOf(0f, firstDetent * (1 - progress))
812
+ }
813
+
814
+ val fromDetent = getDetentValueForIndex(fromIndex)
815
+ val toDetent = getDetentValueForIndex(toIndex)
816
+ return fromDetent + progress * (toDetent - fromDetent)
817
+ }
818
+
819
+ /**
820
+ * Gets the detent value (fraction) for a given index.
821
+ * Returns the raw screen fraction without bottomInset for interpolation calculations.
822
+ * Note: bottomInset is only added in getDetentHeight() for actual sheet sizing.
823
+ */
824
+ private fun getDetentValueForIndex(index: Int): Float {
825
+ if (index < 0 || index >= detents.size) return 0f
826
+ val value = detents[index]
827
+ return if (value == -1.0) {
828
+ (contentHeight + headerHeight).toFloat() / screenHeight.toFloat()
829
+ } else {
830
+ value.toFloat()
831
+ }
832
+ }
833
+
541
834
  // ====================================================================
542
835
  // MARK: - Drag Handling
543
836
  // ====================================================================
@@ -547,36 +840,40 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
547
840
  return DetentInfo(currentDetentIndex, screenY.pxToDp())
548
841
  }
549
842
 
843
+ private fun getCurrentPositionPx(sheetView: View): Int = ScreenUtils.getScreenY(sheetView)
844
+
845
+ /**
846
+ * Returns the detent index for the current position.
847
+ * Only reports a higher index when the sheet has reached that detent's height.
848
+ */
849
+ private fun getDetentIndexForPosition(positionPx: Int): Int {
850
+ if (detents.isEmpty()) return 0
851
+
852
+ val sheetHeight = screenHeight - positionPx
853
+
854
+ // Find the highest detent index that the sheet has reached
855
+ for (i in detents.indices.reversed()) {
856
+ val detentHeight = getDetentHeight(detents[i])
857
+ if (sheetHeight >= detentHeight) {
858
+ return i
859
+ }
860
+ }
861
+
862
+ return 0
863
+ }
864
+
550
865
  private fun handleDragBegin(sheetView: View) {
551
866
  val detentInfo = getCurrentDetentInfo(sheetView)
552
- delegate?.viewControllerDidDragBegin(detentInfo.index, detentInfo.position)
867
+ val detent = getDetentValueForIndex(detentInfo.index)
868
+ delegate?.viewControllerDidDragBegin(detentInfo.index, detentInfo.position, detent)
553
869
  isDragging = true
554
870
  }
555
871
 
556
872
  private fun handleDragChange(sheetView: View) {
557
873
  if (!isDragging) return
558
874
  val detentInfo = getCurrentDetentInfo(sheetView)
559
- delegate?.viewControllerDidDragChange(detentInfo.index, detentInfo.position)
560
- }
561
-
562
- private fun handleDragEnd(state: Int) {
563
- if (!isDragging) return
564
-
565
- val detentInfo = getDetentInfoForState(state)
566
- detentInfo?.let {
567
- delegate?.viewControllerDidDragEnd(it.index, it.position)
568
-
569
- if (it.index != currentDetentIndex) {
570
- presentPromise?.invoke()
571
- presentPromise = null
572
-
573
- currentDetentIndex = it.index
574
- setupDimmedBackground(it.index)
575
- delegate?.viewControllerDidChangeDetent(it.index, it.position)
576
- }
577
- }
578
-
579
- isDragging = false
875
+ val detent = getDetentValueForIndex(detentInfo.index)
876
+ delegate?.viewControllerDidDragChange(detentInfo.index, detentInfo.position, detent)
580
877
  }
581
878
 
582
879
  // ====================================================================
@@ -585,12 +882,15 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
585
882
 
586
883
  private fun getDetentHeight(detent: Double): Int {
587
884
  val height: Int = if (detent == -1.0) {
588
- contentHeight + headerHeight
885
+ // For auto detent, add bottomInset to match iOS behavior where the system
886
+ // adds bottom safe area inset internally to the sheet height
887
+ contentHeight + headerHeight + bottomInset
589
888
  } else {
590
889
  if (detent <= 0.0 || detent > 1.0) {
591
890
  throw IllegalArgumentException("TrueSheet: detent fraction ($detent) must be between 0 and 1")
592
891
  }
593
- (detent * screenHeight).toInt()
892
+ // For fractional detents, add bottomInset to match iOS behavior
893
+ (detent * screenHeight).toInt() + bottomInset
594
894
  }
595
895
 
596
896
  val maxAllowedHeight = screenHeight - sheetTopInset
@@ -669,7 +969,7 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
669
969
  super.onSizeChanged(w, h, oldw, oldh)
670
970
  if (w == oldw && h == oldh) return
671
971
 
672
- delegate?.viewControllerDidChangeSize(w, getEffectiveSheetHeight(h, headerHeight))
972
+ delegate?.viewControllerDidChangeSize(w, h)
673
973
 
674
974
  val oldScreenHeight = screenHeight
675
975
  screenHeight = ScreenUtils.getScreenHeight(reactContext, edgeToEdgeEnabled)
@@ -678,8 +978,9 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
678
978
  setupSheetDetents()
679
979
  this.post {
680
980
  positionFooter()
681
- val detentInfo = getDetentInfoForIndex(currentDetentIndex)
682
- delegate?.viewControllerDidChangePosition(detentInfo.index, detentInfo.position, transitioning = true)
981
+ storeResolvedPosition(currentDetentIndex)
982
+ val positionPx = bottomSheetView?.let { ScreenUtils.getScreenY(it) } ?: screenHeight
983
+ emitChangePositionDelegate(currentDetentIndex, positionPx, realtime = false)
683
984
  }
684
985
  }
685
986
  }
@@ -699,7 +1000,7 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
699
1000
  */
700
1001
  override fun dispatchTouchEvent(event: MotionEvent): Boolean {
701
1002
  val footer = containerView?.footerView
702
- if (footer != null && footer.visibility == View.VISIBLE) {
1003
+ if (footer != null && footer.isVisible) {
703
1004
  val footerLocation = ScreenUtils.getScreenLocation(footer)
704
1005
  val touchScreenX = event.rawX.toInt()
705
1006
  val touchScreenY = event.rawY.toInt()