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

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