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

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 (98) hide show
  1. package/README.md +13 -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 +48 -43
  5. package/android/src/main/java/com/lodev09/truesheet/TrueSheetViewController.kt +387 -88
  6. package/android/src/main/java/com/lodev09/truesheet/TrueSheetViewManager.kt +22 -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 +44 -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/ios/TrueSheetContainerView.mm +4 -0
  19. package/ios/TrueSheetContentView.h +2 -1
  20. package/ios/TrueSheetContentView.mm +91 -11
  21. package/ios/TrueSheetView.mm +65 -41
  22. package/ios/TrueSheetViewController.h +21 -10
  23. package/ios/TrueSheetViewController.mm +330 -165
  24. package/ios/core/TrueSheetBlurView.h +24 -0
  25. package/ios/{utils/ConversionUtil.mm → core/TrueSheetBlurView.mm} +65 -3
  26. package/ios/events/TrueSheetDragEvents.h +39 -0
  27. package/ios/events/TrueSheetDragEvents.mm +62 -0
  28. package/ios/events/{OnPositionChangeEvent.h → TrueSheetFocusEvents.h} +8 -5
  29. package/ios/events/TrueSheetFocusEvents.mm +49 -0
  30. package/ios/events/TrueSheetLifecycleEvents.h +40 -0
  31. package/ios/events/TrueSheetLifecycleEvents.mm +71 -0
  32. package/ios/events/TrueSheetStateEvents.h +35 -0
  33. package/ios/events/TrueSheetStateEvents.mm +49 -0
  34. package/ios/utils/GestureUtil.h +7 -0
  35. package/ios/utils/GestureUtil.mm +12 -0
  36. package/lib/module/TrueSheet.js +65 -12
  37. package/lib/module/TrueSheet.js.map +1 -1
  38. package/lib/module/fabric/TrueSheetViewNativeComponent.ts +15 -5
  39. package/lib/module/index.js +0 -1
  40. package/lib/module/index.js.map +1 -1
  41. package/lib/module/reanimated/ReanimatedTrueSheet.js +13 -7
  42. package/lib/module/reanimated/ReanimatedTrueSheet.js.map +1 -1
  43. package/lib/module/reanimated/ReanimatedTrueSheetProvider.js +4 -2
  44. package/lib/module/reanimated/ReanimatedTrueSheetProvider.js.map +1 -1
  45. package/lib/typescript/src/TrueSheet.d.ts +4 -0
  46. package/lib/typescript/src/TrueSheet.d.ts.map +1 -1
  47. package/lib/typescript/src/TrueSheet.types.d.ts +58 -6
  48. package/lib/typescript/src/TrueSheet.types.d.ts.map +1 -1
  49. package/lib/typescript/src/fabric/TrueSheetViewNativeComponent.d.ts +14 -5
  50. package/lib/typescript/src/fabric/TrueSheetViewNativeComponent.d.ts.map +1 -1
  51. package/lib/typescript/src/index.d.ts +0 -1
  52. package/lib/typescript/src/index.d.ts.map +1 -1
  53. package/lib/typescript/src/reanimated/ReanimatedTrueSheet.d.ts.map +1 -1
  54. package/lib/typescript/src/reanimated/ReanimatedTrueSheetProvider.d.ts +8 -2
  55. package/lib/typescript/src/reanimated/ReanimatedTrueSheetProvider.d.ts.map +1 -1
  56. package/package.json +1 -1
  57. package/src/TrueSheet.tsx +80 -10
  58. package/src/TrueSheet.types.ts +65 -6
  59. package/src/__mocks__/index.js +0 -5
  60. package/src/fabric/TrueSheetViewNativeComponent.ts +15 -5
  61. package/src/index.ts +0 -1
  62. package/src/reanimated/ReanimatedTrueSheet.tsx +12 -7
  63. package/src/reanimated/ReanimatedTrueSheetProvider.tsx +11 -3
  64. package/android/src/main/java/com/lodev09/truesheet/events/DetentChangeEvent.kt +0 -26
  65. package/android/src/main/java/com/lodev09/truesheet/events/DidDismissEvent.kt +0 -20
  66. package/android/src/main/java/com/lodev09/truesheet/events/DidPresentEvent.kt +0 -26
  67. package/android/src/main/java/com/lodev09/truesheet/events/DragBeginEvent.kt +0 -26
  68. package/android/src/main/java/com/lodev09/truesheet/events/DragChangeEvent.kt +0 -26
  69. package/android/src/main/java/com/lodev09/truesheet/events/DragEndEvent.kt +0 -26
  70. package/android/src/main/java/com/lodev09/truesheet/events/MountEvent.kt +0 -20
  71. package/android/src/main/java/com/lodev09/truesheet/events/PositionChangeEvent.kt +0 -32
  72. package/android/src/main/java/com/lodev09/truesheet/events/WillDismissEvent.kt +0 -20
  73. package/android/src/main/java/com/lodev09/truesheet/events/WillPresentEvent.kt +0 -26
  74. package/ios/events/OnDetentChangeEvent.h +0 -28
  75. package/ios/events/OnDetentChangeEvent.mm +0 -30
  76. package/ios/events/OnDidDismissEvent.h +0 -26
  77. package/ios/events/OnDidDismissEvent.mm +0 -25
  78. package/ios/events/OnDidPresentEvent.h +0 -28
  79. package/ios/events/OnDidPresentEvent.mm +0 -30
  80. package/ios/events/OnDragBeginEvent.h +0 -28
  81. package/ios/events/OnDragBeginEvent.mm +0 -30
  82. package/ios/events/OnDragChangeEvent.h +0 -28
  83. package/ios/events/OnDragChangeEvent.mm +0 -30
  84. package/ios/events/OnDragEndEvent.h +0 -28
  85. package/ios/events/OnDragEndEvent.mm +0 -30
  86. package/ios/events/OnMountEvent.h +0 -26
  87. package/ios/events/OnMountEvent.mm +0 -25
  88. package/ios/events/OnPositionChangeEvent.mm +0 -32
  89. package/ios/events/OnWillDismissEvent.h +0 -26
  90. package/ios/events/OnWillDismissEvent.mm +0 -25
  91. package/ios/events/OnWillPresentEvent.h +0 -28
  92. package/ios/events/OnWillPresentEvent.mm +0 -30
  93. package/ios/utils/ConversionUtil.h +0 -24
  94. package/lib/module/TrueSheetGrabber.js +0 -51
  95. package/lib/module/TrueSheetGrabber.js.map +0 -1
  96. package/lib/typescript/src/TrueSheetGrabber.d.ts +0 -39
  97. package/lib/typescript/src/TrueSheetGrabber.d.ts.map +0 -1
  98. 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
@@ -26,21 +25,26 @@ 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)
32
32
 
33
33
  interface TrueSheetViewControllerDelegate {
34
- fun viewControllerWillPresent(index: Int, position: Float)
35
- fun viewControllerDidPresent(index: Int, position: Float)
34
+ fun viewControllerWillPresent(index: Int, position: Float, detent: Float)
35
+ fun viewControllerDidPresent(index: Int, position: Float, detent: Float)
36
36
  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)
37
+ fun viewControllerDidDismiss(hadParent: Boolean)
38
+ fun viewControllerDidChangeDetent(index: Int, position: Float, detent: Float)
39
+ fun viewControllerDidDragBegin(index: Int, position: Float, detent: Float)
40
+ fun viewControllerDidDragChange(index: Int, position: Float, detent: Float)
41
+ fun viewControllerDidDragEnd(index: Int, position: Float, detent: Float)
42
+ fun viewControllerDidChangePosition(index: Float, position: Float, detent: Float, realtime: Boolean)
43
43
  fun viewControllerDidChangeSize(width: Int, height: Int)
44
+ fun viewControllerWillFocus()
45
+ fun viewControllerDidFocus()
46
+ fun viewControllerWillBlur()
47
+ fun viewControllerDidBlur()
44
48
  }
45
49
 
46
50
  /**
@@ -58,10 +62,6 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
58
62
  private const val GRABBER_TAG = "TrueSheetGrabber"
59
63
  private const val DEFAULT_MAX_WIDTH = 640 // dp
60
64
  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
65
 
66
66
  /**
67
67
  * Gets the effective sheet height by subtracting headerHeight * 2.
@@ -107,15 +107,26 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
107
107
  var isPresented = false
108
108
  private set
109
109
 
110
+ var isDialogVisible = false
111
+ private set
112
+
110
113
  var currentDetentIndex: Int = -1
111
114
  private set
112
115
 
116
+ // Resolved detent positions (Y coordinate when sheet rests at each detent)
117
+ private val resolvedDetentPositions = mutableListOf<Int>()
118
+
113
119
  private var isDragging = false
120
+ private var isReconfiguring = false
114
121
  private var windowAnimation: Int = 0
122
+ private var lastEmittedPositionPx: Int = -1
115
123
 
116
124
  var presentPromise: (() -> Unit)? = null
117
125
  var dismissPromise: (() -> Unit)? = null
118
126
 
127
+ // Reference to parent TrueSheetView (if presented from another sheet)
128
+ var parentSheetView: TrueSheetView? = null
129
+
119
130
  // ====================================================================
120
131
  // MARK: - Configuration Properties
121
132
  // ====================================================================
@@ -142,6 +153,12 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
142
153
  }
143
154
  }
144
155
 
156
+ var draggable: Boolean = true
157
+ set(value) {
158
+ field = value
159
+ behavior?.isDraggable = value
160
+ }
161
+
145
162
  // ====================================================================
146
163
  // MARK: - Computed Properties
147
164
  // ====================================================================
@@ -149,6 +166,13 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
149
166
  val statusBarHeight: Int
150
167
  get() = ScreenUtils.getStatusBarHeight(reactContext)
151
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
+
152
176
  /**
153
177
  * Edge-to-edge is enabled by default on API 36+, or when explicitly configured.
154
178
  */
@@ -186,8 +210,6 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
186
210
  */
187
211
  private var rnScreensObserver: RNScreensFragmentObserver? = null
188
212
 
189
- fun hasActiveModals(): Boolean = rnScreensObserver?.hasActiveModals() ?: false
190
-
191
213
  // ====================================================================
192
214
  // MARK: - Initialization
193
215
  // ====================================================================
@@ -208,7 +230,7 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
208
230
  val style = if (edgeToEdgeEnabled) {
209
231
  com.lodev09.truesheet.R.style.TrueSheetEdgeToEdgeEnabledDialog
210
232
  } else {
211
- 0
233
+ com.lodev09.truesheet.R.style.TrueSheetDialog
212
234
  }
213
235
 
214
236
  dialog = BottomSheetDialog(reactContext, style).apply {
@@ -225,6 +247,7 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
225
247
  setCanceledOnTouchOutside(dismissible)
226
248
  setCancelable(dismissible)
227
249
  behavior.isHideable = dismissible
250
+ behavior.isDraggable = draggable
228
251
  }
229
252
  }
230
253
 
@@ -241,19 +264,31 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
241
264
  dialog = null
242
265
  isDragging = false
243
266
  isPresented = false
267
+ isDialogVisible = false
268
+ lastEmittedPositionPx = -1
244
269
  }
245
270
 
246
271
  private fun setupDialogListeners(dialog: BottomSheetDialog) {
247
272
  dialog.setOnShowListener {
248
273
  isPresented = true
274
+ isDialogVisible = true
249
275
  resetAnimation()
250
276
  setupBackground()
251
277
  setupGrabber()
252
278
 
253
279
  sheetContainer?.post {
254
280
  val detentInfo = getDetentInfoForIndex(currentDetentIndex)
255
- delegate?.viewControllerDidPresent(detentInfo.index, detentInfo.position)
256
- delegate?.viewControllerDidChangePosition(detentInfo.index, detentInfo.position, transitioning = true)
281
+ val detent = getDetentValueForIndex(detentInfo.index)
282
+ val positionPx = bottomSheetView?.let { ScreenUtils.getScreenY(it) } ?: screenHeight
283
+
284
+ delegate?.viewControllerDidPresent(detentInfo.index, detentInfo.position, detent)
285
+
286
+ // Store resolved position for initial detent
287
+ storeResolvedPosition(detentInfo.index)
288
+ emitChangePositionDelegate(detentInfo.index, positionPx, realtime = false)
289
+
290
+ // Notify parent sheet that it has lost focus (after this sheet appeared)
291
+ parentSheetView?.viewControllerDidBlur()
257
292
 
258
293
  presentPromise?.invoke()
259
294
  presentPromise = null
@@ -263,13 +298,22 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
263
298
  }
264
299
 
265
300
  dialog.setOnCancelListener {
301
+ // Notify parent sheet that it is about to regain focus
302
+ parentSheetView?.viewControllerWillFocus()
303
+
266
304
  delegate?.viewControllerWillDismiss()
267
305
  }
268
306
 
269
307
  dialog.setOnDismissListener {
308
+ val hadParent = parentSheetView != null
309
+
310
+ // Notify parent sheet that it has regained focus
311
+ parentSheetView?.viewControllerDidFocus()
312
+ parentSheetView = null
313
+
270
314
  dismissPromise?.invoke()
271
315
  dismissPromise = null
272
- delegate?.viewControllerDidDismiss()
316
+ delegate?.viewControllerDidDismiss(hadParent)
273
317
  cleanupDialog()
274
318
  }
275
319
  }
@@ -279,8 +323,10 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
279
323
  object : BottomSheetBehavior.BottomSheetCallback() {
280
324
  override fun onSlide(sheetView: View, slideOffset: Float) {
281
325
  val behavior = behavior ?: return
282
- val detentInfo = getCurrentDetentInfo(sheetView)
283
- delegate?.viewControllerDidChangePosition(detentInfo.index, detentInfo.position, transitioning = false)
326
+ val positionPx = getCurrentPositionPx(sheetView)
327
+ val detentIndex = getDetentIndexForPosition(positionPx)
328
+
329
+ emitChangePositionDelegate(detentIndex, positionPx, realtime = true)
284
330
 
285
331
  when (behavior.state) {
286
332
  BottomSheetBehavior.STATE_DRAGGING,
@@ -305,7 +351,38 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
305
351
 
306
352
  BottomSheetBehavior.STATE_EXPANDED,
307
353
  BottomSheetBehavior.STATE_COLLAPSED,
308
- 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
+ }
309
386
 
310
387
  else -> {}
311
388
  }
@@ -319,12 +396,12 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
319
396
  reactContext = reactContext,
320
397
  onModalPresented = {
321
398
  if (isPresented) {
322
- dialog?.window?.decorView?.visibility = View.INVISIBLE
399
+ hideDialog()
323
400
  }
324
401
  },
325
402
  onModalDismissed = {
326
403
  if (isPresented) {
327
- dialog?.window?.decorView?.visibility = View.VISIBLE
404
+ showDialog()
328
405
  }
329
406
  }
330
407
  )
@@ -336,6 +413,61 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
336
413
  rnScreensObserver = null
337
414
  }
338
415
 
416
+ // ====================================================================
417
+ // MARK: - Dialog Visibility (for stacking)
418
+ // ====================================================================
419
+
420
+ /**
421
+ * Returns true if the sheet's top is at or above the status bar.
422
+ */
423
+ val isExpanded: Boolean
424
+ get() {
425
+ val sheetTop = bottomSheetView?.top ?: return false
426
+ return sheetTop <= statusBarHeight
427
+ }
428
+
429
+ /**
430
+ * Returns the current top position of the sheet (Y coordinate from screen top).
431
+ * Used for comparing sheet positions during stacking.
432
+ */
433
+ val currentSheetTop: Int
434
+ get() = bottomSheetView?.let { ScreenUtils.getScreenY(it) } ?: screenHeight
435
+
436
+ /**
437
+ * Returns the expected top position of the sheet when presented at the given detent index.
438
+ * Used for comparing sheet positions before presentation.
439
+ */
440
+ fun getExpectedSheetTop(detentIndex: Int): Int {
441
+ if (detentIndex < 0 || detentIndex >= detents.size) return screenHeight
442
+ val detentHeight = getDetentHeight(detents[detentIndex])
443
+ return screenHeight - detentHeight
444
+ }
445
+
446
+ /**
447
+ * Hides the dialog without dismissing it.
448
+ * Used when another TrueSheet presents on top or when RN screen is presented.
449
+ */
450
+ fun hideDialog() {
451
+ isDialogVisible = false
452
+ dialog?.window?.decorView?.visibility = View.INVISIBLE
453
+
454
+ // Emit off-screen position (detent = 0 since sheet is fully hidden)
455
+ emitChangePositionDelegate(currentDetentIndex, screenHeight, realtime = false)
456
+ }
457
+
458
+ /**
459
+ * Shows a previously hidden dialog.
460
+ * Used when the sheet on top dismisses.
461
+ */
462
+ fun showDialog() {
463
+ isDialogVisible = true
464
+ dialog?.window?.decorView?.visibility = View.VISIBLE
465
+
466
+ // Emit current position
467
+ val positionPx = bottomSheetView?.let { ScreenUtils.getScreenY(it) } ?: screenHeight
468
+ emitChangePositionDelegate(currentDetentIndex, positionPx, realtime = false)
469
+ }
470
+
339
471
  // ====================================================================
340
472
  // MARK: - Presentation
341
473
  // ====================================================================
@@ -346,20 +478,25 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
346
478
  return
347
479
  }
348
480
 
349
- currentDetentIndex = detentIndex
350
481
  setupDimmedBackground(detentIndex)
351
482
 
352
483
  if (isPresented) {
353
- val detentInfo = getDetentInfoForIndex(detentIndex)
354
- delegate?.viewControllerDidChangeDetent(detentInfo.index, detentInfo.position)
484
+ // Detent change will be emitted when sheet settles in onStateChanged
485
+ // Don't update currentDetentIndex here - it will be updated when sheet settles
355
486
  setStateForDetentIndex(detentIndex)
356
487
  } else {
488
+ currentDetentIndex = detentIndex
357
489
  isDragging = false
358
490
  setupSheetDetents()
359
491
  setStateForDetentIndex(detentIndex)
360
492
 
361
493
  val detentInfo = getDetentInfoForIndex(detentIndex)
362
- delegate?.viewControllerWillPresent(detentInfo.index, detentInfo.position)
494
+ val detent = getDetentValueForIndex(detentInfo.index)
495
+
496
+ // Notify parent sheet that it is about to lose focus (before this sheet appears)
497
+ parentSheetView?.viewControllerWillBlur()
498
+
499
+ delegate?.viewControllerWillPresent(detentInfo.index, detentInfo.position, detent)
363
500
 
364
501
  if (!animated) {
365
502
  dialog.window?.setWindowAnimations(0)
@@ -371,8 +508,8 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
371
508
 
372
509
  fun dismiss() {
373
510
  this.post {
374
- val offScreenPosition = screenHeight.pxToDp()
375
- delegate?.viewControllerDidChangePosition(currentDetentIndex, offScreenPosition, transitioning = true)
511
+ // Emit off-screen position (detent = 0 since sheet is fully hidden)
512
+ emitChangePositionDelegate(currentDetentIndex, screenHeight, realtime = false)
376
513
  }
377
514
  dialog?.dismiss()
378
515
  }
@@ -384,39 +521,53 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
384
521
  fun setupSheetDetents() {
385
522
  val behavior = this.behavior ?: return
386
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
+
387
541
  behavior.apply {
388
542
  skipCollapsed = false
389
- isFitToContents = true
543
+ isFitToContents = false
390
544
  maxWidth = DEFAULT_MAX_WIDTH.dpToPx().toInt()
391
545
 
392
546
  when (detents.size) {
393
547
  1 -> {
394
- maxHeight = getDetentHeight(detents[0])
395
- skipCollapsed = true
548
+ setPeekHeight(getDetentHeight(detents[0]), isPresented)
549
+ expandedOffset = screenHeight - peekHeight
396
550
  }
397
551
 
398
552
  2 -> {
399
553
  setPeekHeight(getDetentHeight(detents[0]), isPresented)
400
- maxHeight = getDetentHeight(detents[1])
554
+ expandedOffset = screenHeight - getDetentHeight(detents[1])
401
555
  }
402
556
 
403
557
  3 -> {
404
- isFitToContents = false
405
558
  setPeekHeight(getDetentHeight(detents[0]), isPresented)
406
- maxHeight = getDetentHeight(detents[2])
407
- expandedOffset = sheetTopInset
408
559
  halfExpandedRatio = minOf(getDetentHeight(detents[1]).toFloat() / screenHeight.toFloat(), 1.0f)
560
+ expandedOffset = screenHeight - getDetentHeight(detents[2])
409
561
  }
410
562
  }
563
+ }
411
564
 
412
- if (isPresented) {
413
- sheetContainer?.apply {
414
- val params = layoutParams
415
- params.height = maxHeight
416
- layoutParams = params
417
- }
418
- }
565
+ // Re-apply current state to update position after config changes
566
+ if (isPresented) {
567
+ setStateForDetentIndex(currentDetentIndex)
419
568
  }
569
+
570
+ isReconfiguring = false
420
571
  }
421
572
 
422
573
  fun setupGrabber() {
@@ -426,23 +577,10 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
426
577
  bottomSheet.removeView(it)
427
578
  }
428
579
 
429
- if (!grabber) return
580
+ if (!grabber || !draggable) return
430
581
 
431
- val grabberView = View(reactContext).apply {
582
+ val grabberView = TrueSheetGrabberView(reactContext).apply {
432
583
  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
584
  }
447
585
 
448
586
  bottomSheet.addView(grabberView)
@@ -538,6 +676,159 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
538
676
  }
539
677
  }
540
678
 
679
+ // ====================================================================
680
+ // MARK: - Position Change Delegate
681
+ // ====================================================================
682
+
683
+ /**
684
+ * Emits position change to the delegate if the position has changed.
685
+ * @param index The current detent index (discrete, used as fallback)
686
+ * @param positionPx The current position in pixels (screen Y coordinate)
687
+ * @param realtime Whether the position is a real-time value (during drag or animation tracking)
688
+ */
689
+ private fun emitChangePositionDelegate(index: Int, positionPx: Int, realtime: Boolean) {
690
+ if (positionPx == lastEmittedPositionPx) return
691
+
692
+ lastEmittedPositionPx = positionPx
693
+ val position = positionPx.pxToDp()
694
+ val interpolatedIndex = getInterpolatedIndexForPosition(positionPx)
695
+ val detent = getInterpolatedDetentForPosition(positionPx)
696
+ delegate?.viewControllerDidChangePosition(interpolatedIndex, position, detent, realtime)
697
+ }
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
+
777
+ /**
778
+ * Calculates the interpolated index based on position.
779
+ * Returns a continuous value (e.g., 0.5 means halfway between detent 0 and 1).
780
+ */
781
+ private fun getInterpolatedIndexForPosition(positionPx: Int): Float {
782
+ val count = resolvedDetentPositions.size
783
+ if (count == 0) return -1f
784
+ if (count == 1) return 0f
785
+
786
+ val segment = findSegmentForPosition(positionPx) ?: return 0f
787
+ val (fromIndex, _, progress) = segment
788
+
789
+ // Below first detent
790
+ if (fromIndex == -1) return -progress
791
+
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))
810
+ }
811
+
812
+ val fromDetent = getDetentValueForIndex(fromIndex)
813
+ val toDetent = getDetentValueForIndex(toIndex)
814
+ return fromDetent + progress * (toDetent - fromDetent)
815
+ }
816
+
817
+ /**
818
+ * Gets the detent value (fraction) for a given index.
819
+ * Returns the raw screen fraction without bottomInset for interpolation calculations.
820
+ * Note: bottomInset is only added in getDetentHeight() for actual sheet sizing.
821
+ */
822
+ private fun getDetentValueForIndex(index: Int): Float {
823
+ if (index < 0 || index >= detents.size) return 0f
824
+ val value = detents[index]
825
+ return if (value == -1.0) {
826
+ (contentHeight + headerHeight).toFloat() / screenHeight.toFloat()
827
+ } else {
828
+ value.toFloat()
829
+ }
830
+ }
831
+
541
832
  // ====================================================================
542
833
  // MARK: - Drag Handling
543
834
  // ====================================================================
@@ -547,36 +838,40 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
547
838
  return DetentInfo(currentDetentIndex, screenY.pxToDp())
548
839
  }
549
840
 
841
+ private fun getCurrentPositionPx(sheetView: View): Int = ScreenUtils.getScreenY(sheetView)
842
+
843
+ /**
844
+ * Returns the detent index for the current position.
845
+ * Only reports a higher index when the sheet has reached that detent's height.
846
+ */
847
+ private fun getDetentIndexForPosition(positionPx: Int): Int {
848
+ if (detents.isEmpty()) return 0
849
+
850
+ val sheetHeight = screenHeight - positionPx
851
+
852
+ // Find the highest detent index that the sheet has reached
853
+ for (i in detents.indices.reversed()) {
854
+ val detentHeight = getDetentHeight(detents[i])
855
+ if (sheetHeight >= detentHeight) {
856
+ return i
857
+ }
858
+ }
859
+
860
+ return 0
861
+ }
862
+
550
863
  private fun handleDragBegin(sheetView: View) {
551
864
  val detentInfo = getCurrentDetentInfo(sheetView)
552
- delegate?.viewControllerDidDragBegin(detentInfo.index, detentInfo.position)
865
+ val detent = getDetentValueForIndex(detentInfo.index)
866
+ delegate?.viewControllerDidDragBegin(detentInfo.index, detentInfo.position, detent)
553
867
  isDragging = true
554
868
  }
555
869
 
556
870
  private fun handleDragChange(sheetView: View) {
557
871
  if (!isDragging) return
558
872
  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
873
+ val detent = getDetentValueForIndex(detentInfo.index)
874
+ delegate?.viewControllerDidDragChange(detentInfo.index, detentInfo.position, detent)
580
875
  }
581
876
 
582
877
  // ====================================================================
@@ -585,12 +880,15 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
585
880
 
586
881
  private fun getDetentHeight(detent: Double): Int {
587
882
  val height: Int = if (detent == -1.0) {
588
- 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
589
886
  } else {
590
887
  if (detent <= 0.0 || detent > 1.0) {
591
888
  throw IllegalArgumentException("TrueSheet: detent fraction ($detent) must be between 0 and 1")
592
889
  }
593
- (detent * screenHeight).toInt()
890
+ // For fractional detents, add bottomInset to match iOS behavior
891
+ (detent * screenHeight).toInt() + bottomInset
594
892
  }
595
893
 
596
894
  val maxAllowedHeight = screenHeight - sheetTopInset
@@ -669,7 +967,7 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
669
967
  super.onSizeChanged(w, h, oldw, oldh)
670
968
  if (w == oldw && h == oldh) return
671
969
 
672
- delegate?.viewControllerDidChangeSize(w, getEffectiveSheetHeight(h, headerHeight))
970
+ delegate?.viewControllerDidChangeSize(w, h)
673
971
 
674
972
  val oldScreenHeight = screenHeight
675
973
  screenHeight = ScreenUtils.getScreenHeight(reactContext, edgeToEdgeEnabled)
@@ -678,8 +976,9 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
678
976
  setupSheetDetents()
679
977
  this.post {
680
978
  positionFooter()
681
- val detentInfo = getDetentInfoForIndex(currentDetentIndex)
682
- delegate?.viewControllerDidChangePosition(detentInfo.index, detentInfo.position, transitioning = true)
979
+ storeResolvedPosition(currentDetentIndex)
980
+ val positionPx = bottomSheetView?.let { ScreenUtils.getScreenY(it) } ?: screenHeight
981
+ emitChangePositionDelegate(currentDetentIndex, positionPx, realtime = false)
683
982
  }
684
983
  }
685
984
  }
@@ -699,7 +998,7 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
699
998
  */
700
999
  override fun dispatchTouchEvent(event: MotionEvent): Boolean {
701
1000
  val footer = containerView?.footerView
702
- if (footer != null && footer.visibility == View.VISIBLE) {
1001
+ if (footer != null && footer.isVisible) {
703
1002
  val footerLocation = ScreenUtils.getScreenLocation(footer)
704
1003
  val touchScreenX = event.rawX.toInt()
705
1004
  val touchScreenY = event.rawY.toInt()