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

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 (75) 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 +41 -57
  4. package/android/src/main/java/com/lodev09/truesheet/TrueSheetViewController.kt +182 -25
  5. package/android/src/main/java/com/lodev09/truesheet/TrueSheetViewManager.kt +5 -1
  6. package/android/src/main/java/com/lodev09/truesheet/core/TrueSheetDialogObserver.kt +67 -0
  7. package/android/src/main/java/com/lodev09/truesheet/events/TrueSheetDragEvents.kt +71 -0
  8. package/android/src/main/java/com/lodev09/truesheet/events/TrueSheetFocusEvents.kt +65 -0
  9. package/android/src/main/java/com/lodev09/truesheet/events/TrueSheetLifecycleEvents.kt +94 -0
  10. package/android/src/main/java/com/lodev09/truesheet/events/TrueSheetStateEvents.kt +56 -0
  11. package/android/src/main/res/anim/true_sheet_slide_in.xml +13 -0
  12. package/android/src/main/res/anim/true_sheet_slide_out.xml +13 -0
  13. package/android/src/main/res/values/styles.xml +13 -1
  14. package/ios/TrueSheetView.mm +49 -21
  15. package/ios/TrueSheetViewController.h +9 -1
  16. package/ios/TrueSheetViewController.mm +130 -42
  17. package/ios/events/TrueSheetDragEvents.h +39 -0
  18. package/ios/events/TrueSheetDragEvents.mm +62 -0
  19. package/ios/events/{OnPositionChangeEvent.h → TrueSheetFocusEvents.h} +8 -5
  20. package/ios/events/TrueSheetFocusEvents.mm +49 -0
  21. package/ios/events/TrueSheetLifecycleEvents.h +40 -0
  22. package/ios/events/TrueSheetLifecycleEvents.mm +71 -0
  23. package/ios/events/TrueSheetStateEvents.h +35 -0
  24. package/ios/events/TrueSheetStateEvents.mm +49 -0
  25. package/lib/module/TrueSheet.js +20 -0
  26. package/lib/module/TrueSheet.js.map +1 -1
  27. package/lib/module/fabric/TrueSheetViewNativeComponent.ts +7 -1
  28. package/lib/module/reanimated/ReanimatedTrueSheet.js +9 -4
  29. package/lib/module/reanimated/ReanimatedTrueSheet.js.map +1 -1
  30. package/lib/module/reanimated/ReanimatedTrueSheetProvider.js +4 -2
  31. package/lib/module/reanimated/ReanimatedTrueSheetProvider.js.map +1 -1
  32. package/lib/typescript/src/TrueSheet.d.ts +4 -0
  33. package/lib/typescript/src/TrueSheet.d.ts.map +1 -1
  34. package/lib/typescript/src/TrueSheet.types.d.ts +24 -0
  35. package/lib/typescript/src/TrueSheet.types.d.ts.map +1 -1
  36. package/lib/typescript/src/fabric/TrueSheetViewNativeComponent.d.ts +7 -1
  37. package/lib/typescript/src/fabric/TrueSheetViewNativeComponent.d.ts.map +1 -1
  38. package/lib/typescript/src/reanimated/ReanimatedTrueSheet.d.ts.map +1 -1
  39. package/lib/typescript/src/reanimated/ReanimatedTrueSheetProvider.d.ts +8 -2
  40. package/lib/typescript/src/reanimated/ReanimatedTrueSheetProvider.d.ts.map +1 -1
  41. package/package.json +1 -1
  42. package/src/TrueSheet.tsx +28 -0
  43. package/src/TrueSheet.types.ts +28 -0
  44. package/src/fabric/TrueSheetViewNativeComponent.ts +7 -1
  45. package/src/reanimated/ReanimatedTrueSheet.tsx +9 -4
  46. package/src/reanimated/ReanimatedTrueSheetProvider.tsx +11 -3
  47. package/android/src/main/java/com/lodev09/truesheet/events/DetentChangeEvent.kt +0 -26
  48. package/android/src/main/java/com/lodev09/truesheet/events/DidDismissEvent.kt +0 -20
  49. package/android/src/main/java/com/lodev09/truesheet/events/DidPresentEvent.kt +0 -26
  50. package/android/src/main/java/com/lodev09/truesheet/events/DragBeginEvent.kt +0 -26
  51. package/android/src/main/java/com/lodev09/truesheet/events/DragChangeEvent.kt +0 -26
  52. package/android/src/main/java/com/lodev09/truesheet/events/DragEndEvent.kt +0 -26
  53. package/android/src/main/java/com/lodev09/truesheet/events/MountEvent.kt +0 -20
  54. package/android/src/main/java/com/lodev09/truesheet/events/PositionChangeEvent.kt +0 -32
  55. package/android/src/main/java/com/lodev09/truesheet/events/WillDismissEvent.kt +0 -20
  56. package/android/src/main/java/com/lodev09/truesheet/events/WillPresentEvent.kt +0 -26
  57. package/ios/events/OnDetentChangeEvent.h +0 -28
  58. package/ios/events/OnDetentChangeEvent.mm +0 -30
  59. package/ios/events/OnDidDismissEvent.h +0 -26
  60. package/ios/events/OnDidDismissEvent.mm +0 -25
  61. package/ios/events/OnDidPresentEvent.h +0 -28
  62. package/ios/events/OnDidPresentEvent.mm +0 -30
  63. package/ios/events/OnDragBeginEvent.h +0 -28
  64. package/ios/events/OnDragBeginEvent.mm +0 -30
  65. package/ios/events/OnDragChangeEvent.h +0 -28
  66. package/ios/events/OnDragChangeEvent.mm +0 -30
  67. package/ios/events/OnDragEndEvent.h +0 -28
  68. package/ios/events/OnDragEndEvent.mm +0 -30
  69. package/ios/events/OnMountEvent.h +0 -26
  70. package/ios/events/OnMountEvent.mm +0 -25
  71. package/ios/events/OnPositionChangeEvent.mm +0 -32
  72. package/ios/events/OnWillDismissEvent.h +0 -26
  73. package/ios/events/OnWillDismissEvent.mm +0 -25
  74. package/ios/events/OnWillPresentEvent.h +0 -28
  75. package/ios/events/OnWillPresentEvent.mm +0 -30
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,16 +13,8 @@ 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
17
- import com.lodev09.truesheet.events.DetentChangeEvent
18
- import com.lodev09.truesheet.events.DidDismissEvent
19
- import com.lodev09.truesheet.events.DidPresentEvent
20
- import com.lodev09.truesheet.events.DragBeginEvent
21
- import com.lodev09.truesheet.events.DragChangeEvent
22
- import com.lodev09.truesheet.events.DragEndEvent
23
- import com.lodev09.truesheet.events.MountEvent
24
- import com.lodev09.truesheet.events.PositionChangeEvent
25
- import com.lodev09.truesheet.events.WillDismissEvent
26
- import com.lodev09.truesheet.events.WillPresentEvent
16
+ import com.lodev09.truesheet.core.TrueSheetDialogObserver
17
+ import com.lodev09.truesheet.events.*
27
18
 
28
19
  /**
29
20
  * Main TrueSheet host view that manages the sheet dialog and dispatches events to JavaScript.
@@ -36,7 +27,7 @@ class TrueSheetView(private val reactContext: ThemedReactContext) :
36
27
  TrueSheetViewControllerDelegate,
37
28
  TrueSheetContainerViewDelegate {
38
29
 
39
- private val viewController: TrueSheetViewController = TrueSheetViewController(reactContext)
30
+ internal val viewController: TrueSheetViewController = TrueSheetViewController(reactContext)
40
31
 
41
32
  private val containerView: TrueSheetContainerView?
42
33
  get() = viewController.getChildAt(0) as? TrueSheetContainerView
@@ -63,9 +54,6 @@ class TrueSheetView(private val reactContext: ThemedReactContext) :
63
54
  // Flag to prevent multiple pending sheet updates
64
55
  private var isSheetUpdatePending: Boolean = false
65
56
 
66
- // Reference to parent sheet's controller (for stacking support)
67
- private var parentViewController: TrueSheetViewController? = null
68
-
69
57
  init {
70
58
  reactContext.addLifecycleEventListener(this)
71
59
  viewController.delegate = this
@@ -144,6 +132,7 @@ class TrueSheetView(private val reactContext: ThemedReactContext) :
144
132
  fun onDropInstance() {
145
133
  reactContext.removeLifecycleEventListener(this)
146
134
  TrueSheetModule.unregisterView(id)
135
+ TrueSheetDialogObserver.removeSheet(this)
147
136
 
148
137
  if (viewController.isPresented) {
149
138
  viewController.dismiss()
@@ -164,14 +153,14 @@ class TrueSheetView(private val reactContext: ThemedReactContext) :
164
153
 
165
154
  // ==================== TrueSheetViewControllerDelegate Implementation ====================
166
155
 
167
- override fun viewControllerWillPresent(index: Int, position: Float) {
156
+ override fun viewControllerWillPresent(index: Int, position: Float, detent: Float) {
168
157
  val surfaceId = UIManagerHelper.getSurfaceId(this)
169
- eventDispatcher?.dispatchEvent(WillPresentEvent(surfaceId, id, index, position))
158
+ eventDispatcher?.dispatchEvent(WillPresentEvent(surfaceId, id, index, position, detent))
170
159
  }
171
160
 
172
- override fun viewControllerDidPresent(index: Int, position: Float) {
161
+ override fun viewControllerDidPresent(index: Int, position: Float, detent: Float) {
173
162
  val surfaceId = UIManagerHelper.getSurfaceId(this)
174
- eventDispatcher?.dispatchEvent(DidPresentEvent(surfaceId, id, index, position))
163
+ eventDispatcher?.dispatchEvent(DidPresentEvent(surfaceId, id, index, position, detent))
175
164
 
176
165
  // Enable touch event dispatching to React Native
177
166
  viewController.eventDispatcher = eventDispatcher
@@ -187,44 +176,62 @@ class TrueSheetView(private val reactContext: ThemedReactContext) :
187
176
  containerView?.footerView?.eventDispatcher = null
188
177
  }
189
178
 
190
- override fun viewControllerDidDismiss() {
179
+ override fun viewControllerDidDismiss(hadParent: Boolean) {
191
180
  val surfaceId = UIManagerHelper.getSurfaceId(this)
192
181
  eventDispatcher?.dispatchEvent(DidDismissEvent(surfaceId, id))
193
182
 
194
- // Show parent sheet again if this was a stacked sheet
195
- parentViewController?.showDialog()
196
- parentViewController = null
183
+ TrueSheetDialogObserver.onSheetDidDismiss(this, hadParent)
197
184
  }
198
185
 
199
- override fun viewControllerDidChangeDetent(index: Int, position: Float) {
186
+ override fun viewControllerDidChangeDetent(index: Int, position: Float, detent: Float) {
200
187
  val surfaceId = UIManagerHelper.getSurfaceId(this)
201
- eventDispatcher?.dispatchEvent(DetentChangeEvent(surfaceId, id, index, position))
188
+ eventDispatcher?.dispatchEvent(DetentChangeEvent(surfaceId, id, index, position, detent))
202
189
  }
203
190
 
204
- override fun viewControllerDidDragBegin(index: Int, position: Float) {
191
+ override fun viewControllerDidDragBegin(index: Int, position: Float, detent: Float) {
205
192
  val surfaceId = UIManagerHelper.getSurfaceId(this)
206
- eventDispatcher?.dispatchEvent(DragBeginEvent(surfaceId, id, index, position))
193
+ eventDispatcher?.dispatchEvent(DragBeginEvent(surfaceId, id, index, position, detent))
207
194
  }
208
195
 
209
- override fun viewControllerDidDragChange(index: Int, position: Float) {
196
+ override fun viewControllerDidDragChange(index: Int, position: Float, detent: Float) {
210
197
  val surfaceId = UIManagerHelper.getSurfaceId(this)
211
- eventDispatcher?.dispatchEvent(DragChangeEvent(surfaceId, id, index, position))
198
+ eventDispatcher?.dispatchEvent(DragChangeEvent(surfaceId, id, index, position, detent))
212
199
  }
213
200
 
214
- override fun viewControllerDidDragEnd(index: Int, position: Float) {
201
+ override fun viewControllerDidDragEnd(index: Int, position: Float, detent: Float) {
215
202
  val surfaceId = UIManagerHelper.getSurfaceId(this)
216
- eventDispatcher?.dispatchEvent(DragEndEvent(surfaceId, id, index, position))
203
+ eventDispatcher?.dispatchEvent(DragEndEvent(surfaceId, id, index, position, detent))
217
204
  }
218
205
 
219
- override fun viewControllerDidChangePosition(index: Int, position: Float, transitioning: Boolean) {
206
+ override fun viewControllerDidChangePosition(index: Float, position: Float, detent: Float, transitioning: Boolean) {
220
207
  val surfaceId = UIManagerHelper.getSurfaceId(this)
221
- eventDispatcher?.dispatchEvent(PositionChangeEvent(surfaceId, id, index, position, transitioning))
208
+ eventDispatcher?.dispatchEvent(PositionChangeEvent(surfaceId, id, index, position, detent, transitioning))
222
209
  }
223
210
 
224
211
  override fun viewControllerDidChangeSize(width: Int, height: Int) {
225
212
  updateState(width, height)
226
213
  }
227
214
 
215
+ override fun viewControllerWillFocus() {
216
+ val surfaceId = UIManagerHelper.getSurfaceId(this)
217
+ eventDispatcher?.dispatchEvent(WillFocusEvent(surfaceId, id))
218
+ }
219
+
220
+ override fun viewControllerDidFocus() {
221
+ val surfaceId = UIManagerHelper.getSurfaceId(this)
222
+ eventDispatcher?.dispatchEvent(FocusEvent(surfaceId, id))
223
+ }
224
+
225
+ override fun viewControllerWillBlur() {
226
+ val surfaceId = UIManagerHelper.getSurfaceId(this)
227
+ eventDispatcher?.dispatchEvent(WillBlurEvent(surfaceId, id))
228
+ }
229
+
230
+ override fun viewControllerDidBlur() {
231
+ val surfaceId = UIManagerHelper.getSurfaceId(this)
232
+ eventDispatcher?.dispatchEvent(BlurEvent(surfaceId, id))
233
+ }
234
+
228
235
  // ==================== Property Setters (forward to controller) ====================
229
236
 
230
237
  fun setMaxHeight(height: Int) {
@@ -300,17 +307,9 @@ class TrueSheetView(private val reactContext: ThemedReactContext) :
300
307
 
301
308
  @UiThread
302
309
  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)
305
310
  if (!viewController.isPresented) {
306
- parentViewController = findParentViewController()
307
- if (parentViewController?.isExpanded == false) {
308
- parentViewController?.hideDialog()
309
- } else {
310
- parentViewController = null
311
- }
311
+ viewController.parentSheetView = TrueSheetDialogObserver.onSheetWillPresent(this, detentIndex)
312
312
  }
313
-
314
313
  viewController.presentPromise = promiseCallback
315
314
  viewController.present(detentIndex, animated)
316
315
  }
@@ -321,21 +320,6 @@ class TrueSheetView(private val reactContext: ThemedReactContext) :
321
320
  viewController.dismiss()
322
321
  }
323
322
 
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
323
  /**
340
324
  * Debounced sheet update to handle rapid content/header size changes.
341
325
  * Uses post to ensure all layout passes complete before reconfiguring.
@@ -31,16 +31,20 @@ 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
- 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, transitioning: 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
  /**
@@ -107,15 +111,22 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
107
111
  var isPresented = false
108
112
  private set
109
113
 
114
+ var isDialogVisible = false
115
+ private set
116
+
110
117
  var currentDetentIndex: Int = -1
111
118
  private set
112
119
 
113
120
  private var isDragging = 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
  // ====================================================================
@@ -206,7 +217,7 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
206
217
  val style = if (edgeToEdgeEnabled) {
207
218
  com.lodev09.truesheet.R.style.TrueSheetEdgeToEdgeEnabledDialog
208
219
  } else {
209
- 0
220
+ com.lodev09.truesheet.R.style.TrueSheetDialog
210
221
  }
211
222
 
212
223
  dialog = BottomSheetDialog(reactContext, style).apply {
@@ -239,19 +250,27 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
239
250
  dialog = null
240
251
  isDragging = false
241
252
  isPresented = false
253
+ isDialogVisible = false
254
+ lastEmittedPositionPx = -1
242
255
  }
243
256
 
244
257
  private fun setupDialogListeners(dialog: BottomSheetDialog) {
245
258
  dialog.setOnShowListener {
246
259
  isPresented = true
260
+ isDialogVisible = true
247
261
  resetAnimation()
248
262
  setupBackground()
249
263
  setupGrabber()
250
264
 
251
265
  sheetContainer?.post {
252
266
  val detentInfo = getDetentInfoForIndex(currentDetentIndex)
253
- delegate?.viewControllerDidPresent(detentInfo.index, detentInfo.position)
254
- delegate?.viewControllerDidChangePosition(detentInfo.index, detentInfo.position, transitioning = true)
267
+ val detent = getDetentValueForIndex(detentInfo.index)
268
+ val positionPx = bottomSheetView?.let { ScreenUtils.getScreenY(it) } ?: screenHeight
269
+ delegate?.viewControllerDidPresent(detentInfo.index, detentInfo.position, detent)
270
+ emitChangePositionDelegate(detentInfo.index, positionPx, transitioning = true)
271
+
272
+ // Notify parent sheet that it has lost focus (after this sheet appeared)
273
+ parentSheetView?.viewControllerDidBlur()
255
274
 
256
275
  presentPromise?.invoke()
257
276
  presentPromise = null
@@ -261,13 +280,22 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
261
280
  }
262
281
 
263
282
  dialog.setOnCancelListener {
283
+ // Notify parent sheet that it is about to regain focus
284
+ parentSheetView?.viewControllerWillFocus()
285
+
264
286
  delegate?.viewControllerWillDismiss()
265
287
  }
266
288
 
267
289
  dialog.setOnDismissListener {
290
+ val hadParent = parentSheetView != null
291
+
292
+ // Notify parent sheet that it has regained focus
293
+ parentSheetView?.viewControllerDidFocus()
294
+ parentSheetView = null
295
+
268
296
  dismissPromise?.invoke()
269
297
  dismissPromise = null
270
- delegate?.viewControllerDidDismiss()
298
+ delegate?.viewControllerDidDismiss(hadParent)
271
299
  cleanupDialog()
272
300
  }
273
301
  }
@@ -277,8 +305,10 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
277
305
  object : BottomSheetBehavior.BottomSheetCallback() {
278
306
  override fun onSlide(sheetView: View, slideOffset: Float) {
279
307
  val behavior = behavior ?: return
280
- val detentInfo = getCurrentDetentInfo(sheetView)
281
- delegate?.viewControllerDidChangePosition(detentInfo.index, detentInfo.position, transitioning = false)
308
+ val positionPx = getCurrentPositionPx(sheetView)
309
+ val detentIndex = getDetentIndexForPosition(positionPx)
310
+
311
+ emitChangePositionDelegate(detentIndex, positionPx, transitioning = false)
282
312
 
283
313
  when (behavior.state) {
284
314
  BottomSheetBehavior.STATE_DRAGGING,
@@ -347,12 +377,33 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
347
377
  return sheetTop <= statusBarHeight
348
378
  }
349
379
 
380
+ /**
381
+ * Returns the current top position of the sheet (Y coordinate from screen top).
382
+ * Used for comparing sheet positions during stacking.
383
+ */
384
+ val currentSheetTop: Int
385
+ get() = bottomSheetView?.let { ScreenUtils.getScreenY(it) } ?: screenHeight
386
+
387
+ /**
388
+ * Returns the expected top position of the sheet when presented at the given detent index.
389
+ * Used for comparing sheet positions before presentation.
390
+ */
391
+ fun getExpectedSheetTop(detentIndex: Int): Int {
392
+ if (detentIndex < 0 || detentIndex >= detents.size) return screenHeight
393
+ val detentHeight = getDetentHeight(detents[detentIndex])
394
+ return screenHeight - detentHeight
395
+ }
396
+
350
397
  /**
351
398
  * Hides the dialog without dismissing it.
352
- * Used when another TrueSheet presents on top.
399
+ * Used when another TrueSheet presents on top or when RN screen is presented.
353
400
  */
354
401
  fun hideDialog() {
402
+ isDialogVisible = false
355
403
  dialog?.window?.decorView?.visibility = View.INVISIBLE
404
+
405
+ // Emit off-screen position (detent = 0 since sheet is fully hidden)
406
+ emitChangePositionDelegate(currentDetentIndex, screenHeight, transitioning = true)
356
407
  }
357
408
 
358
409
  /**
@@ -360,7 +411,12 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
360
411
  * Used when the sheet on top dismisses.
361
412
  */
362
413
  fun showDialog() {
414
+ isDialogVisible = true
363
415
  dialog?.window?.decorView?.visibility = View.VISIBLE
416
+
417
+ // Emit current position
418
+ val positionPx = bottomSheetView?.let { ScreenUtils.getScreenY(it) } ?: screenHeight
419
+ emitChangePositionDelegate(currentDetentIndex, positionPx, transitioning = true)
364
420
  }
365
421
 
366
422
  // ====================================================================
@@ -378,7 +434,8 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
378
434
 
379
435
  if (isPresented) {
380
436
  val detentInfo = getDetentInfoForIndex(detentIndex)
381
- delegate?.viewControllerDidChangeDetent(detentInfo.index, detentInfo.position)
437
+ val detent = getDetentValueForIndex(detentInfo.index)
438
+ delegate?.viewControllerDidChangeDetent(detentInfo.index, detentInfo.position, detent)
382
439
  setStateForDetentIndex(detentIndex)
383
440
  } else {
384
441
  isDragging = false
@@ -386,7 +443,12 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
386
443
  setStateForDetentIndex(detentIndex)
387
444
 
388
445
  val detentInfo = getDetentInfoForIndex(detentIndex)
389
- delegate?.viewControllerWillPresent(detentInfo.index, detentInfo.position)
446
+ val detent = getDetentValueForIndex(detentInfo.index)
447
+
448
+ // Notify parent sheet that it is about to lose focus (before this sheet appears)
449
+ parentSheetView?.viewControllerWillBlur()
450
+
451
+ delegate?.viewControllerWillPresent(detentInfo.index, detentInfo.position, detent)
390
452
 
391
453
  if (!animated) {
392
454
  dialog.window?.setWindowAnimations(0)
@@ -398,8 +460,8 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
398
460
 
399
461
  fun dismiss() {
400
462
  this.post {
401
- val offScreenPosition = screenHeight.pxToDp()
402
- delegate?.viewControllerDidChangePosition(currentDetentIndex, offScreenPosition, transitioning = true)
463
+ // Emit off-screen position (detent = 0 since sheet is fully hidden)
464
+ emitChangePositionDelegate(currentDetentIndex, screenHeight, transitioning = true)
403
465
  }
404
466
  dialog?.dismiss()
405
467
  }
@@ -565,6 +627,76 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
565
627
  }
566
628
  }
567
629
 
630
+ // ====================================================================
631
+ // MARK: - Position Change Delegate
632
+ // ====================================================================
633
+
634
+ /**
635
+ * Emits position change to the delegate if the position has changed.
636
+ * @param index The current detent index (discrete, used as fallback)
637
+ * @param positionPx The current position in pixels (screen Y coordinate)
638
+ * @param transitioning Whether the sheet is transitioning (programmatic) vs dragging
639
+ */
640
+ private fun emitChangePositionDelegate(index: Int, positionPx: Int, transitioning: Boolean) {
641
+ if (positionPx == lastEmittedPositionPx) return
642
+
643
+ lastEmittedPositionPx = positionPx
644
+ val position = positionPx.pxToDp()
645
+ val interpolatedIndex = getInterpolatedIndexForPosition(positionPx)
646
+ val detent = getDetentValueForIndex(kotlin.math.round(interpolatedIndex).toInt())
647
+ delegate?.viewControllerDidChangePosition(interpolatedIndex, position, detent, transitioning)
648
+ }
649
+
650
+ /**
651
+ * Calculates the interpolated index based on position.
652
+ * Returns a continuous value (e.g., 0.5 means halfway between detent 0 and 1).
653
+ */
654
+ private fun getInterpolatedIndexForPosition(positionPx: Int): Float {
655
+ val count = detents.size
656
+ if (count == 0) return -1f
657
+ if (count == 1) return 0f
658
+
659
+ // Convert position to detent fraction
660
+ val currentDetent = (screenHeight - positionPx).toFloat() / screenHeight.toFloat()
661
+
662
+ // Handle below first detent (interpolate from -1 to 0)
663
+ val firstDetentValue = getDetentValueForIndex(0)
664
+ if (currentDetent < firstDetentValue) {
665
+ if (firstDetentValue <= 0) return 0f
666
+ val progress = currentDetent / firstDetentValue
667
+ return progress - 1f
668
+ }
669
+
670
+ // Find which segment the current detent falls into and interpolate
671
+ for (i in 0 until count - 1) {
672
+ val detentValue = getDetentValueForIndex(i)
673
+ val nextDetentValue = getDetentValueForIndex(i + 1)
674
+
675
+ if (currentDetent <= nextDetentValue) {
676
+ val range = nextDetentValue - detentValue
677
+ if (range <= 0) return i.toFloat()
678
+ val progress = (currentDetent - detentValue) / range
679
+ return i + maxOf(0f, minOf(1f, progress))
680
+ }
681
+ }
682
+
683
+ return (count - 1).toFloat()
684
+ }
685
+
686
+ /**
687
+ * Gets the detent value (fraction) for a given index.
688
+ * For auto (-1), calculates the actual fraction from content + header height.
689
+ */
690
+ private fun getDetentValueForIndex(index: Int): Float {
691
+ if (index < 0 || index >= detents.size) return 0f
692
+ val value = detents[index]
693
+ return if (value == -1.0) {
694
+ (contentHeight + headerHeight).toFloat() / screenHeight.toFloat()
695
+ } else {
696
+ value.toFloat()
697
+ }
698
+ }
699
+
568
700
  // ====================================================================
569
701
  // MARK: - Drag Handling
570
702
  // ====================================================================
@@ -574,16 +706,40 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
574
706
  return DetentInfo(currentDetentIndex, screenY.pxToDp())
575
707
  }
576
708
 
709
+ private fun getCurrentPositionPx(sheetView: View): Int = ScreenUtils.getScreenY(sheetView)
710
+
711
+ /**
712
+ * Returns the detent index for the current position.
713
+ * Only reports a higher index when the sheet has reached that detent's height.
714
+ */
715
+ private fun getDetentIndexForPosition(positionPx: Int): Int {
716
+ if (detents.isEmpty()) return 0
717
+
718
+ val sheetHeight = screenHeight - positionPx
719
+
720
+ // Find the highest detent index that the sheet has reached
721
+ for (i in detents.indices.reversed()) {
722
+ val detentHeight = getDetentHeight(detents[i])
723
+ if (sheetHeight >= detentHeight) {
724
+ return i
725
+ }
726
+ }
727
+
728
+ return 0
729
+ }
730
+
577
731
  private fun handleDragBegin(sheetView: View) {
578
732
  val detentInfo = getCurrentDetentInfo(sheetView)
579
- delegate?.viewControllerDidDragBegin(detentInfo.index, detentInfo.position)
733
+ val detent = getDetentValueForIndex(detentInfo.index)
734
+ delegate?.viewControllerDidDragBegin(detentInfo.index, detentInfo.position, detent)
580
735
  isDragging = true
581
736
  }
582
737
 
583
738
  private fun handleDragChange(sheetView: View) {
584
739
  if (!isDragging) return
585
740
  val detentInfo = getCurrentDetentInfo(sheetView)
586
- delegate?.viewControllerDidDragChange(detentInfo.index, detentInfo.position)
741
+ val detent = getDetentValueForIndex(detentInfo.index)
742
+ delegate?.viewControllerDidDragChange(detentInfo.index, detentInfo.position, detent)
587
743
  }
588
744
 
589
745
  private fun handleDragEnd(state: Int) {
@@ -591,7 +747,8 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
591
747
 
592
748
  val detentInfo = getDetentInfoForState(state)
593
749
  detentInfo?.let {
594
- delegate?.viewControllerDidDragEnd(it.index, it.position)
750
+ val detent = getDetentValueForIndex(it.index)
751
+ delegate?.viewControllerDidDragEnd(it.index, it.position, detent)
595
752
 
596
753
  if (it.index != currentDetentIndex) {
597
754
  presentPromise?.invoke()
@@ -599,7 +756,7 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
599
756
 
600
757
  currentDetentIndex = it.index
601
758
  setupDimmedBackground(it.index)
602
- delegate?.viewControllerDidChangeDetent(it.index, it.position)
759
+ delegate?.viewControllerDidChangeDetent(it.index, it.position, detent)
603
760
  }
604
761
  }
605
762
 
@@ -705,8 +862,8 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
705
862
  setupSheetDetents()
706
863
  this.post {
707
864
  positionFooter()
708
- val detentInfo = getDetentInfoForIndex(currentDetentIndex)
709
- delegate?.viewControllerDidChangePosition(detentInfo.index, detentInfo.position, transitioning = true)
865
+ val positionPx = bottomSheetView?.let { ScreenUtils.getScreenY(it) } ?: screenHeight
866
+ emitChangePositionDelegate(currentDetentIndex, positionPx, transitioning = true)
710
867
  }
711
868
  }
712
869
  }
@@ -67,7 +67,11 @@ class TrueSheetViewManager :
67
67
  DragBeginEvent.EVENT_NAME to hashMapOf("registrationName" to DragBeginEvent.REGISTRATION_NAME),
68
68
  DragChangeEvent.EVENT_NAME to hashMapOf("registrationName" to DragChangeEvent.REGISTRATION_NAME),
69
69
  DragEndEvent.EVENT_NAME to hashMapOf("registrationName" to DragEndEvent.REGISTRATION_NAME),
70
- PositionChangeEvent.EVENT_NAME to hashMapOf("registrationName" to PositionChangeEvent.REGISTRATION_NAME)
70
+ PositionChangeEvent.EVENT_NAME to hashMapOf("registrationName" to PositionChangeEvent.REGISTRATION_NAME),
71
+ WillFocusEvent.EVENT_NAME to hashMapOf("registrationName" to WillFocusEvent.REGISTRATION_NAME),
72
+ FocusEvent.EVENT_NAME to hashMapOf("registrationName" to FocusEvent.REGISTRATION_NAME),
73
+ WillBlurEvent.EVENT_NAME to hashMapOf("registrationName" to WillBlurEvent.REGISTRATION_NAME),
74
+ BlurEvent.EVENT_NAME to hashMapOf("registrationName" to BlurEvent.REGISTRATION_NAME)
71
75
  )
72
76
 
73
77
  // ==================== Props ====================
@@ -0,0 +1,67 @@
1
+ package com.lodev09.truesheet.core
2
+
3
+ import com.lodev09.truesheet.TrueSheetView
4
+
5
+ /**
6
+ * Manages TrueSheet stacking behavior.
7
+ * Tracks presented sheets and handles visibility when sheets stack on top of each other.
8
+ */
9
+ object TrueSheetDialogObserver {
10
+
11
+ private val presentedSheetStack = mutableListOf<TrueSheetView>()
12
+
13
+ /**
14
+ * Called when a sheet is about to be presented.
15
+ * Returns the visible parent sheet to stack on, or null if none.
16
+ */
17
+ @JvmStatic
18
+ fun onSheetWillPresent(sheetView: TrueSheetView, detentIndex: Int): TrueSheetView? {
19
+ synchronized(presentedSheetStack) {
20
+ val parentSheet = presentedSheetStack.lastOrNull()
21
+ ?.takeIf { it.viewController.isPresented && it.viewController.isDialogVisible }
22
+
23
+ // Hide parent if the new sheet would cover it
24
+ parentSheet?.let {
25
+ val parentTop = it.viewController.currentSheetTop
26
+ val newSheetTop = sheetView.viewController.getExpectedSheetTop(detentIndex)
27
+ if (!it.viewController.isExpanded && parentTop <= newSheetTop) {
28
+ it.viewController.hideDialog()
29
+ }
30
+ }
31
+
32
+ if (!presentedSheetStack.contains(sheetView)) {
33
+ presentedSheetStack.add(sheetView)
34
+ }
35
+
36
+ return parentSheet
37
+ }
38
+ }
39
+
40
+ /**
41
+ * Called when a sheet has been dismissed.
42
+ * Shows the parent sheet if this sheet was stacked on it.
43
+ */
44
+ @JvmStatic
45
+ fun onSheetDidDismiss(sheetView: TrueSheetView, hadParent: Boolean) {
46
+ synchronized(presentedSheetStack) {
47
+ presentedSheetStack.remove(sheetView)
48
+ if (hadParent) {
49
+ presentedSheetStack.lastOrNull()?.viewController?.showDialog()
50
+ }
51
+ }
52
+ }
53
+
54
+ @JvmStatic
55
+ fun removeSheet(sheetView: TrueSheetView) {
56
+ synchronized(presentedSheetStack) {
57
+ presentedSheetStack.remove(sheetView)
58
+ }
59
+ }
60
+
61
+ @JvmStatic
62
+ fun clear() {
63
+ synchronized(presentedSheetStack) {
64
+ presentedSheetStack.clear()
65
+ }
66
+ }
67
+ }