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

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 (60) hide show
  1. package/README.md +1 -1
  2. package/android/src/main/java/com/lodev09/truesheet/TrueSheetModule.kt +3 -1
  3. package/android/src/main/java/com/lodev09/truesheet/TrueSheetView.kt +33 -45
  4. package/android/src/main/java/com/lodev09/truesheet/TrueSheetViewController.kt +151 -22
  5. package/android/src/main/java/com/lodev09/truesheet/core/TrueSheetDialogObserver.kt +93 -0
  6. package/android/src/main/java/com/lodev09/truesheet/events/BlurEvent.kt +20 -0
  7. package/android/src/main/java/com/lodev09/truesheet/events/DetentChangeEvent.kt +2 -1
  8. package/android/src/main/java/com/lodev09/truesheet/events/DidPresentEvent.kt +2 -1
  9. package/android/src/main/java/com/lodev09/truesheet/events/DragBeginEvent.kt +2 -1
  10. package/android/src/main/java/com/lodev09/truesheet/events/DragChangeEvent.kt +2 -1
  11. package/android/src/main/java/com/lodev09/truesheet/events/DragEndEvent.kt +2 -1
  12. package/android/src/main/java/com/lodev09/truesheet/events/FocusEvent.kt +20 -0
  13. package/android/src/main/java/com/lodev09/truesheet/events/PositionChangeEvent.kt +5 -3
  14. package/android/src/main/java/com/lodev09/truesheet/events/WillPresentEvent.kt +2 -1
  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/TrueSheetView.mm +26 -8
  19. package/ios/TrueSheetViewController.h +7 -1
  20. package/ios/TrueSheetViewController.mm +110 -40
  21. package/ios/events/OnDetentChangeEvent.h +2 -1
  22. package/ios/events/OnDetentChangeEvent.mm +3 -1
  23. package/ios/events/OnDidBlurEvent.h +26 -0
  24. package/ios/events/OnDidBlurEvent.mm +25 -0
  25. package/ios/events/OnDidFocusEvent.h +26 -0
  26. package/ios/events/OnDidFocusEvent.mm +25 -0
  27. package/ios/events/OnDidPresentEvent.h +2 -1
  28. package/ios/events/OnDidPresentEvent.mm +3 -1
  29. package/ios/events/OnDragBeginEvent.h +2 -1
  30. package/ios/events/OnDragBeginEvent.mm +3 -1
  31. package/ios/events/OnDragChangeEvent.h +2 -1
  32. package/ios/events/OnDragChangeEvent.mm +3 -1
  33. package/ios/events/OnDragEndEvent.h +2 -1
  34. package/ios/events/OnDragEndEvent.mm +3 -1
  35. package/ios/events/OnPositionChangeEvent.h +2 -1
  36. package/ios/events/OnPositionChangeEvent.mm +4 -2
  37. package/ios/events/OnWillPresentEvent.h +2 -1
  38. package/ios/events/OnWillPresentEvent.mm +3 -1
  39. package/lib/module/TrueSheet.js +10 -0
  40. package/lib/module/TrueSheet.js.map +1 -1
  41. package/lib/module/fabric/TrueSheetViewNativeComponent.ts +5 -1
  42. package/lib/module/reanimated/ReanimatedTrueSheet.js +9 -4
  43. package/lib/module/reanimated/ReanimatedTrueSheet.js.map +1 -1
  44. package/lib/module/reanimated/ReanimatedTrueSheetProvider.js +4 -2
  45. package/lib/module/reanimated/ReanimatedTrueSheetProvider.js.map +1 -1
  46. package/lib/typescript/src/TrueSheet.d.ts +2 -0
  47. package/lib/typescript/src/TrueSheet.d.ts.map +1 -1
  48. package/lib/typescript/src/TrueSheet.types.d.ts +14 -0
  49. package/lib/typescript/src/TrueSheet.types.d.ts.map +1 -1
  50. package/lib/typescript/src/fabric/TrueSheetViewNativeComponent.d.ts +5 -1
  51. package/lib/typescript/src/fabric/TrueSheetViewNativeComponent.d.ts.map +1 -1
  52. package/lib/typescript/src/reanimated/ReanimatedTrueSheet.d.ts.map +1 -1
  53. package/lib/typescript/src/reanimated/ReanimatedTrueSheetProvider.d.ts +8 -2
  54. package/lib/typescript/src/reanimated/ReanimatedTrueSheetProvider.d.ts.map +1 -1
  55. package/package.json +1 -1
  56. package/src/TrueSheet.tsx +14 -0
  57. package/src/TrueSheet.types.ts +16 -0
  58. package/src/fabric/TrueSheetViewNativeComponent.ts +5 -1
  59. package/src/reanimated/ReanimatedTrueSheet.tsx +9 -4
  60. package/src/reanimated/ReanimatedTrueSheetProvider.tsx +11 -3
package/README.md CHANGED
@@ -13,7 +13,7 @@ The true native bottom sheet experience for your React Native Apps. 💩
13
13
  * 🚀 **Native** - Implemented in the native realm
14
14
  * 🎯 **Type-safe** - Full TypeScript support with Codegen-generated native interfaces
15
15
  * ♿ **Accessible** - Native accessibility and screen reader support out of the box
16
- * 🔄 **Imperative API** - Asynchronus `ref` [Methods](https://sheet.lodev09.com/reference/methods#ref-methods)
16
+ * 🔄 **Flexible API** - Use [imperative methods](https://sheet.lodev09.com/reference/methods#ref-methods) or [lifecycle events](https://sheet.lodev09.com/reference/props#events)
17
17
  * 🪟 **Liquid Glass** - iOS 26+ Liquid Glass support out of the box. Featured in [Expo Blog](https://expo.dev/blog/how-to-create-apple-maps-style-liquid-glass-sheets)
18
18
 
19
19
  ## Installation
@@ -8,6 +8,7 @@ import com.facebook.react.bridge.ReactMethod
8
8
  import com.facebook.react.module.annotations.ReactModule
9
9
  import com.facebook.react.turbomodule.core.interfaces.TurboModule
10
10
  import com.facebook.react.uimanager.UIManagerHelper
11
+ import com.lodev09.truesheet.core.TrueSheetDialogObserver
11
12
  import java.util.concurrent.ConcurrentHashMap
12
13
 
13
14
  /**
@@ -27,10 +28,11 @@ class TrueSheetModule(reactContext: ReactApplicationContext) :
27
28
 
28
29
  override fun invalidate() {
29
30
  super.invalidate()
30
- // Clear all registered views on module invalidation
31
+ // Clear all registered views and observer on module invalidation
31
32
  synchronized(viewRegistry) {
32
33
  viewRegistry.clear()
33
34
  }
35
+ TrueSheetDialogObserver.clear()
34
36
  }
35
37
 
36
38
  /**
@@ -2,7 +2,6 @@ package com.lodev09.truesheet
2
2
 
3
3
  import android.annotation.SuppressLint
4
4
  import android.view.View
5
- import android.view.ViewGroup
6
5
  import android.view.ViewStructure
7
6
  import android.view.accessibility.AccessibilityEvent
8
7
  import androidx.annotation.UiThread
@@ -14,12 +13,15 @@ import com.facebook.react.uimanager.ThemedReactContext
14
13
  import com.facebook.react.uimanager.UIManagerHelper
15
14
  import com.facebook.react.uimanager.events.EventDispatcher
16
15
  import com.facebook.react.views.view.ReactViewGroup
16
+ import com.lodev09.truesheet.core.TrueSheetDialogObserver
17
+ import com.lodev09.truesheet.events.BlurEvent
17
18
  import com.lodev09.truesheet.events.DetentChangeEvent
18
19
  import com.lodev09.truesheet.events.DidDismissEvent
19
20
  import com.lodev09.truesheet.events.DidPresentEvent
20
21
  import com.lodev09.truesheet.events.DragBeginEvent
21
22
  import com.lodev09.truesheet.events.DragChangeEvent
22
23
  import com.lodev09.truesheet.events.DragEndEvent
24
+ import com.lodev09.truesheet.events.FocusEvent
23
25
  import com.lodev09.truesheet.events.MountEvent
24
26
  import com.lodev09.truesheet.events.PositionChangeEvent
25
27
  import com.lodev09.truesheet.events.WillDismissEvent
@@ -36,7 +38,7 @@ class TrueSheetView(private val reactContext: ThemedReactContext) :
36
38
  TrueSheetViewControllerDelegate,
37
39
  TrueSheetContainerViewDelegate {
38
40
 
39
- private val viewController: TrueSheetViewController = TrueSheetViewController(reactContext)
41
+ internal val viewController: TrueSheetViewController = TrueSheetViewController(reactContext)
40
42
 
41
43
  private val containerView: TrueSheetContainerView?
42
44
  get() = viewController.getChildAt(0) as? TrueSheetContainerView
@@ -63,9 +65,6 @@ class TrueSheetView(private val reactContext: ThemedReactContext) :
63
65
  // Flag to prevent multiple pending sheet updates
64
66
  private var isSheetUpdatePending: Boolean = false
65
67
 
66
- // Reference to parent sheet's controller (for stacking support)
67
- private var parentViewController: TrueSheetViewController? = null
68
-
69
68
  init {
70
69
  reactContext.addLifecycleEventListener(this)
71
70
  viewController.delegate = this
@@ -144,6 +143,7 @@ class TrueSheetView(private val reactContext: ThemedReactContext) :
144
143
  fun onDropInstance() {
145
144
  reactContext.removeLifecycleEventListener(this)
146
145
  TrueSheetModule.unregisterView(id)
146
+ TrueSheetDialogObserver.removeSheet(this)
147
147
 
148
148
  if (viewController.isPresented) {
149
149
  viewController.dismiss()
@@ -164,14 +164,14 @@ class TrueSheetView(private val reactContext: ThemedReactContext) :
164
164
 
165
165
  // ==================== TrueSheetViewControllerDelegate Implementation ====================
166
166
 
167
- override fun viewControllerWillPresent(index: Int, position: Float) {
167
+ override fun viewControllerWillPresent(index: Int, position: Float, detent: Float) {
168
168
  val surfaceId = UIManagerHelper.getSurfaceId(this)
169
- eventDispatcher?.dispatchEvent(WillPresentEvent(surfaceId, id, index, position))
169
+ eventDispatcher?.dispatchEvent(WillPresentEvent(surfaceId, id, index, position, detent))
170
170
  }
171
171
 
172
- override fun viewControllerDidPresent(index: Int, position: Float) {
172
+ override fun viewControllerDidPresent(index: Int, position: Float, detent: Float) {
173
173
  val surfaceId = UIManagerHelper.getSurfaceId(this)
174
- eventDispatcher?.dispatchEvent(DidPresentEvent(surfaceId, id, index, position))
174
+ eventDispatcher?.dispatchEvent(DidPresentEvent(surfaceId, id, index, position, detent))
175
175
 
176
176
  // Enable touch event dispatching to React Native
177
177
  viewController.eventDispatcher = eventDispatcher
@@ -191,40 +191,49 @@ class TrueSheetView(private val reactContext: ThemedReactContext) :
191
191
  val surfaceId = UIManagerHelper.getSurfaceId(this)
192
192
  eventDispatcher?.dispatchEvent(DidDismissEvent(surfaceId, id))
193
193
 
194
- // Show parent sheet again if this was a stacked sheet
195
- parentViewController?.showDialog()
196
- parentViewController = null
194
+ // Notify observer that this sheet was dismissed (will show/focus parent sheet)
195
+ TrueSheetDialogObserver.onSheetDidDismiss(this)
197
196
  }
198
197
 
199
- override fun viewControllerDidChangeDetent(index: Int, position: Float) {
198
+ override fun viewControllerDidChangeDetent(index: Int, position: Float, detent: Float) {
200
199
  val surfaceId = UIManagerHelper.getSurfaceId(this)
201
- eventDispatcher?.dispatchEvent(DetentChangeEvent(surfaceId, id, index, position))
200
+ eventDispatcher?.dispatchEvent(DetentChangeEvent(surfaceId, id, index, position, detent))
202
201
  }
203
202
 
204
- override fun viewControllerDidDragBegin(index: Int, position: Float) {
203
+ override fun viewControllerDidDragBegin(index: Int, position: Float, detent: Float) {
205
204
  val surfaceId = UIManagerHelper.getSurfaceId(this)
206
- eventDispatcher?.dispatchEvent(DragBeginEvent(surfaceId, id, index, position))
205
+ eventDispatcher?.dispatchEvent(DragBeginEvent(surfaceId, id, index, position, detent))
207
206
  }
208
207
 
209
- override fun viewControllerDidDragChange(index: Int, position: Float) {
208
+ override fun viewControllerDidDragChange(index: Int, position: Float, detent: Float) {
210
209
  val surfaceId = UIManagerHelper.getSurfaceId(this)
211
- eventDispatcher?.dispatchEvent(DragChangeEvent(surfaceId, id, index, position))
210
+ eventDispatcher?.dispatchEvent(DragChangeEvent(surfaceId, id, index, position, detent))
212
211
  }
213
212
 
214
- override fun viewControllerDidDragEnd(index: Int, position: Float) {
213
+ override fun viewControllerDidDragEnd(index: Int, position: Float, detent: Float) {
215
214
  val surfaceId = UIManagerHelper.getSurfaceId(this)
216
- eventDispatcher?.dispatchEvent(DragEndEvent(surfaceId, id, index, position))
215
+ eventDispatcher?.dispatchEvent(DragEndEvent(surfaceId, id, index, position, detent))
217
216
  }
218
217
 
219
- override fun viewControllerDidChangePosition(index: Int, position: Float, transitioning: Boolean) {
218
+ override fun viewControllerDidChangePosition(index: Float, position: Float, detent: Float, transitioning: Boolean) {
220
219
  val surfaceId = UIManagerHelper.getSurfaceId(this)
221
- eventDispatcher?.dispatchEvent(PositionChangeEvent(surfaceId, id, index, position, transitioning))
220
+ eventDispatcher?.dispatchEvent(PositionChangeEvent(surfaceId, id, index, position, detent, transitioning))
222
221
  }
223
222
 
224
223
  override fun viewControllerDidChangeSize(width: Int, height: Int) {
225
224
  updateState(width, height)
226
225
  }
227
226
 
227
+ override fun viewControllerDidFocus() {
228
+ val surfaceId = UIManagerHelper.getSurfaceId(this)
229
+ eventDispatcher?.dispatchEvent(FocusEvent(surfaceId, id))
230
+ }
231
+
232
+ override fun viewControllerDidBlur() {
233
+ val surfaceId = UIManagerHelper.getSurfaceId(this)
234
+ eventDispatcher?.dispatchEvent(BlurEvent(surfaceId, id))
235
+ }
236
+
228
237
  // ==================== Property Setters (forward to controller) ====================
229
238
 
230
239
  fun setMaxHeight(height: Int) {
@@ -300,15 +309,9 @@ class TrueSheetView(private val reactContext: ThemedReactContext) :
300
309
 
301
310
  @UiThread
302
311
  fun present(detentIndex: Int, animated: Boolean = true, promiseCallback: () -> Unit) {
303
- // Find and hide parent sheet if this sheet is nested inside another TrueSheet
304
- // Only hide if parent is not expanded (otherwise it's already covering the screen)
312
+ // Notify observer that this sheet will present (will hide/blur topmost sheet)
305
313
  if (!viewController.isPresented) {
306
- parentViewController = findParentViewController()
307
- if (parentViewController?.isExpanded == false) {
308
- parentViewController?.hideDialog()
309
- } else {
310
- parentViewController = null
311
- }
314
+ TrueSheetDialogObserver.onSheetWillPresent(this, detentIndex)
312
315
  }
313
316
 
314
317
  viewController.presentPromise = promiseCallback
@@ -321,21 +324,6 @@ class TrueSheetView(private val reactContext: ThemedReactContext) :
321
324
  viewController.dismiss()
322
325
  }
323
326
 
324
- /**
325
- * Traverses up the view hierarchy to find a parent TrueSheetViewController.
326
- * This is used to detect if this sheet is nested inside another TrueSheet's content.
327
- */
328
- private fun findParentViewController(): TrueSheetViewController? {
329
- var current: ViewGroup? = parent as? ViewGroup
330
- while (current != null) {
331
- if (current is TrueSheetViewController) {
332
- return current
333
- }
334
- current = current.parent as? ViewGroup
335
- }
336
- return null
337
- }
338
-
339
327
  /**
340
328
  * Debounced sheet update to handle rapid content/header size changes.
341
329
  * Uses post to ensure all layout passes complete before reconfiguring.
@@ -31,16 +31,18 @@ import com.lodev09.truesheet.utils.ScreenUtils
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
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 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, transitioning: Boolean)
43
43
  fun viewControllerDidChangeSize(width: Int, height: Int)
44
+ fun viewControllerDidFocus()
45
+ fun viewControllerDidBlur()
44
46
  }
45
47
 
46
48
  /**
@@ -112,6 +114,7 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
112
114
 
113
115
  private var isDragging = false
114
116
  private var windowAnimation: Int = 0
117
+ private var lastEmittedPositionPx: Int = -1
115
118
 
116
119
  var presentPromise: (() -> Unit)? = null
117
120
  var dismissPromise: (() -> Unit)? = null
@@ -206,7 +209,7 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
206
209
  val style = if (edgeToEdgeEnabled) {
207
210
  com.lodev09.truesheet.R.style.TrueSheetEdgeToEdgeEnabledDialog
208
211
  } else {
209
- 0
212
+ com.lodev09.truesheet.R.style.TrueSheetDialog
210
213
  }
211
214
 
212
215
  dialog = BottomSheetDialog(reactContext, style).apply {
@@ -239,6 +242,7 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
239
242
  dialog = null
240
243
  isDragging = false
241
244
  isPresented = false
245
+ lastEmittedPositionPx = -1
242
246
  }
243
247
 
244
248
  private fun setupDialogListeners(dialog: BottomSheetDialog) {
@@ -250,8 +254,10 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
250
254
 
251
255
  sheetContainer?.post {
252
256
  val detentInfo = getDetentInfoForIndex(currentDetentIndex)
253
- delegate?.viewControllerDidPresent(detentInfo.index, detentInfo.position)
254
- delegate?.viewControllerDidChangePosition(detentInfo.index, detentInfo.position, transitioning = true)
257
+ val detent = getDetentValueForIndex(detentInfo.index)
258
+ val positionPx = bottomSheetView?.let { ScreenUtils.getScreenY(it) } ?: screenHeight
259
+ delegate?.viewControllerDidPresent(detentInfo.index, detentInfo.position, detent)
260
+ emitChangePositionDelegate(detentInfo.index, positionPx, transitioning = true)
255
261
 
256
262
  presentPromise?.invoke()
257
263
  presentPromise = null
@@ -277,8 +283,10 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
277
283
  object : BottomSheetBehavior.BottomSheetCallback() {
278
284
  override fun onSlide(sheetView: View, slideOffset: Float) {
279
285
  val behavior = behavior ?: return
280
- val detentInfo = getCurrentDetentInfo(sheetView)
281
- delegate?.viewControllerDidChangePosition(detentInfo.index, detentInfo.position, transitioning = false)
286
+ val positionPx = getCurrentPositionPx(sheetView)
287
+ val detentIndex = getDetentIndexForPosition(positionPx)
288
+
289
+ emitChangePositionDelegate(detentIndex, positionPx, transitioning = false)
282
290
 
283
291
  when (behavior.state) {
284
292
  BottomSheetBehavior.STATE_DRAGGING,
@@ -347,12 +355,32 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
347
355
  return sheetTop <= statusBarHeight
348
356
  }
349
357
 
358
+ /**
359
+ * Returns the current top position of the sheet (Y coordinate from screen top).
360
+ * Used for comparing sheet positions during stacking.
361
+ */
362
+ val currentSheetTop: Int
363
+ get() = bottomSheetView?.let { ScreenUtils.getScreenY(it) } ?: screenHeight
364
+
365
+ /**
366
+ * Returns the expected top position of the sheet when presented at the given detent index.
367
+ * Used for comparing sheet positions before presentation.
368
+ */
369
+ fun getExpectedSheetTop(detentIndex: Int): Int {
370
+ if (detentIndex < 0 || detentIndex >= detents.size) return screenHeight
371
+ val detentHeight = getDetentHeight(detents[detentIndex])
372
+ return screenHeight - detentHeight
373
+ }
374
+
350
375
  /**
351
376
  * Hides the dialog without dismissing it.
352
377
  * Used when another TrueSheet presents on top.
353
378
  */
354
379
  fun hideDialog() {
355
380
  dialog?.window?.decorView?.visibility = View.INVISIBLE
381
+
382
+ // Emit off-screen position (detent = 0 since sheet is fully hidden)
383
+ emitChangePositionDelegate(currentDetentIndex, screenHeight, transitioning = true)
356
384
  }
357
385
 
358
386
  /**
@@ -361,6 +389,10 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
361
389
  */
362
390
  fun showDialog() {
363
391
  dialog?.window?.decorView?.visibility = View.VISIBLE
392
+
393
+ // Emit current position
394
+ val positionPx = bottomSheetView?.let { ScreenUtils.getScreenY(it) } ?: screenHeight
395
+ emitChangePositionDelegate(currentDetentIndex, positionPx, transitioning = true)
364
396
  }
365
397
 
366
398
  // ====================================================================
@@ -378,7 +410,8 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
378
410
 
379
411
  if (isPresented) {
380
412
  val detentInfo = getDetentInfoForIndex(detentIndex)
381
- delegate?.viewControllerDidChangeDetent(detentInfo.index, detentInfo.position)
413
+ val detent = getDetentValueForIndex(detentInfo.index)
414
+ delegate?.viewControllerDidChangeDetent(detentInfo.index, detentInfo.position, detent)
382
415
  setStateForDetentIndex(detentIndex)
383
416
  } else {
384
417
  isDragging = false
@@ -386,7 +419,8 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
386
419
  setStateForDetentIndex(detentIndex)
387
420
 
388
421
  val detentInfo = getDetentInfoForIndex(detentIndex)
389
- delegate?.viewControllerWillPresent(detentInfo.index, detentInfo.position)
422
+ val detent = getDetentValueForIndex(detentInfo.index)
423
+ delegate?.viewControllerWillPresent(detentInfo.index, detentInfo.position, detent)
390
424
 
391
425
  if (!animated) {
392
426
  dialog.window?.setWindowAnimations(0)
@@ -398,8 +432,8 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
398
432
 
399
433
  fun dismiss() {
400
434
  this.post {
401
- val offScreenPosition = screenHeight.pxToDp()
402
- delegate?.viewControllerDidChangePosition(currentDetentIndex, offScreenPosition, transitioning = true)
435
+ // Emit off-screen position (detent = 0 since sheet is fully hidden)
436
+ emitChangePositionDelegate(currentDetentIndex, screenHeight, transitioning = true)
403
437
  }
404
438
  dialog?.dismiss()
405
439
  }
@@ -565,6 +599,76 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
565
599
  }
566
600
  }
567
601
 
602
+ // ====================================================================
603
+ // MARK: - Position Change Delegate
604
+ // ====================================================================
605
+
606
+ /**
607
+ * Emits position change to the delegate if the position has changed.
608
+ * @param index The current detent index (discrete, used as fallback)
609
+ * @param positionPx The current position in pixels (screen Y coordinate)
610
+ * @param transitioning Whether the sheet is transitioning (programmatic) vs dragging
611
+ */
612
+ private fun emitChangePositionDelegate(index: Int, positionPx: Int, transitioning: Boolean) {
613
+ if (positionPx == lastEmittedPositionPx) return
614
+
615
+ lastEmittedPositionPx = positionPx
616
+ val position = positionPx.pxToDp()
617
+ val interpolatedIndex = getInterpolatedIndexForPosition(positionPx)
618
+ val detent = getDetentValueForIndex(kotlin.math.round(interpolatedIndex).toInt())
619
+ delegate?.viewControllerDidChangePosition(interpolatedIndex, position, detent, transitioning)
620
+ }
621
+
622
+ /**
623
+ * Calculates the interpolated index based on position.
624
+ * Returns a continuous value (e.g., 0.5 means halfway between detent 0 and 1).
625
+ */
626
+ private fun getInterpolatedIndexForPosition(positionPx: Int): Float {
627
+ val count = detents.size
628
+ if (count == 0) return -1f
629
+ if (count == 1) return 0f
630
+
631
+ // Convert position to detent fraction
632
+ val currentDetent = (screenHeight - positionPx).toFloat() / screenHeight.toFloat()
633
+
634
+ // Handle below first detent (interpolate from -1 to 0)
635
+ val firstDetentValue = getDetentValueForIndex(0)
636
+ if (currentDetent < firstDetentValue) {
637
+ if (firstDetentValue <= 0) return 0f
638
+ val progress = currentDetent / firstDetentValue
639
+ return progress - 1f
640
+ }
641
+
642
+ // Find which segment the current detent falls into and interpolate
643
+ for (i in 0 until count - 1) {
644
+ val detentValue = getDetentValueForIndex(i)
645
+ val nextDetentValue = getDetentValueForIndex(i + 1)
646
+
647
+ if (currentDetent <= nextDetentValue) {
648
+ val range = nextDetentValue - detentValue
649
+ if (range <= 0) return i.toFloat()
650
+ val progress = (currentDetent - detentValue) / range
651
+ return i + maxOf(0f, minOf(1f, progress))
652
+ }
653
+ }
654
+
655
+ return (count - 1).toFloat()
656
+ }
657
+
658
+ /**
659
+ * Gets the detent value (fraction) for a given index.
660
+ * For auto (-1), calculates the actual fraction from content + header height.
661
+ */
662
+ private fun getDetentValueForIndex(index: Int): Float {
663
+ if (index < 0 || index >= detents.size) return 0f
664
+ val value = detents[index]
665
+ return if (value == -1.0) {
666
+ (contentHeight + headerHeight).toFloat() / screenHeight.toFloat()
667
+ } else {
668
+ value.toFloat()
669
+ }
670
+ }
671
+
568
672
  // ====================================================================
569
673
  // MARK: - Drag Handling
570
674
  // ====================================================================
@@ -574,16 +678,40 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
574
678
  return DetentInfo(currentDetentIndex, screenY.pxToDp())
575
679
  }
576
680
 
681
+ private fun getCurrentPositionPx(sheetView: View): Int = ScreenUtils.getScreenY(sheetView)
682
+
683
+ /**
684
+ * Returns the detent index for the current position.
685
+ * Only reports a higher index when the sheet has reached that detent's height.
686
+ */
687
+ private fun getDetentIndexForPosition(positionPx: Int): Int {
688
+ if (detents.isEmpty()) return 0
689
+
690
+ val sheetHeight = screenHeight - positionPx
691
+
692
+ // Find the highest detent index that the sheet has reached
693
+ for (i in detents.indices.reversed()) {
694
+ val detentHeight = getDetentHeight(detents[i])
695
+ if (sheetHeight >= detentHeight) {
696
+ return i
697
+ }
698
+ }
699
+
700
+ return 0
701
+ }
702
+
577
703
  private fun handleDragBegin(sheetView: View) {
578
704
  val detentInfo = getCurrentDetentInfo(sheetView)
579
- delegate?.viewControllerDidDragBegin(detentInfo.index, detentInfo.position)
705
+ val detent = getDetentValueForIndex(detentInfo.index)
706
+ delegate?.viewControllerDidDragBegin(detentInfo.index, detentInfo.position, detent)
580
707
  isDragging = true
581
708
  }
582
709
 
583
710
  private fun handleDragChange(sheetView: View) {
584
711
  if (!isDragging) return
585
712
  val detentInfo = getCurrentDetentInfo(sheetView)
586
- delegate?.viewControllerDidDragChange(detentInfo.index, detentInfo.position)
713
+ val detent = getDetentValueForIndex(detentInfo.index)
714
+ delegate?.viewControllerDidDragChange(detentInfo.index, detentInfo.position, detent)
587
715
  }
588
716
 
589
717
  private fun handleDragEnd(state: Int) {
@@ -591,7 +719,8 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
591
719
 
592
720
  val detentInfo = getDetentInfoForState(state)
593
721
  detentInfo?.let {
594
- delegate?.viewControllerDidDragEnd(it.index, it.position)
722
+ val detent = getDetentValueForIndex(it.index)
723
+ delegate?.viewControllerDidDragEnd(it.index, it.position, detent)
595
724
 
596
725
  if (it.index != currentDetentIndex) {
597
726
  presentPromise?.invoke()
@@ -599,7 +728,7 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
599
728
 
600
729
  currentDetentIndex = it.index
601
730
  setupDimmedBackground(it.index)
602
- delegate?.viewControllerDidChangeDetent(it.index, it.position)
731
+ delegate?.viewControllerDidChangeDetent(it.index, it.position, detent)
603
732
  }
604
733
  }
605
734
 
@@ -705,8 +834,8 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
705
834
  setupSheetDetents()
706
835
  this.post {
707
836
  positionFooter()
708
- val detentInfo = getDetentInfoForIndex(currentDetentIndex)
709
- delegate?.viewControllerDidChangePosition(detentInfo.index, detentInfo.position, transitioning = true)
837
+ val positionPx = bottomSheetView?.let { ScreenUtils.getScreenY(it) } ?: screenHeight
838
+ emitChangePositionDelegate(currentDetentIndex, positionPx, transitioning = true)
710
839
  }
711
840
  }
712
841
  }
@@ -0,0 +1,93 @@
1
+ package com.lodev09.truesheet.core
2
+
3
+ import com.lodev09.truesheet.TrueSheetView
4
+
5
+ /**
6
+ * Observes TrueSheet dialog lifecycle to manage sheet stacking.
7
+ * Automatically hides/shows sheets and dispatches focus/blur events
8
+ * when sheets are presented on top of each other.
9
+ */
10
+ object TrueSheetDialogObserver {
11
+
12
+ /**
13
+ * Stack of currently presented sheet views (most recent on top)
14
+ */
15
+ private val presentedSheetStack = mutableListOf<TrueSheetView>()
16
+
17
+ /**
18
+ * Called when a sheet is about to be presented.
19
+ * Hides and blurs the current topmost sheet if exists.
20
+ *
21
+ * @param sheetView The sheet that is about to be presented
22
+ * @param detentIndex The detent index the sheet will be presented at
23
+ */
24
+ @JvmStatic
25
+ fun onSheetWillPresent(sheetView: TrueSheetView, detentIndex: Int) {
26
+ synchronized(presentedSheetStack) {
27
+ // Get the current topmost sheet
28
+ val topSheet = presentedSheetStack.lastOrNull()
29
+
30
+ // Hide and blur the topmost sheet if it exists
31
+ topSheet?.let {
32
+ // Don't hide if the top sheet is fully expanded (covers the screen)
33
+ // or if the top sheet is smaller than the presenting sheet
34
+ // A smaller topSheetTop value means the sheet is taller (closer to top of screen)
35
+ val topSheetTop = it.viewController.currentSheetTop
36
+ val presentingSheetTop = sheetView.viewController.getExpectedSheetTop(detentIndex)
37
+
38
+ if (!it.viewController.isExpanded && topSheetTop <= presentingSheetTop) {
39
+ it.viewController.hideDialog()
40
+ }
41
+ it.viewControllerDidBlur()
42
+ }
43
+
44
+ // Add new sheet to stack
45
+ if (!presentedSheetStack.contains(sheetView)) {
46
+ presentedSheetStack.add(sheetView)
47
+ }
48
+ }
49
+ }
50
+
51
+ /**
52
+ * Called when a sheet has been dismissed.
53
+ * Shows and focuses the sheet below it (if any).
54
+ *
55
+ * @param sheetView The sheet that was dismissed
56
+ */
57
+ @JvmStatic
58
+ fun onSheetDidDismiss(sheetView: TrueSheetView) {
59
+ synchronized(presentedSheetStack) {
60
+ presentedSheetStack.remove(sheetView)
61
+
62
+ // Show and focus the new topmost sheet
63
+ presentedSheetStack.lastOrNull()?.let {
64
+ it.viewController.showDialog()
65
+ it.viewControllerDidFocus()
66
+ }
67
+ }
68
+ }
69
+
70
+ /**
71
+ * Removes a sheet from the stack without triggering focus events.
72
+ * Used when a sheet is being destroyed/cleaned up.
73
+ *
74
+ * @param sheetView The sheet to remove
75
+ */
76
+ @JvmStatic
77
+ fun removeSheet(sheetView: TrueSheetView) {
78
+ synchronized(presentedSheetStack) {
79
+ presentedSheetStack.remove(sheetView)
80
+ }
81
+ }
82
+
83
+ /**
84
+ * Clears all tracked sheets.
85
+ * Used when the module is invalidated.
86
+ */
87
+ @JvmStatic
88
+ fun clear() {
89
+ synchronized(presentedSheetStack) {
90
+ presentedSheetStack.clear()
91
+ }
92
+ }
93
+ }
@@ -0,0 +1,20 @@
1
+ package com.lodev09.truesheet.events
2
+
3
+ import com.facebook.react.bridge.Arguments
4
+ import com.facebook.react.bridge.WritableMap
5
+ import com.facebook.react.uimanager.events.Event
6
+
7
+ /**
8
+ * Fired when the sheet loses focus because another sheet is presented on top of it
9
+ */
10
+ class BlurEvent(surfaceId: Int, viewId: Int) : Event<BlurEvent>(surfaceId, viewId) {
11
+
12
+ override fun getEventName(): String = EVENT_NAME
13
+
14
+ override fun getEventData(): WritableMap = Arguments.createMap()
15
+
16
+ companion object {
17
+ const val EVENT_NAME = "topDidBlur"
18
+ const val REGISTRATION_NAME = "onDidBlur"
19
+ }
20
+ }
@@ -8,7 +8,7 @@ import com.facebook.react.uimanager.events.Event
8
8
  * Fired when the active detent changes
9
9
  * Payload: { index: number, position: number }
10
10
  */
11
- class DetentChangeEvent(surfaceId: Int, viewId: Int, private val index: Int, private val position: Float) :
11
+ class DetentChangeEvent(surfaceId: Int, viewId: Int, private val index: Int, private val position: Float, private val detent: Float) :
12
12
  Event<DetentChangeEvent>(surfaceId, viewId) {
13
13
 
14
14
  override fun getEventName(): String = EVENT_NAME
@@ -17,6 +17,7 @@ class DetentChangeEvent(surfaceId: Int, viewId: Int, private val index: Int, pri
17
17
  Arguments.createMap().apply {
18
18
  putInt("index", index)
19
19
  putDouble("position", position.toDouble())
20
+ putDouble("detent", detent.toDouble())
20
21
  }
21
22
 
22
23
  companion object {
@@ -8,7 +8,7 @@ import com.facebook.react.uimanager.events.Event
8
8
  * Fired after the sheet presentation is complete
9
9
  * Payload: { index: number, position: number }
10
10
  */
11
- class DidPresentEvent(surfaceId: Int, viewId: Int, private val index: Int, private val position: Float) :
11
+ class DidPresentEvent(surfaceId: Int, viewId: Int, private val index: Int, private val position: Float, private val detent: Float) :
12
12
  Event<DidPresentEvent>(surfaceId, viewId) {
13
13
 
14
14
  override fun getEventName(): String = EVENT_NAME
@@ -17,6 +17,7 @@ class DidPresentEvent(surfaceId: Int, viewId: Int, private val index: Int, priva
17
17
  Arguments.createMap().apply {
18
18
  putInt("index", index)
19
19
  putDouble("position", position.toDouble())
20
+ putDouble("detent", detent.toDouble())
20
21
  }
21
22
 
22
23
  companion object {
@@ -8,7 +8,7 @@ import com.facebook.react.uimanager.events.Event
8
8
  * Fired when user starts dragging the sheet
9
9
  * Payload: { index: number, position: number }
10
10
  */
11
- class DragBeginEvent(surfaceId: Int, viewId: Int, private val index: Int, private val position: Float) :
11
+ class DragBeginEvent(surfaceId: Int, viewId: Int, private val index: Int, private val position: Float, private val detent: Float) :
12
12
  Event<DragBeginEvent>(surfaceId, viewId) {
13
13
 
14
14
  override fun getEventName(): String = EVENT_NAME
@@ -17,6 +17,7 @@ class DragBeginEvent(surfaceId: Int, viewId: Int, private val index: Int, privat
17
17
  Arguments.createMap().apply {
18
18
  putInt("index", index)
19
19
  putDouble("position", position.toDouble())
20
+ putDouble("detent", detent.toDouble())
20
21
  }
21
22
 
22
23
  companion object {