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

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 (50) hide show
  1. package/README.md +1 -0
  2. package/android/src/main/java/com/lodev09/truesheet/TrueSheetContainerView.kt +51 -99
  3. package/android/src/main/java/com/lodev09/truesheet/TrueSheetContainerViewManager.kt +0 -7
  4. package/android/src/main/java/com/lodev09/truesheet/TrueSheetContentView.kt +10 -18
  5. package/android/src/main/java/com/lodev09/truesheet/TrueSheetFooterView.kt +76 -20
  6. package/android/src/main/java/com/lodev09/truesheet/TrueSheetHeaderView.kt +38 -0
  7. package/android/src/main/java/com/lodev09/truesheet/TrueSheetHeaderViewManager.kt +21 -0
  8. package/android/src/main/java/com/lodev09/truesheet/TrueSheetPackage.kt +1 -0
  9. package/android/src/main/java/com/lodev09/truesheet/TrueSheetView.kt +106 -136
  10. package/android/src/main/java/com/lodev09/truesheet/TrueSheetViewController.kt +305 -419
  11. package/android/src/main/java/com/lodev09/truesheet/TrueSheetViewManager.kt +9 -4
  12. package/android/src/main/java/com/lodev09/truesheet/core/RNScreensFragmentObserver.kt +116 -0
  13. package/android/src/main/java/com/lodev09/truesheet/utils/ScreenUtils.kt +33 -5
  14. package/android/src/main/jni/TrueSheetSpec.h +1 -1
  15. package/common/cpp/react/renderer/components/TrueSheetSpec/{TrueSheetContainerViewComponentDescriptor.h → TrueSheetViewComponentDescriptor.h} +5 -5
  16. package/common/cpp/react/renderer/components/TrueSheetSpec/{TrueSheetContainerViewShadowNode.cpp → TrueSheetViewShadowNode.cpp} +4 -4
  17. package/common/cpp/react/renderer/components/TrueSheetSpec/{TrueSheetContainerViewShadowNode.h → TrueSheetViewShadowNode.h} +8 -8
  18. package/common/cpp/react/renderer/components/TrueSheetSpec/{TrueSheetContainerViewState.cpp → TrueSheetViewState.cpp} +2 -2
  19. package/common/cpp/react/renderer/components/TrueSheetSpec/{TrueSheetContainerViewState.h → TrueSheetViewState.h} +6 -6
  20. package/ios/TrueSheetContainerView.h +20 -2
  21. package/ios/TrueSheetContainerView.mm +62 -62
  22. package/ios/TrueSheetContentView.h +4 -2
  23. package/ios/TrueSheetContentView.mm +29 -72
  24. package/ios/TrueSheetFooterView.mm +2 -2
  25. package/ios/TrueSheetHeaderView.h +29 -0
  26. package/ios/TrueSheetHeaderView.mm +60 -0
  27. package/ios/TrueSheetView.mm +195 -214
  28. package/ios/TrueSheetViewController.h +2 -2
  29. package/ios/TrueSheetViewController.mm +126 -231
  30. package/ios/utils/LayoutUtil.h +2 -1
  31. package/ios/utils/LayoutUtil.mm +14 -1
  32. package/lib/module/TrueSheet.js +9 -2
  33. package/lib/module/TrueSheet.js.map +1 -1
  34. package/lib/module/fabric/TrueSheetContainerViewNativeComponent.ts +1 -3
  35. package/lib/module/fabric/TrueSheetHeaderViewNativeComponent.ts +8 -0
  36. package/lib/module/fabric/TrueSheetViewNativeComponent.ts +3 -1
  37. package/lib/typescript/src/TrueSheet.d.ts.map +1 -1
  38. package/lib/typescript/src/TrueSheet.types.d.ts +9 -9
  39. package/lib/typescript/src/TrueSheet.types.d.ts.map +1 -1
  40. package/lib/typescript/src/fabric/TrueSheetContainerViewNativeComponent.d.ts.map +1 -1
  41. package/lib/typescript/src/fabric/TrueSheetHeaderViewNativeComponent.d.ts +6 -0
  42. package/lib/typescript/src/fabric/TrueSheetHeaderViewNativeComponent.d.ts.map +1 -0
  43. package/lib/typescript/src/fabric/TrueSheetViewNativeComponent.d.ts.map +1 -1
  44. package/package.json +6 -3
  45. package/react-native.config.js +1 -1
  46. package/src/TrueSheet.tsx +9 -0
  47. package/src/TrueSheet.types.ts +10 -11
  48. package/src/fabric/TrueSheetContainerViewNativeComponent.ts +1 -3
  49. package/src/fabric/TrueSheetHeaderViewNativeComponent.ts +8 -0
  50. package/src/fabric/TrueSheetViewNativeComponent.ts +3 -1
@@ -2,9 +2,11 @@ package com.lodev09.truesheet
2
2
 
3
3
  import android.annotation.SuppressLint
4
4
  import android.graphics.Color
5
+ import android.graphics.drawable.GradientDrawable
5
6
  import android.graphics.drawable.ShapeDrawable
6
7
  import android.graphics.drawable.shapes.RoundRectShape
7
8
  import android.util.TypedValue
9
+ import android.view.Gravity
8
10
  import android.view.MotionEvent
9
11
  import android.view.View
10
12
  import android.view.WindowManager
@@ -12,7 +14,6 @@ import android.view.accessibility.AccessibilityNodeInfo
12
14
  import android.widget.FrameLayout
13
15
  import androidx.core.view.isNotEmpty
14
16
  import com.facebook.react.R
15
- import com.facebook.react.common.annotations.UnstableReactNativeAPI
16
17
  import com.facebook.react.uimanager.JSPointerDispatcher
17
18
  import com.facebook.react.uimanager.JSTouchDispatcher
18
19
  import com.facebook.react.uimanager.PixelUtil.dpToPx
@@ -20,17 +21,15 @@ import com.facebook.react.uimanager.PixelUtil.pxToDp
20
21
  import com.facebook.react.uimanager.RootView
21
22
  import com.facebook.react.uimanager.ThemedReactContext
22
23
  import com.facebook.react.uimanager.events.EventDispatcher
24
+ import com.facebook.react.util.RNLog
23
25
  import com.facebook.react.views.view.ReactViewGroup
24
26
  import com.google.android.material.bottomsheet.BottomSheetBehavior
25
27
  import com.google.android.material.bottomsheet.BottomSheetDialog
28
+ import com.lodev09.truesheet.core.RNScreensFragmentObserver
26
29
  import com.lodev09.truesheet.utils.ScreenUtils
27
30
 
28
31
  data class DetentInfo(val index: Int, val position: Float)
29
32
 
30
- /**
31
- * Delegate protocol for TrueSheetViewController lifecycle and interaction events.
32
- * Similar to iOS TrueSheetViewControllerDelegate pattern.
33
- */
34
33
  interface TrueSheetViewControllerDelegate {
35
34
  fun viewControllerWillPresent(index: Int, position: Float)
36
35
  fun viewControllerDidPresent(index: Int, position: Float)
@@ -41,13 +40,12 @@ interface TrueSheetViewControllerDelegate {
41
40
  fun viewControllerDidDragChange(index: Int, position: Float)
42
41
  fun viewControllerDidDragEnd(index: Int, position: Float)
43
42
  fun viewControllerDidChangePosition(index: Int, position: Float, transitioning: Boolean)
43
+ fun viewControllerDidChangeSize(width: Int, height: Int)
44
44
  }
45
45
 
46
46
  /**
47
- * TrueSheetViewController manages the bottom sheet dialog lifecycle and properties.
48
- * Similar to iOS TrueSheetViewController pattern.
49
- *
50
- * This view acts as both the RootView (handles touch events) and the controller (manages dialog).
47
+ * TrueSheetViewController manages the bottom sheet dialog and its presentation lifecycle.
48
+ * This view also acts as a RootView to properly handle and dispatch touch events to React Native.
51
49
  */
52
50
  @SuppressLint("ClickableViewAccessibility", "ViewConstructor")
53
51
  class TrueSheetViewController(private val reactContext: ThemedReactContext) :
@@ -55,135 +53,84 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
55
53
  RootView {
56
54
 
57
55
  companion object {
58
- private const val TAG_NAME = "TrueSheet"
59
- }
60
-
61
- // ==================== RootView Touch Handling ====================
56
+ const val TAG_NAME = "TrueSheet"
62
57
 
63
- internal var eventDispatcher: EventDispatcher? = null
58
+ private const val GRABBER_TAG = "TrueSheetGrabber"
59
+ private const val DEFAULT_MAX_WIDTH = 640 // dp
60
+ 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%
64
65
 
65
- private val jSTouchDispatcher = JSTouchDispatcher(this)
66
- private var jSPointerDispatcher: JSPointerDispatcher? = null
66
+ /**
67
+ * Gets the effective sheet height by subtracting headerHeight * 2.
68
+ * This is needed because both native layout and Yoga layout account for the header separately.
69
+ */
70
+ fun getEffectiveSheetHeight(sheetHeight: Int, headerHeight: Int): Int = sheetHeight - headerHeight * 2
71
+ }
67
72
 
68
- private var viewWidth = 0
69
- private var viewHeight = 0
73
+ // ====================================================================
74
+ // MARK: - Delegate
75
+ // ====================================================================
70
76
 
71
- /**
72
- * Delegate for handling view controller events
73
- */
74
77
  var delegate: TrueSheetViewControllerDelegate? = null
75
78
 
76
- /**
77
- * The BottomSheetDialog instance - created lazily when container mounts
78
- */
79
+ // ====================================================================
80
+ // MARK: - Dialog & Views
81
+ // ====================================================================
82
+
79
83
  private var dialog: BottomSheetDialog? = null
80
84
 
81
- /**
82
- * The sheet behavior from the dialog
83
- */
84
85
  private val behavior: BottomSheetBehavior<FrameLayout>?
85
86
  get() = dialog?.behavior
86
87
 
87
- /**
88
- * The sheet container view from Material BottomSheetDialog (our parent)
89
- */
90
88
  private val sheetContainer: FrameLayout?
91
89
  get() = this.parent as? FrameLayout
92
90
 
93
- /**
94
- * The actual bottom sheet view used by Material BottomSheetBehavior
95
- * This is the view whose position changes during drag
96
- */
97
91
  private val bottomSheetView: FrameLayout?
98
92
  get() = dialog?.findViewById(com.google.android.material.R.id.design_bottom_sheet)
99
93
 
100
- /**
101
- * Our sheet container view from this root view's only child
102
- */
103
94
  private val containerView: TrueSheetContainerView?
104
- get() = if (this.isNotEmpty()) {
105
- this.getChildAt(0) as? TrueSheetContainerView
106
- } else {
107
- null
108
- }
95
+ get() = if (this.isNotEmpty()) getChildAt(0) as? TrueSheetContainerView else null
109
96
 
110
- /**
111
- * Footer view from the container
112
- */
113
- private val footerView: TrueSheetFooterView?
114
- get() = containerView?.footerView
97
+ private val contentHeight: Int
98
+ get() = containerView?.contentHeight ?: 0
115
99
 
116
- /**
117
- * Track if the dialog is currently being dragged
118
- */
119
- private var isDragging = false
100
+ private val headerHeight: Int
101
+ get() = containerView?.headerHeight ?: 0
102
+
103
+ // ====================================================================
104
+ // MARK: - State
105
+ // ====================================================================
120
106
 
121
- /**
122
- * Track if the sheet has been presented (after onShow callback)
123
- */
124
107
  var isPresented = false
125
108
  private set
126
109
 
127
- private val edgeToEdgeEnabled: Boolean
128
- get() {
129
- // Auto-enable edge-to-edge for Android 16+ (API level 36) if not explicitly set
130
- val defaultEnabled = android.os.Build.VERSION.SDK_INT >= 36
131
- return BuildConfig.EDGE_TO_EDGE_ENABLED || dialog?.edgeToEdgeEnabled == true || defaultEnabled
132
- }
133
-
134
- /**
135
- * Whether to allow the sheet to extend behind the status bar in edge-to-edge mode
136
- */
137
- var edgeToEdgeFullScreen: Boolean = false
138
-
139
- /**
140
- * Top inset to apply to sheet max height calculation (only when not edgeToEdgeFullScreen)
141
- */
142
- private val sheetTopInset: Int
143
- get() = if (edgeToEdgeEnabled && !edgeToEdgeFullScreen) ScreenUtils.getStatusBarHeight(reactContext) else 0
144
-
145
- /**
146
- * Current active detent index
147
- */
148
110
  var currentDetentIndex: Int = -1
149
111
  private set
150
112
 
151
- /**
152
- * Promise callback to be invoked after present is called
153
- */
154
- var presentPromise: (() -> Unit)? = null
113
+ private var isDragging = false
114
+ private var windowAnimation: Int = 0
155
115
 
156
- /**
157
- * Promise callback to be invoked after dismiss is called
158
- */
116
+ var presentPromise: (() -> Unit)? = null
159
117
  var dismissPromise: (() -> Unit)? = null
160
118
 
161
- // ==================== Properties ====================
162
-
163
- /**
164
- * Specify whether the sheet background is dimmed.
165
- * Set to `false` to allow interaction with the background components.
166
- */
167
- var dimmed = true
168
-
169
- /**
170
- * The detent index that the sheet should start to dim the background.
171
- * This is ignored if `dimmed` is set to `false`.
172
- */
173
- var dimmedDetentIndex = 0
174
-
175
- /**
176
- * The maximum window height
177
- */
178
- var maxScreenHeight = 0
119
+ // ====================================================================
120
+ // MARK: - Configuration Properties
121
+ // ====================================================================
179
122
 
123
+ var screenHeight = 0
124
+ var screenWidth = 0
180
125
  var maxSheetHeight: Int? = null
126
+ var detents = mutableListOf(0.5, 1.0)
181
127
 
182
- /**
183
- * The content height from the container view.
184
- * Set by the host view when content size changes.
185
- */
186
- var contentHeight: Int = 0
128
+ var dimmed = true
129
+ var dimmedDetentIndex = 0
130
+ var grabber: Boolean = true
131
+ var sheetCornerRadius: Float = -1f
132
+ var sheetBackgroundColor: Int = 0
133
+ var edgeToEdgeFullScreen: Boolean = false
187
134
 
188
135
  var dismissible: Boolean = true
189
136
  set(value) {
@@ -195,40 +142,66 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
195
142
  }
196
143
  }
197
144
 
198
- var cornerRadius: Float = 28f.dpToPx()
199
- var sheetBackgroundColor: Int = 0
200
- var detents = mutableListOf(0.5, 1.0)
201
-
202
- private var windowAnimation: Int = 0
145
+ // ====================================================================
146
+ // MARK: - Computed Properties
147
+ // ====================================================================
203
148
 
204
- init {
205
- maxScreenHeight = ScreenUtils.getScreenHeight(reactContext, edgeToEdgeEnabled)
206
- jSPointerDispatcher = JSPointerDispatcher(this)
207
- }
149
+ val statusBarHeight: Int
150
+ get() = ScreenUtils.getStatusBarHeight(reactContext)
208
151
 
209
152
  /**
210
- * Get the default background color from Material Design 3 theme.
211
- * Uses colorSurfaceContainerLow which adapts to light/dark mode.
153
+ * Edge-to-edge is enabled by default on API 36+, or when explicitly configured.
212
154
  */
213
- fun getDefaultBackgroundColor(): Int {
214
- val typedValue = TypedValue()
215
- return if (reactContext.theme.resolveAttribute(
216
- com.google.android.material.R.attr.colorSurfaceContainerLow,
217
- typedValue,
218
- true
219
- )
220
- ) {
221
- typedValue.data
222
- } else {
223
- Color.WHITE
155
+ private val edgeToEdgeEnabled: Boolean
156
+ get() {
157
+ val defaultEnabled = android.os.Build.VERSION.SDK_INT >= 36
158
+ return BuildConfig.EDGE_TO_EDGE_ENABLED || dialog?.edgeToEdgeEnabled == true || defaultEnabled
224
159
  }
225
- }
226
160
 
227
- // ==================== Lifecycle ====================
161
+ /**
162
+ * The top inset to apply when edge-to-edge is enabled but not full-screen.
163
+ * This prevents the sheet from going under the status bar.
164
+ */
165
+ private val sheetTopInset: Int
166
+ get() = if (edgeToEdgeEnabled && !edgeToEdgeFullScreen) statusBarHeight else 0
167
+
168
+ // ====================================================================
169
+ // MARK: - Touch Dispatchers
170
+ // ====================================================================
171
+
172
+ /**
173
+ * Touch dispatchers are required for RootView to properly forward touch events to React Native.
174
+ */
175
+ internal var eventDispatcher: EventDispatcher? = null
176
+ private val jSTouchDispatcher = JSTouchDispatcher(this)
177
+ private var jSPointerDispatcher: JSPointerDispatcher? = null
178
+
179
+ // ====================================================================
180
+ // MARK: - Modal Observer
181
+ // ====================================================================
228
182
 
229
183
  /**
230
- * Creates the dialog instance. Should be called when container view is mounted.
184
+ * Observes react-native-screens modal fragments to hide/show the sheet appropriately.
185
+ * This prevents the sheet from rendering on top of modals.
231
186
  */
187
+ private var rnScreensObserver: RNScreensFragmentObserver? = null
188
+
189
+ fun hasActiveModals(): Boolean = rnScreensObserver?.hasActiveModals() ?: false
190
+
191
+ // ====================================================================
192
+ // MARK: - Initialization
193
+ // ====================================================================
194
+
195
+ init {
196
+ screenHeight = ScreenUtils.getScreenHeight(reactContext, edgeToEdgeEnabled)
197
+ screenWidth = ScreenUtils.getScreenWidth(reactContext)
198
+ jSPointerDispatcher = JSPointerDispatcher(this)
199
+ }
200
+
201
+ // ====================================================================
202
+ // MARK: - Dialog Lifecycle
203
+ // ====================================================================
204
+
232
205
  fun createDialog() {
233
206
  if (dialog != null) return
234
207
 
@@ -241,31 +214,20 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
241
214
  dialog = BottomSheetDialog(reactContext, style).apply {
242
215
  setContentView(this@TrueSheetViewController)
243
216
 
244
- // Setup window params
245
217
  window?.apply {
246
- // Store current windowAnimation value to toggle later
247
218
  windowAnimation = attributes.windowAnimations
248
219
  }
249
220
 
250
- // Setup dialog lifecycle listeners
221
+ setupModalObserver()
251
222
  setupDialogListeners(this)
252
-
253
- // Setup bottom sheet behavior callbacks
254
223
  setupBottomSheetBehavior(this)
255
224
 
256
- // Apply initial properties
257
225
  setCanceledOnTouchOutside(dismissible)
258
226
  setCancelable(dismissible)
259
227
  behavior.isHideable = dismissible
260
-
261
- // Apply background color and corner radius
262
- setupBackground()
263
228
  }
264
229
  }
265
230
 
266
- /**
267
- * Cleans up the dialog instance. Called when dismissed to ensure clean state.
268
- */
269
231
  private fun cleanupDialog() {
270
232
  dialog?.apply {
271
233
  setOnShowListener(null)
@@ -273,7 +235,7 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
273
235
  setOnDismissListener(null)
274
236
  }
275
237
 
276
- // Remove this view from its parent to allow re-attachment on next presentation
238
+ cleanupModalObserver()
277
239
  sheetContainer?.removeView(this)
278
240
 
279
241
  dialog = null
@@ -281,89 +243,56 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
281
243
  isPresented = false
282
244
  }
283
245
 
284
- /**
285
- * Setup dialog lifecycle listeners
286
- */
287
246
  private fun setupDialogListeners(dialog: BottomSheetDialog) {
288
- // Setup listener when the dialog has been presented
289
247
  dialog.setOnShowListener {
290
248
  isPresented = true
291
-
292
- // Re-enable animation
293
249
  resetAnimation()
250
+ setupBackground()
251
+ setupGrabber()
294
252
 
295
- // Wait for the sheet to settle before notifying didPresent
296
- // The sheet animates to its final position after onShow fires
297
253
  sheetContainer?.post {
298
- // Notify delegate with the settled position
299
254
  val detentInfo = getDetentInfoForIndex(currentDetentIndex)
300
255
  delegate?.viewControllerDidPresent(detentInfo.index, detentInfo.position)
301
-
302
- // Emit position change with transitioning=true so Reanimated can animate it
303
256
  delegate?.viewControllerDidChangePosition(detentInfo.index, detentInfo.position, transitioning = true)
304
257
 
305
- // Resolve the present promise
306
- presentPromise?.let { promise ->
307
- promise()
308
- presentPromise = null
309
- }
258
+ presentPromise?.invoke()
259
+ presentPromise = null
310
260
 
311
- // Initialize footer position after layout is complete
312
261
  positionFooter()
313
262
  }
314
263
  }
315
264
 
316
- // Setup listener when the dialog is about to be dismissed
317
265
  dialog.setOnCancelListener {
318
- // Notify delegate
319
266
  delegate?.viewControllerWillDismiss()
320
267
  }
321
268
 
322
- // Setup listener when the dialog has been dismissed
323
269
  dialog.setOnDismissListener {
324
- // Resolve the dismiss promise
325
- dismissPromise?.let { promise ->
326
- promise()
327
- dismissPromise = null
328
- }
329
-
330
- // Notify delegate
270
+ dismissPromise?.invoke()
271
+ dismissPromise = null
331
272
  delegate?.viewControllerDidDismiss()
332
-
333
- // Clean up the dialog for next presentation
334
273
  cleanupDialog()
335
274
  }
336
275
  }
337
276
 
338
- /**
339
- * Setup bottom sheet behavior callbacks
340
- */
341
277
  private fun setupBottomSheetBehavior(dialog: BottomSheetDialog) {
342
278
  dialog.behavior.addBottomSheetCallback(
343
279
  object : BottomSheetBehavior.BottomSheetCallback() {
344
280
  override fun onSlide(sheetView: View, slideOffset: Float) {
345
281
  val behavior = behavior ?: return
346
-
347
- // Emit position change event continuously during slide
348
- // Set transitioning=false during drag to get real-time position updates
349
282
  val detentInfo = getCurrentDetentInfo(sheetView)
350
283
  delegate?.viewControllerDidChangePosition(detentInfo.index, detentInfo.position, transitioning = false)
351
284
 
352
285
  when (behavior.state) {
353
- // For consistency with iOS, we consider SETTLING as dragging change
354
286
  BottomSheetBehavior.STATE_DRAGGING,
355
287
  BottomSheetBehavior.STATE_SETTLING -> handleDragChange(sheetView)
356
288
 
357
289
  else -> { }
358
290
  }
359
291
 
360
- // Update footer position during slide
361
292
  positionFooter(slideOffset)
362
293
  }
363
294
 
364
295
  override fun onStateChanged(sheetView: View, newState: Int) {
365
- // Handle STATE_HIDDEN before checking isPresented
366
- // This ensures we can dismiss even if dialog state gets out of sync
367
296
  if (newState == BottomSheetBehavior.STATE_HIDDEN) {
368
297
  dismiss()
369
298
  return
@@ -372,10 +301,8 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
372
301
  if (!isPresented) return
373
302
 
374
303
  when (newState) {
375
- // When changed to dragging, we know that the drag has started
376
304
  BottomSheetBehavior.STATE_DRAGGING -> handleDragBegin(sheetView)
377
305
 
378
- // Either of the following state determines drag end
379
306
  BottomSheetBehavior.STATE_EXPANDED,
380
307
  BottomSheetBehavior.STATE_COLLAPSED,
381
308
  BottomSheetBehavior.STATE_HALF_EXPANDED -> handleDragEnd(newState)
@@ -387,14 +314,35 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
387
314
  )
388
315
  }
389
316
 
390
- // ==================== Presentation ====================
317
+ private fun setupModalObserver() {
318
+ rnScreensObserver = RNScreensFragmentObserver(
319
+ reactContext = reactContext,
320
+ onModalPresented = {
321
+ if (isPresented) {
322
+ dialog?.window?.decorView?.visibility = View.INVISIBLE
323
+ }
324
+ },
325
+ onModalDismissed = {
326
+ if (isPresented) {
327
+ dialog?.window?.decorView?.visibility = View.VISIBLE
328
+ }
329
+ }
330
+ )
331
+ rnScreensObserver?.start()
332
+ }
333
+
334
+ private fun cleanupModalObserver() {
335
+ rnScreensObserver?.stop()
336
+ rnScreensObserver = null
337
+ }
338
+
339
+ // ====================================================================
340
+ // MARK: - Presentation
341
+ // ====================================================================
391
342
 
392
- /**
393
- * Present the sheet.
394
- */
395
343
  fun present(detentIndex: Int, animated: Boolean = true) {
396
344
  val dialog = this.dialog ?: run {
397
- // Dialog not created yet - this shouldn't happen but handle gracefully
345
+ RNLog.w(reactContext, "TrueSheet: No dialog available. Ensure the sheet is mounted before presenting.")
398
346
  return
399
347
  }
400
348
 
@@ -402,26 +350,18 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
402
350
  setupDimmedBackground(detentIndex)
403
351
 
404
352
  if (isPresented) {
405
- // For consistency with iOS, we notify detent change immediately
406
- // when already presented (not waiting for state to change)
407
353
  val detentInfo = getDetentInfoForIndex(detentIndex)
408
354
  delegate?.viewControllerDidChangeDetent(detentInfo.index, detentInfo.position)
409
-
410
- // Note: onSlide will be called during resize animation, no need to emit position change here
411
355
  setStateForDetentIndex(detentIndex)
412
356
  } else {
413
- // Reset drag state before presenting
414
357
  isDragging = false
415
-
416
358
  setupSheetDetents()
417
359
  setStateForDetentIndex(detentIndex)
418
360
 
419
- // Notify delegate before showing
420
361
  val detentInfo = getDetentInfoForIndex(detentIndex)
421
362
  delegate?.viewControllerWillPresent(detentInfo.index, detentInfo.position)
422
363
 
423
364
  if (!animated) {
424
- // Disable animation
425
365
  dialog.window?.setWindowAnimations(0)
426
366
  }
427
367
 
@@ -429,67 +369,46 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
429
369
  }
430
370
  }
431
371
 
432
- /**
433
- * Dismiss the sheet.
434
- */
435
372
  fun dismiss() {
436
373
  this.post {
437
- // Emit position change with transitioning=true to animate dismissal
438
- // Use maxScreenHeight as the off-screen position (sheet slides down off screen)
439
- val offScreenPosition = maxScreenHeight.pxToDp()
374
+ val offScreenPosition = screenHeight.pxToDp()
440
375
  delegate?.viewControllerDidChangePosition(currentDetentIndex, offScreenPosition, transitioning = true)
441
376
  }
442
-
443
377
  dialog?.dismiss()
444
378
  }
445
379
 
446
- // ==================== Configuration ====================
380
+ // ====================================================================
381
+ // MARK: - Sheet Configuration
382
+ // ====================================================================
447
383
 
448
- /**
449
- * Setup sheet detents based on the detent preference.
450
- */
451
384
  fun setupSheetDetents() {
452
385
  val behavior = this.behavior ?: return
453
386
 
454
- // Configure sheet sizes
455
387
  behavior.apply {
456
388
  skipCollapsed = false
457
389
  isFitToContents = true
458
-
459
- // m3 max width 640dp
460
- maxWidth = 640.0.dpToPx().toInt()
390
+ maxWidth = DEFAULT_MAX_WIDTH.dpToPx().toInt()
461
391
 
462
392
  when (detents.size) {
463
393
  1 -> {
464
- val detentHeight = getDetentHeight(detents[0])
465
- maxHeight = detentHeight
394
+ maxHeight = getDetentHeight(detents[0])
466
395
  skipCollapsed = true
467
396
  }
468
397
 
469
398
  2 -> {
470
- val peekHeight = getDetentHeight(detents[0])
471
- val maxHeightValue = getDetentHeight(detents[1])
472
- maxHeight = maxHeightValue
473
- setPeekHeight(peekHeight, isPresented)
399
+ setPeekHeight(getDetentHeight(detents[0]), isPresented)
400
+ maxHeight = getDetentHeight(detents[1])
474
401
  }
475
402
 
476
403
  3 -> {
477
- // Enables half expanded
478
404
  isFitToContents = false
479
-
480
- val peekHeightValue = getDetentHeight(detents[0])
481
- val middleDetentHeight = getDetentHeight(detents[1])
482
- val maxHeightValue = getDetentHeight(detents[2])
483
-
484
- setPeekHeight(peekHeightValue, isPresented)
485
- maxHeight = maxHeightValue
405
+ setPeekHeight(getDetentHeight(detents[0]), isPresented)
406
+ maxHeight = getDetentHeight(detents[2])
486
407
  expandedOffset = sheetTopInset
487
- halfExpandedRatio = minOf(middleDetentHeight.toFloat() / maxScreenHeight.toFloat(), 1.0f)
408
+ halfExpandedRatio = minOf(getDetentHeight(detents[1]).toFloat() / screenHeight.toFloat(), 1.0f)
488
409
  }
489
410
  }
490
411
 
491
- // Force a layout update when sheet is presented (e.g., during rotation)
492
- // This ensures the container respects the new maxHeight constraint
493
412
  if (isPresented) {
494
413
  sheetContainer?.apply {
495
414
  val params = layoutParams
@@ -500,36 +419,51 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
500
419
  }
501
420
  }
502
421
 
503
- /**
504
- * Setup background color and corner radius.
505
- */
506
- fun setupBackground() {
507
- sheetContainer?.apply {
508
- val outerRadii = floatArrayOf(
509
- cornerRadius,
510
- cornerRadius,
511
- cornerRadius,
512
- cornerRadius,
513
- 0f,
514
- 0f,
515
- 0f,
516
- 0f
517
- )
422
+ fun setupGrabber() {
423
+ val bottomSheet = bottomSheetView ?: return
424
+
425
+ bottomSheet.findViewWithTag<View>(GRABBER_TAG)?.let {
426
+ bottomSheet.removeView(it)
427
+ }
428
+
429
+ if (!grabber) return
430
+
431
+ val grabberView = View(reactContext).apply {
432
+ 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
+ }
447
+
448
+ bottomSheet.addView(grabberView)
449
+ }
518
450
 
519
- // 0 (transparent) is used as sentinel for "use system default"
520
- val backgroundColor = if (sheetBackgroundColor != 0) sheetBackgroundColor else getDefaultBackgroundColor()
451
+ fun setupBackground() {
452
+ val bottomSheet = bottomSheetView ?: return
521
453
 
522
- val background = ShapeDrawable(RoundRectShape(outerRadii, null, null))
523
- background.paint.color = backgroundColor
454
+ val cornerRadius = if (sheetCornerRadius < 0) DEFAULT_CORNER_RADIUS.dpToPx() else sheetCornerRadius
455
+ val outerRadii = floatArrayOf(cornerRadius, cornerRadius, cornerRadius, cornerRadius, 0f, 0f, 0f, 0f)
456
+ val backgroundColor = if (sheetBackgroundColor != 0) sheetBackgroundColor else getDefaultBackgroundColor()
524
457
 
525
- this.background = background
526
- this.clipToOutline = true
458
+ bottomSheet.background = ShapeDrawable(RoundRectShape(outerRadii, null, null)).apply {
459
+ paint.color = backgroundColor
527
460
  }
461
+ bottomSheet.clipToOutline = true
528
462
  }
529
463
 
530
464
  /**
531
- * Setup dimmed sheet.
532
- * `dimmedDetentIndex` will further customize the dimming behavior.
465
+ * Configures the dimmed background based on the current detent index.
466
+ * When not dimmed, touch events pass through to the activity behind the sheet.
533
467
  */
534
468
  fun setupDimmedBackground(detentIndex: Int) {
535
469
  val dialog = this.dialog ?: return
@@ -537,47 +471,39 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
537
471
  val view = findViewById<View>(com.google.android.material.R.id.touch_outside)
538
472
 
539
473
  if (dimmed && detentIndex >= dimmedDetentIndex) {
540
- // Remove touch listener
541
474
  view.setOnTouchListener(null)
542
-
543
- // Add the dimmed background
544
- setFlags(
545
- WindowManager.LayoutParams.FLAG_DIM_BEHIND,
546
- WindowManager.LayoutParams.FLAG_DIM_BEHIND
547
- )
548
-
475
+ setFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND, WindowManager.LayoutParams.FLAG_DIM_BEHIND)
549
476
  dialog.setCanceledOnTouchOutside(dismissible)
550
477
  } else {
551
- // Override the background touch and pass it to the components outside
478
+ // Forward touch events to the activity when not dimmed
552
479
  view.setOnTouchListener { v, event ->
553
480
  event.setLocation(event.rawX - v.x, event.rawY - v.y)
554
481
  reactContext.currentActivity?.dispatchTouchEvent(event)
555
482
  false
556
483
  }
557
-
558
- // Remove the dimmed background
559
484
  clearFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND)
560
-
561
485
  dialog.setCanceledOnTouchOutside(false)
562
486
  }
563
487
  }
564
488
  }
565
489
 
566
490
  fun resetAnimation() {
567
- dialog?.window?.apply {
568
- setWindowAnimations(windowAnimation)
569
- }
491
+ dialog?.window?.setWindowAnimations(windowAnimation)
570
492
  }
571
493
 
494
+ /**
495
+ * Positions the footer view at the bottom of the sheet.
496
+ * The footer stays fixed at the bottom edge of the visible sheet area,
497
+ * adjusting during drag gestures via slideOffset.
498
+ */
572
499
  fun positionFooter(slideOffset: Float? = null) {
573
- val footer = footerView ?: return
500
+ val footerView = containerView?.footerView ?: return
574
501
  val bottomSheet = bottomSheetView ?: return
575
- val footerHeight = footer.height
576
502
 
503
+ val footerHeight = footerView.height
577
504
  val bottomSheetY = ScreenUtils.getScreenY(bottomSheet)
578
505
 
579
- // Calculate footer Y position based on bottom sheet position
580
- var footerY = (maxScreenHeight - bottomSheetY - footerHeight).toFloat()
506
+ var footerY = (screenHeight - bottomSheetY - footerHeight).toFloat()
581
507
 
582
508
  // Animate footer down with sheet when below peek height
583
509
  if (slideOffset != null && slideOffset < 0) {
@@ -586,14 +512,10 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
586
512
 
587
513
  // Clamp footer position to prevent it from going off screen when positioning at the top
588
514
  // This happens when fullScreen is enabled in edge-to-edge mode
589
- val statusBarHeight = ScreenUtils.getStatusBarHeight(reactContext)
590
- val maxAllowedY = (maxScreenHeight - statusBarHeight - footerHeight).toFloat()
591
- footer.y = minOf(footerY, maxAllowedY)
515
+ val maxAllowedY = (screenHeight - statusBarHeight - footerHeight).toFloat()
516
+ footerView.y = minOf(footerY, maxAllowedY)
592
517
  }
593
518
 
594
- /**
595
- * Set the state based for the given detent index.
596
- */
597
519
  fun setStateForDetentIndex(index: Int) {
598
520
  behavior?.state = getStateForDetentIndex(index)
599
521
  }
@@ -602,62 +524,54 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
602
524
  dialog?.window?.setSoftInputMode(mode)
603
525
  }
604
526
 
605
- // ==================== Drag Handling ====================
527
+ fun getDefaultBackgroundColor(): Int {
528
+ val typedValue = TypedValue()
529
+ return if (reactContext.theme.resolveAttribute(
530
+ com.google.android.material.R.attr.colorSurfaceContainerLow,
531
+ typedValue,
532
+ true
533
+ )
534
+ ) {
535
+ typedValue.data
536
+ } else {
537
+ Color.WHITE
538
+ }
539
+ }
540
+
541
+ // ====================================================================
542
+ // MARK: - Drag Handling
543
+ // ====================================================================
606
544
 
607
- /**
608
- * Get current detent info from sheet view position
609
- */
610
545
  private fun getCurrentDetentInfo(sheetView: View): DetentInfo {
611
- // Get the Y position in screen coordinates (like iOS presentedView.frame.origin.y)
612
546
  val screenY = ScreenUtils.getScreenY(sheetView)
613
- val position = screenY.pxToDp()
614
- return DetentInfo(currentDetentIndex, position)
547
+ return DetentInfo(currentDetentIndex, screenY.pxToDp())
615
548
  }
616
549
 
617
- /**
618
- * Handle drag begin
619
- */
620
550
  private fun handleDragBegin(sheetView: View) {
621
551
  val detentInfo = getCurrentDetentInfo(sheetView)
622
552
  delegate?.viewControllerDidDragBegin(detentInfo.index, detentInfo.position)
623
553
  isDragging = true
624
554
  }
625
555
 
626
- /**
627
- * Handle drag change
628
- */
629
556
  private fun handleDragChange(sheetView: View) {
630
557
  if (!isDragging) return
631
-
632
558
  val detentInfo = getCurrentDetentInfo(sheetView)
633
559
  delegate?.viewControllerDidDragChange(detentInfo.index, detentInfo.position)
634
560
  }
635
561
 
636
- /**
637
- * Handle drag end
638
- */
639
562
  private fun handleDragEnd(state: Int) {
640
563
  if (!isDragging) return
641
564
 
642
- // For consistency with iOS,
643
- // we only handle state changes after dragging.
644
- //
645
- // Changing detent programmatically is handled via the present method.
646
565
  val detentInfo = getDetentInfoForState(state)
647
566
  detentInfo?.let {
648
- // Notify delegate of drag end
649
567
  delegate?.viewControllerDidDragEnd(it.index, it.position)
650
568
 
651
569
  if (it.index != currentDetentIndex) {
652
- presentPromise?.let { promise ->
653
- promise()
654
- presentPromise = null
655
- }
570
+ presentPromise?.invoke()
571
+ presentPromise = null
656
572
 
657
573
  currentDetentIndex = it.index
658
574
  setupDimmedBackground(it.index)
659
-
660
- // Notify delegate of detent change
661
575
  delegate?.viewControllerDidChangeDetent(it.index, it.position)
662
576
  }
663
577
  }
@@ -665,162 +579,105 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
665
579
  isDragging = false
666
580
  }
667
581
 
668
- // ==================== Detent Calculations ====================
582
+ // ====================================================================
583
+ // MARK: - Detent Calculations
584
+ // ====================================================================
669
585
 
670
- /**
671
- * Get the height value based on the detent config value.
672
- */
673
586
  private fun getDetentHeight(detent: Double): Int {
674
587
  val height: Int = if (detent == -1.0) {
675
- // -1.0 represents "auto"
676
- contentHeight
588
+ contentHeight + headerHeight
677
589
  } else {
678
590
  if (detent <= 0.0 || detent > 1.0) {
679
591
  throw IllegalArgumentException("TrueSheet: detent fraction ($detent) must be between 0 and 1")
680
592
  }
681
- (detent * maxScreenHeight).toInt()
593
+ (detent * screenHeight).toInt()
682
594
  }
683
595
 
684
- // Apply top inset when edge-to-edge is enabled and fullScreen is false
685
- val maxAllowedHeight = maxScreenHeight - sheetTopInset
686
- val finalHeight = maxSheetHeight?.let { minOf(height, it, maxAllowedHeight) } ?: minOf(height, maxAllowedHeight)
687
- return finalHeight
596
+ val maxAllowedHeight = screenHeight - sheetTopInset
597
+ return maxSheetHeight?.let { minOf(height, it, maxAllowedHeight) } ?: minOf(height, maxAllowedHeight)
688
598
  }
689
599
 
690
- /**
691
- * Determines the state based from the given detent index.
692
- */
693
600
  private fun getStateForDetentIndex(index: Int): Int =
694
601
  when (detents.size) {
695
- 1 -> {
696
- BottomSheetBehavior.STATE_EXPANDED
697
- }
602
+ 1 -> BottomSheetBehavior.STATE_EXPANDED
698
603
 
699
- 2 -> {
700
- when (index) {
701
- 0 -> BottomSheetBehavior.STATE_COLLAPSED
702
- 1 -> BottomSheetBehavior.STATE_EXPANDED
703
- else -> BottomSheetBehavior.STATE_HIDDEN
704
- }
604
+ 2 -> when (index) {
605
+ 0 -> BottomSheetBehavior.STATE_COLLAPSED
606
+ 1 -> BottomSheetBehavior.STATE_EXPANDED
607
+ else -> BottomSheetBehavior.STATE_HIDDEN
705
608
  }
706
609
 
707
- 3 -> {
708
- when (index) {
709
- 0 -> BottomSheetBehavior.STATE_COLLAPSED
710
- 1 -> BottomSheetBehavior.STATE_HALF_EXPANDED
711
- 2 -> BottomSheetBehavior.STATE_EXPANDED
712
- else -> BottomSheetBehavior.STATE_HIDDEN
713
- }
610
+ 3 -> when (index) {
611
+ 0 -> BottomSheetBehavior.STATE_COLLAPSED
612
+ 1 -> BottomSheetBehavior.STATE_HALF_EXPANDED
613
+ 2 -> BottomSheetBehavior.STATE_EXPANDED
614
+ else -> BottomSheetBehavior.STATE_HIDDEN
714
615
  }
715
616
 
716
617
  else -> BottomSheetBehavior.STATE_HIDDEN
717
618
  }
718
619
 
719
- /**
720
- * Get the DetentInfo data by state.
721
- */
722
620
  fun getDetentInfoForState(state: Int): DetentInfo? =
723
621
  when (detents.size) {
724
- 1 -> {
725
- when (state) {
726
- BottomSheetBehavior.STATE_COLLAPSED -> DetentInfo(0, getPositionForDetentIndex(0))
727
- BottomSheetBehavior.STATE_EXPANDED -> DetentInfo(0, getPositionForDetentIndex(0))
728
- else -> null
729
- }
622
+ 1 -> when (state) {
623
+ BottomSheetBehavior.STATE_COLLAPSED,
624
+ BottomSheetBehavior.STATE_EXPANDED -> DetentInfo(0, getPositionForDetentIndex(0))
625
+
626
+ else -> null
730
627
  }
731
628
 
732
- 2 -> {
733
- when (state) {
734
- BottomSheetBehavior.STATE_COLLAPSED -> DetentInfo(0, getPositionForDetentIndex(0))
735
- BottomSheetBehavior.STATE_EXPANDED -> DetentInfo(1, getPositionForDetentIndex(1))
736
- else -> null
737
- }
629
+ 2 -> when (state) {
630
+ BottomSheetBehavior.STATE_COLLAPSED -> DetentInfo(0, getPositionForDetentIndex(0))
631
+ BottomSheetBehavior.STATE_EXPANDED -> DetentInfo(1, getPositionForDetentIndex(1))
632
+ else -> null
738
633
  }
739
634
 
740
- 3 -> {
741
- when (state) {
742
- BottomSheetBehavior.STATE_COLLAPSED -> DetentInfo(0, getPositionForDetentIndex(0))
743
- BottomSheetBehavior.STATE_HALF_EXPANDED -> DetentInfo(1, getPositionForDetentIndex(1))
744
- BottomSheetBehavior.STATE_EXPANDED -> DetentInfo(2, getPositionForDetentIndex(2))
745
- else -> null
746
- }
635
+ 3 -> when (state) {
636
+ BottomSheetBehavior.STATE_COLLAPSED -> DetentInfo(0, getPositionForDetentIndex(0))
637
+ BottomSheetBehavior.STATE_HALF_EXPANDED -> DetentInfo(1, getPositionForDetentIndex(1))
638
+ BottomSheetBehavior.STATE_EXPANDED -> DetentInfo(2, getPositionForDetentIndex(2))
639
+ else -> null
747
640
  }
748
641
 
749
642
  else -> null
750
643
  }
751
644
 
752
- /**
753
- * Calculate the expected Y position for a given detent index.
754
- * Uses actual screen position if available, otherwise calculates based on screen height.
755
- */
756
645
  private fun getPositionForDetentIndex(index: Int): Float {
757
- if (index < 0 || index >= detents.size) {
758
- return 0f
759
- }
646
+ if (index < 0 || index >= detents.size) return 0f
760
647
 
761
- // Try to get actual position from bottom sheet view first (same view used in behavior callbacks)
762
648
  bottomSheetView?.let {
763
- it
764
649
  val screenY = ScreenUtils.getScreenY(it)
765
- // Only use actual position if sheet has been positioned (screenY > 0)
766
- if (screenY > 0) {
767
- return screenY.pxToDp()
768
- }
650
+ if (screenY > 0) return screenY.pxToDp()
769
651
  }
770
652
 
771
- // Fallback: calculate expected position
772
653
  val detentHeight = getDetentHeight(detents[index])
773
-
774
- // Position calculation is simple: screen height - sheet height
775
- // In both edge-to-edge and non-edge-to-edge modes, getScreenY returns
776
- // coordinates in screen space, and maxScreenHeight represents the available height
777
- // for the sheet, so the calculation is the same
778
- val positionPx = maxScreenHeight - detentHeight
779
-
780
- return positionPx.pxToDp()
654
+ return (screenHeight - detentHeight).pxToDp()
781
655
  }
782
656
 
783
- /**
784
- * Get DetentInfo data for given detent index.
785
- */
786
657
  fun getDetentInfoForIndex(index: Int) = getDetentInfoForState(getStateForDetentIndex(index)) ?: DetentInfo(0, 0f)
787
658
 
788
- // ==================== RootView Implementation ====================
659
+ // ====================================================================
660
+ // MARK: - RootView Implementation
661
+ // ====================================================================
789
662
 
790
663
  override fun onInitializeAccessibilityNodeInfo(info: AccessibilityNodeInfo) {
791
664
  super.onInitializeAccessibilityNodeInfo(info)
792
-
793
- val testId = getTag(R.id.react_test_id) as String?
794
- if (testId != null) {
795
- info.viewIdResourceName = testId
796
- }
665
+ (getTag(R.id.react_test_id) as? String)?.let { info.viewIdResourceName = it }
797
666
  }
798
667
 
799
668
  override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
800
669
  super.onSizeChanged(w, h, oldw, oldh)
801
- viewWidth = w
802
- viewHeight = h
803
-
804
- // Update container state with new dimensions
805
- containerView?.updateState(viewWidth, viewHeight)
806
-
807
- // Only proceed if size actually changed
808
670
  if (w == oldw && h == oldh) return
809
671
 
810
- // Update screen height based on new dimensions
811
- val oldMaxScreenHeight = maxScreenHeight
812
- maxScreenHeight = ScreenUtils.getScreenHeight(reactContext, edgeToEdgeEnabled)
672
+ delegate?.viewControllerDidChangeSize(w, getEffectiveSheetHeight(h, headerHeight))
813
673
 
814
- // Only handle rotation if sheet is presented and screen height actually changed
815
- if (isPresented && oldMaxScreenHeight != maxScreenHeight && oldMaxScreenHeight > 0) {
816
- // Recalculate sheet detents with new screen dimensions
817
- setupSheetDetents()
674
+ val oldScreenHeight = screenHeight
675
+ screenHeight = ScreenUtils.getScreenHeight(reactContext, edgeToEdgeEnabled)
818
676
 
677
+ if (isPresented && oldScreenHeight != screenHeight && oldScreenHeight > 0) {
678
+ setupSheetDetents()
819
679
  this.post {
820
- // Update footer position after rotation
821
680
  positionFooter()
822
-
823
- // Notify JS about position change after rotation settles
824
681
  val detentInfo = getDetentInfoForIndex(currentDetentIndex)
825
682
  delegate?.viewControllerDidChangePosition(detentInfo.index, detentInfo.position, transitioning = true)
826
683
  }
@@ -831,22 +688,54 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
831
688
  reactContext.reactApplicationContext.handleException(RuntimeException(t))
832
689
  }
833
690
 
691
+ // ====================================================================
692
+ // MARK: - Touch Event Handling
693
+ // ====================================================================
694
+
695
+ /**
696
+ * Custom touch dispatch to handle footer touch events.
697
+ * The footer is positioned outside the normal view hierarchy, so we need to
698
+ * manually check if touches fall within its bounds and forward them.
699
+ */
700
+ override fun dispatchTouchEvent(event: MotionEvent): Boolean {
701
+ val footer = containerView?.footerView
702
+ if (footer != null && footer.visibility == View.VISIBLE) {
703
+ val footerLocation = ScreenUtils.getScreenLocation(footer)
704
+ val touchScreenX = event.rawX.toInt()
705
+ val touchScreenY = event.rawY.toInt()
706
+
707
+ if (touchScreenX >= footerLocation[0] &&
708
+ touchScreenX <= footerLocation[0] + footer.width &&
709
+ touchScreenY >= footerLocation[1] &&
710
+ touchScreenY <= footerLocation[1] + footer.height
711
+ ) {
712
+ val localEvent = MotionEvent.obtain(event)
713
+ localEvent.setLocation(
714
+ (touchScreenX - footerLocation[0]).toFloat(),
715
+ (touchScreenY - footerLocation[1]).toFloat()
716
+ )
717
+ val handled = footer.dispatchTouchEvent(localEvent)
718
+ localEvent.recycle()
719
+ if (handled) return true
720
+ }
721
+ }
722
+ return super.dispatchTouchEvent(event)
723
+ }
724
+
834
725
  override fun onInterceptTouchEvent(event: MotionEvent): Boolean {
835
- eventDispatcher?.let { eventDispatcher ->
836
- jSTouchDispatcher.handleTouchEvent(event, eventDispatcher, reactContext)
837
- jSPointerDispatcher?.handleMotionEvent(event, eventDispatcher, true)
726
+ eventDispatcher?.let {
727
+ jSTouchDispatcher.handleTouchEvent(event, it, reactContext)
728
+ jSPointerDispatcher?.handleMotionEvent(event, it, true)
838
729
  }
839
730
  return super.onInterceptTouchEvent(event)
840
731
  }
841
732
 
842
733
  override fun onTouchEvent(event: MotionEvent): Boolean {
843
- eventDispatcher?.let { eventDispatcher ->
844
- jSTouchDispatcher.handleTouchEvent(event, eventDispatcher, reactContext)
845
- jSPointerDispatcher?.handleMotionEvent(event, eventDispatcher, false)
734
+ eventDispatcher?.let {
735
+ jSTouchDispatcher.handleTouchEvent(event, it, reactContext)
736
+ jSPointerDispatcher?.handleMotionEvent(event, it, false)
846
737
  }
847
738
  super.onTouchEvent(event)
848
- // In case when there is no children interested in handling touch event, we return true from
849
- // the root view in order to receive subsequent events related to that gesture
850
739
  return true
851
740
  }
852
741
 
@@ -860,12 +749,10 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
860
749
  return super.onHoverEvent(event)
861
750
  }
862
751
 
863
- @OptIn(UnstableReactNativeAPI::class)
864
- @Suppress("DEPRECATION")
865
752
  override fun onChildStartedNativeGesture(childView: View?, ev: MotionEvent) {
866
- eventDispatcher?.let { eventDispatcher ->
867
- jSTouchDispatcher.onChildStartedNativeGesture(ev, eventDispatcher, reactContext)
868
- jSPointerDispatcher?.onChildStartedNativeGesture(childView, ev, eventDispatcher)
753
+ eventDispatcher?.let {
754
+ jSTouchDispatcher.onChildStartedNativeGesture(ev, it)
755
+ jSPointerDispatcher?.onChildStartedNativeGesture(childView, ev, it)
869
756
  }
870
757
  }
871
758
 
@@ -875,7 +762,6 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
875
762
  }
876
763
 
877
764
  override fun requestDisallowInterceptTouchEvent(disallowIntercept: Boolean) {
878
- // Allow the request to propagate to parent
879
765
  super.requestDisallowInterceptTouchEvent(disallowIntercept)
880
766
  }
881
767
  }