@swmansion/react-native-bottom-sheet 0.15.0-next.5 → 0.15.0-next.7

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.
@@ -1,963 +1,452 @@
1
+ @file:Suppress("DEPRECATION")
2
+
1
3
  package com.swmansion.reactnativebottomsheet
2
4
 
5
+ import android.annotation.SuppressLint
6
+ import android.app.Activity
3
7
  import android.content.Context
4
- import android.graphics.Canvas
5
8
  import android.graphics.Color
6
- import android.graphics.Paint
9
+ import android.graphics.drawable.ColorDrawable
10
+ import android.os.Build
11
+ import android.view.Gravity
7
12
  import android.view.MotionEvent
8
- import android.view.VelocityTracker
9
13
  import android.view.View
10
- import android.view.ViewConfiguration
11
14
  import android.view.ViewGroup
12
- import android.widget.FrameLayout
13
- import androidx.dynamicanimation.animation.DynamicAnimation
14
- import androidx.dynamicanimation.animation.SpringAnimation
15
- import androidx.dynamicanimation.animation.SpringForce
16
- import com.facebook.react.bridge.Arguments
15
+ import android.view.Window
16
+ import android.view.WindowManager
17
+ import androidx.activity.ComponentActivity
18
+ import androidx.activity.ComponentDialog
19
+ import androidx.activity.OnBackPressedCallback
20
+ import androidx.core.view.WindowCompat
21
+ import com.facebook.react.bridge.LifecycleEventListener
22
+ import com.facebook.react.common.annotations.UnstableReactNativeAPI
23
+ import com.facebook.react.config.ReactFeatureFlags
24
+ import com.facebook.react.uimanager.JSPointerDispatcher
25
+ import com.facebook.react.uimanager.JSTouchDispatcher
17
26
  import com.facebook.react.uimanager.PointerEvents
27
+ import com.facebook.react.uimanager.RootView
18
28
  import com.facebook.react.uimanager.StateWrapper
19
- import com.facebook.react.uimanager.events.NativeGestureUtil
29
+ import com.facebook.react.uimanager.ThemedReactContext
30
+ import com.facebook.react.uimanager.UIManagerHelper
31
+ import com.facebook.react.uimanager.events.EventDispatcher
20
32
  import com.facebook.react.views.view.ReactViewGroup
21
- import kotlin.math.abs
22
-
23
- private enum class DetentKind {
24
- POINTS,
25
- CONTENT,
26
- }
27
-
28
- private data class RawDetentSpec(val value: Float, val kind: DetentKind, val programmatic: Boolean)
29
-
30
- private data class DetentSpec(val height: Float, val programmatic: Boolean)
31
-
32
- interface BottomSheetViewListener {
33
- fun onIndexChange(index: Int)
34
33
 
35
- fun onSettle(index: Int)
34
+ /**
35
+ * Fabric-mounted bottom-sheet view. It is a thin coordinator around a single [BottomSheetHostView]
36
+ * that does the real work (scrim, gestures, detents, layout, events).
37
+ *
38
+ * In the default (portal) mode the host is a full-size child of this view, so behavior is identical
39
+ * to hosting the logic directly. In native-overlay mode the host is hoisted into a full-screen,
40
+ * edge-to-edge, transparent dialog that floats above everything—including native modal
41
+ * screens—letting the sheet escape the JS portal's React tree (see issue #16).
42
+ *
43
+ * Child mounting and prop setters are delegated to the host; the view manager already routes
44
+ * children through [addSheetChild]/[sheetChildCount].
45
+ */
46
+ class BottomSheetView(context: Context) : ReactViewGroup(context), LifecycleEventListener {
47
+
48
+ private val host = BottomSheetHostView(context)
49
+ private val themedReactContext = context as? ThemedReactContext
50
+ private var overlayDialog: ComponentDialog? = null
51
+ private var overlayRoot: BottomSheetDialogRootView? = null
52
+ private var nativeOverlay = false
53
+ // Cached only while a dialog is present, so per-frame interaction callbacks
54
+ // don't thrash the window flags.
55
+ private var overlayInteractive: Boolean? = null
36
56
 
37
- fun onPositionChange(position: Double, index: Double)
38
- }
39
-
40
- class BottomSheetView(context: Context) : ReactViewGroup(context) {
57
+ init {
58
+ pointerEvents = PointerEvents.BOX_NONE
59
+ host.interactionListener = { interactive -> updateOverlayTouchability(interactive) }
60
+ attachHostInline()
61
+ // The overlay dialog's window is bound to the host activity, so we follow the
62
+ // activity lifecycle: tear the window down before the activity is destroyed
63
+ // (otherwise the window leaks) and restore it when the activity resumes.
64
+ themedReactContext?.addLifecycleEventListener(this)
65
+ }
41
66
 
42
- // MARK: - Listener
67
+ override fun setId(id: Int) {
68
+ super.setId(id)
69
+ overlayRoot?.id = id
70
+ }
43
71
 
44
- var listener: BottomSheetViewListener? = null
45
- var stateWrapper: StateWrapper? = null
72
+ // MARK: - Listener / state forwarding
46
73
 
47
- // MARK: - State
74
+ var listener: BottomSheetViewListener?
75
+ get() = host.listener
76
+ set(value) {
77
+ host.listener = value
78
+ }
48
79
 
49
- private var rawDetentSpecs: List<RawDetentSpec> = emptyList()
50
- private var detentSpecs: List<DetentSpec> = emptyList()
51
- private var targetIndex: Int = 0
52
- var animateIn: Boolean = true
53
- var modal: Boolean = false
80
+ var stateWrapper: StateWrapper?
81
+ get() = host.stateWrapper
54
82
  set(value) {
55
- field = value
56
- updateInteractionState()
57
- updateScrim()
83
+ host.stateWrapper = value
58
84
  }
59
85
 
60
- var disableScrollableNegotiation: Boolean = false
61
- private var pendingIndex: Int? = null
62
- private var hasLaidOut = false
63
- private var isPanning = false
64
- private var panStartingIndex: Int? = null
65
-
66
- // MARK: - Internal
67
-
68
- private val sheetContainer = FrameLayout(context)
69
- private val scrimPaint = Paint(Paint.ANTI_ALIAS_FLAG)
70
- private var activeAnimation: SpringAnimation? = null
71
- private var activeAnimationEmitsSettle = false
72
- private var velocityTracker: VelocityTracker? = null
73
- private val density = context.resources.displayMetrics.density
74
- private val touchSlop = ViewConfiguration.get(context).scaledTouchSlop
75
-
76
- // Touch tracking
77
- private var initialTouchY = 0f
78
- private var initialTouchX = 0f
79
- private var lastTouchY = 0f
80
- private var activePointerId = MotionEvent.INVALID_POINTER_ID
81
- private var scrimPressed = false
82
- private var scrimTouchActive = false
83
- private var scrimColor = Color.TRANSPARENT
84
- // The JS layer always supplies a per-detent array; the fully-opaque fallback
85
- // only guards against empty input (indexing requires a non-empty array).
86
- private var scrimOpacities = listOf(1f)
87
- private var scrimProgress = 0f
88
- private var suppressScrimForClosingTarget = false
89
- private var scrimPinnedFull = false
90
- private var maxDetentHeight = Float.NaN
91
- private var contentHeightMarker: View? = null
92
- private var surfaceView: View? = null
93
-
94
- private val contentHeightMarkerLayoutListener =
95
- View.OnLayoutChangeListener { _, _, _, _, _, _, _, _, _ ->
96
- refreshDetentsFromLayout()
86
+ var eventDispatcher: EventDispatcher? = null
87
+ set(value) {
88
+ field = value
89
+ overlayRoot?.eventDispatcher = value
97
90
  }
98
91
 
99
- init {
100
- clipChildren = false
101
- clipToPadding = false
102
- // Set directly rather than via the JSX prop because Fabric doesn't forward
103
- // pointerEvents to the native view on Android. Without BOX_NONE the view
104
- // itself becomes a touch target and its onTouchEvent would claim gestures
105
- // that should go to children.
106
- pointerEvents = PointerEvents.BOX_NONE
107
- sheetContainer.clipChildren = false
108
- sheetContainer.clipToPadding = false
109
- super.addView(
110
- sheetContainer,
111
- LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT),
112
- )
113
- }
92
+ // MARK: - Child view management (routed to the host's sheet container)
114
93
 
115
94
  val sheetChildCount: Int
116
- get() = sheetContainer.childCount
95
+ get() = host.sheetChildCount
117
96
 
118
- fun getSheetChildAt(index: Int): View? = sheetContainer.getChildAt(index)
97
+ fun getSheetChildAt(index: Int): View? = host.getSheetChildAt(index)
119
98
 
120
- fun addSheetChild(child: View, index: Int) {
121
- sheetContainer.addView(child, index)
122
- refreshContentHeightMarker()
123
- }
99
+ fun addSheetChild(child: View, index: Int) = host.addSheetChild(child, index)
124
100
 
125
- fun removeSheetChildAt(index: Int) {
126
- sheetContainer.removeViewAt(index)
127
- refreshContentHeightMarker()
128
- }
101
+ fun removeSheetChildAt(index: Int) = host.removeSheetChildAt(index)
129
102
 
130
- // MARK: - Child view management
103
+ // MARK: - Prop setters (forwarded to the host)
131
104
 
132
- override fun addView(child: View, index: Int, params: ViewGroup.LayoutParams) {
133
- if (child === sheetContainer) {
134
- super.addView(child, index, params)
135
- } else {
136
- sheetContainer.addView(child, index, params)
137
- }
138
- }
105
+ fun setDetents(raw: List<Map<String, Any>>) = host.setDetents(raw)
139
106
 
140
- override fun removeView(view: View) {
141
- if (view === sheetContainer) {
142
- super.removeView(view)
143
- } else {
144
- sheetContainer.removeView(view)
145
- refreshContentHeightMarker()
146
- }
147
- }
148
-
149
- override fun removeViewAt(index: Int) {
150
- sheetContainer.removeViewAt(index)
151
- refreshContentHeightMarker()
152
- }
153
-
154
- // MARK: - Layout
155
-
156
- override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
157
- super.onLayout(changed, left, top, right, bottom)
158
- val w = right - left
159
- val h = bottom - top
160
- if (w <= 0 || h <= 0) return
161
-
162
- refreshContentHeightMarker()
163
- refreshDetentsFromLayout()
164
- layoutSheetContainer(w, h)
165
-
166
- if (!hasLaidOut && detentSpecs.isNotEmpty()) {
167
- val indexToApply = pendingIndex ?: targetIndex
168
- val clampedIndex = indexToApply.coerceIn(0, detentSpecs.size - 1)
169
-
170
- if (animateIn && isInvalidContentDetentTarget(clampedIndex)) {
171
- targetIndex = clampedIndex
172
- pendingIndex = clampedIndex
173
- val closedTy = resolvedMaxDetentHeight(h)
174
- sheetContainer.translationY = closedTy
175
- emitPosition()
176
- return
177
- }
107
+ fun setIndex(newIndex: Int) = host.setIndex(newIndex)
178
108
 
179
- hasLaidOut = true
180
- pendingIndex = null
181
- targetIndex = clampedIndex
182
-
183
- if (animateIn) {
184
- val closedTy = resolvedMaxDetentHeight(h)
185
- sheetContainer.translationY = closedTy
186
- emitPosition()
187
- snapToIndex(targetIndex, 0f, emitIndexChange = false, emitSettle = true)
188
- } else {
189
- sheetContainer.translationY = translationY(targetIndex)
190
- emitPosition()
191
- }
192
- return
109
+ var animateIn: Boolean
110
+ get() = host.animateIn
111
+ set(value) {
112
+ host.animateIn = value
193
113
  }
194
114
 
195
- if (activeAnimation != null || isPanning) return
196
- sheetContainer.translationY = translationY(targetIndex)
197
- updateShadowState(sheetContainer.translationY)
198
- }
199
-
200
- override fun dispatchDraw(canvas: Canvas) {
201
- drawScrim(canvas)
202
- super.dispatchDraw(canvas)
203
- }
204
-
205
- private fun layoutSheetChildren(containerWidth: Int, containerHeight: Int) {
206
- for (i in 0 until sheetContainer.childCount) {
207
- val child = sheetContainer.getChildAt(i)
208
- if (child === surfaceView) {
209
- // The surface fills the full container so it always covers the visible
210
- // sheet (the container is translated to the current sheet position),
211
- // regardless of how short the content becomes.
212
- child.layout(0, 0, containerWidth, containerHeight)
213
- } else {
214
- child.layout(0, 0, child.measuredWidth, child.measuredHeight)
215
- }
115
+ var modal: Boolean
116
+ get() = host.modal
117
+ set(value) {
118
+ host.modal = value
216
119
  }
217
- }
218
-
219
- private fun layoutSheetContainer(viewWidth: Int, viewHeight: Int) {
220
- val maxHeight = resolvedMaxDetentHeight(viewHeight)
221
- val containerTop = (viewHeight - maxHeight).toInt()
222
- sheetContainer.layout(0, containerTop, viewWidth, containerTop + maxHeight.toInt())
223
- layoutSheetChildren(viewWidth, maxHeight.toInt())
224
- }
225
120
 
226
- // MARK: - Prop setters
227
-
228
- fun setDetents(raw: List<Map<String, Any>>) {
229
- rawDetentSpecs = raw.mapNotNull { dict ->
230
- val value = (dict["value"] as? Number)?.toDouble() ?: return@mapNotNull null
231
- val kind =
232
- when ((dict["kind"] as? String)?.lowercase()) {
233
- "content" -> DetentKind.CONTENT
234
- else -> DetentKind.POINTS
235
- }
236
- val programmatic = dict["programmatic"] as? Boolean ?: false
237
- RawDetentSpec(value = (value * density).toFloat(), kind = kind, programmatic = programmatic)
121
+ var disableScrollableNegotiation: Boolean
122
+ get() = host.disableScrollableNegotiation
123
+ set(value) {
124
+ host.disableScrollableNegotiation = value
238
125
  }
239
- refreshDetentsFromLayout()
240
- }
241
126
 
242
- fun setIndex(newIndex: Int) {
243
- if (newIndex < 0) return
127
+ fun setScrimColor(color: Int?) = host.setScrimColor(color)
244
128
 
245
- if (!hasLaidOut) {
246
- pendingIndex = newIndex
247
- targetIndex = newIndex
248
- return
249
- }
129
+ fun setScrimOpacities(values: List<Float>) = host.setScrimOpacities(values)
250
130
 
251
- if (newIndex >= detentSpecs.size || newIndex == targetIndex) return
252
- snapToIndex(newIndex, 0f, emitIndexChange = false)
253
- }
131
+ fun setMaxDetentHeight(maxDetentHeight: Double) = host.setMaxDetentHeight(maxDetentHeight)
254
132
 
255
- fun setScrimColor(color: Int?) {
256
- scrimColor = color ?: Color.TRANSPARENT
257
- invalidate()
133
+ fun setNativeOverlay(value: Boolean) {
134
+ if (value == nativeOverlay) return
135
+ nativeOverlay = value
136
+ if (value) presentOverlay() else dismissOverlay()
258
137
  }
259
138
 
260
- fun setScrimOpacities(values: List<Float>) {
261
- scrimOpacities = if (values.isEmpty()) listOf(1f) else values
262
- updateScrim()
263
- }
139
+ // MARK: - Inline vs overlay presentation
264
140
 
265
- fun setMaxDetentHeight(maxDetentHeight: Double) {
266
- this.maxDetentHeight = (maxDetentHeight * density).toFloat()
267
- refreshDetentsFromLayout()
141
+ override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
142
+ super.onMeasure(widthMeasureSpec, heightMeasureSpec)
143
+ if (host.parent === this) {
144
+ host.measure(
145
+ MeasureSpec.makeMeasureSpec(measuredWidth, MeasureSpec.EXACTLY),
146
+ MeasureSpec.makeMeasureSpec(measuredHeight, MeasureSpec.EXACTLY),
147
+ )
148
+ }
268
149
  }
269
150
 
270
- // Stable coordinate base for the sheet container. The container is sized to
271
- // the full available height rather than the tallest detent, so it stays a
272
- // fixed-size canvas: when content — and thus the `content` detent — shrinks,
273
- // the container does not collapse underneath the sheet, leaving room to
274
- // animate the sheet down to its new height. The surface fills this canvas, so
275
- // the area below the shrunken content stays covered throughout.
276
- private fun resolvedMaxDetentHeight(viewHeight: Int = height): Float {
277
- val viewHeightPx = viewHeight.toFloat()
278
- if (!maxDetentHeight.isFinite() || maxDetentHeight <= 0f) {
279
- return viewHeightPx
151
+ override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
152
+ super.onLayout(changed, left, top, right, bottom)
153
+ if (host.parent === this) {
154
+ host.layout(0, 0, right - left, bottom - top)
280
155
  }
281
- return maxDetentHeight.coerceIn(0f, viewHeightPx)
282
156
  }
283
157
 
284
- private fun resolveDetentSpecs(): List<DetentSpec> {
285
- val maxHeight = resolvedMaxDetentHeight()
286
- val measuredContentHeight =
287
- validContentHeight().takeIf { maxHeight > 0f && it.isFinite() }?.coerceAtMost(maxHeight)
288
- val contentHeight = measuredContentHeight ?: maxHeight
289
- return rawDetentSpecs.mapIndexed { index, spec ->
290
- val height =
291
- when (spec.kind) {
292
- DetentKind.POINTS -> {
293
- if (measuredContentHeight != null && spec.value > contentHeight) {
294
- throw IllegalArgumentException(
295
- "Invalid bottom sheet detent at index $index: fixed detent ${spec.value / density} exceeds measured content height ${contentHeight / density}."
296
- )
297
- }
298
- spec.value
299
- }
300
- DetentKind.CONTENT -> contentHeight
301
- }.coerceIn(0f, maxHeight)
302
- DetentSpec(height = height, programmatic = spec.programmatic)
303
- }
158
+ private fun attachHostInline() {
159
+ if (host.parent === this) return
160
+ (host.parent as? ViewGroup)?.removeView(host)
161
+ super.addView(host, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT))
304
162
  }
305
163
 
306
- private fun refreshDetentsFromLayout() {
307
- if (hasLaidOut && isInvalidContentDetentTarget(targetIndex)) {
308
- updateScrim()
164
+ private fun presentOverlay() {
165
+ val reactContext = context as? ThemedReactContext
166
+ val activity = reactContext?.currentActivity
167
+ if (activity == null || activity.isFinishing || activity.isDestroyed) {
168
+ // Without an activity there is no window to host the dialog; stay inline.
169
+ nativeOverlay = false
309
170
  return
310
171
  }
172
+ (host.parent as? ViewGroup)?.removeView(host)
173
+
174
+ val dialog = ComponentDialog(activity, android.R.style.Theme_Translucent_NoTitleBar)
175
+ val root =
176
+ BottomSheetDialogRootView(reactContext).apply {
177
+ id = this@BottomSheetView.id
178
+ eventDispatcher = this@BottomSheetView.eventDispatcher ?: currentEventDispatcher()
179
+ addView(
180
+ host,
181
+ ViewGroup.LayoutParams(
182
+ ViewGroup.LayoutParams.MATCH_PARENT,
183
+ ViewGroup.LayoutParams.MATCH_PARENT,
184
+ ),
185
+ )
186
+ }
187
+ dialog.setContentView(
188
+ root,
189
+ ViewGroup.LayoutParams(
190
+ ViewGroup.LayoutParams.MATCH_PARENT,
191
+ ViewGroup.LayoutParams.MATCH_PARENT,
192
+ ),
193
+ )
194
+ dialog.setCancelable(false)
195
+ dialog.window?.let { configureOverlayWindow(it, activity) }
196
+ // Stay unopinionated about the system back gesture, exactly like the inline
197
+ // (portal-based) modal, which registers no back handling and leaves it to the
198
+ // consumer. The dialog is a separate focusable window that would otherwise
199
+ // consume the back press and dismiss itself, so instead of acting on it we
200
+ // forward it to the host activity. That runs whatever the consumer wired up
201
+ // (JS `BackHandler`, React Navigation, …), just as a back press would for an
202
+ // inline modal.
203
+ dialog.onBackPressedDispatcher.addCallback(
204
+ dialog,
205
+ object : OnBackPressedCallback(true) {
206
+ override fun handleOnBackPressed() {
207
+ (activity as? ComponentActivity)?.onBackPressedDispatcher?.onBackPressed()
208
+ }
209
+ },
210
+ )
211
+ overlayInteractive = null
212
+ overlayRoot = root
213
+ overlayDialog = dialog
214
+ try {
215
+ dialog.show()
216
+ dialog.window?.let { configureOverlayWindow(it, activity) }
217
+ } catch (_: RuntimeException) {
218
+ // Show failed (e.g. the activity went away mid-present). Dismiss so the
219
+ // partially-created window can't leak, then fall back to inline.
220
+ runCatching { if (dialog.isShowing) dialog.dismiss() }
221
+ overlayDialog = null
222
+ overlayRoot = null
223
+ overlayInteractive = null
224
+ nativeOverlay = false
225
+ (host.parent as? ViewGroup)?.removeView(host)
226
+ attachHostInline()
227
+ }
228
+ }
229
+
230
+ private fun dismissOverlay() {
231
+ overlayDialog?.let { dialog ->
232
+ (host.parent as? ViewGroup)?.removeView(host)
233
+ if (dialog.isShowing) dialog.dismiss()
234
+ }
235
+ overlayDialog = null
236
+ overlayRoot = null
237
+ overlayInteractive = null
238
+ attachHostInline()
239
+ }
240
+
241
+ private fun currentEventDispatcher(): EventDispatcher? =
242
+ UIManagerHelper.getEventDispatcher(UIManagerHelper.getReactContext(this))
243
+
244
+ private fun configureOverlayWindow(window: Window, activity: Activity) {
245
+ window.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT))
246
+ window.setGravity(Gravity.TOP or Gravity.START)
247
+ window.setLayout(
248
+ WindowManager.LayoutParams.MATCH_PARENT,
249
+ WindowManager.LayoutParams.MATCH_PARENT,
250
+ )
251
+ // The sheet draws its own scrim, so the window must not add system dimming.
252
+ window.setDimAmount(0f)
253
+ window.clearFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND)
254
+ // The sheet runs its own enter/exit animation; suppress the dialog's.
255
+ window.setWindowAnimations(0)
256
+ window.enableTransparentEdgeToEdge(activity)
257
+
258
+ // Start non-interactive (closed): pass touches and focus to the screen behind
259
+ // until the sheet animates open.
260
+ window.addFlags(NON_INTERACTIVE_FLAGS)
261
+ window.setLayout(
262
+ WindowManager.LayoutParams.MATCH_PARENT,
263
+ WindowManager.LayoutParams.MATCH_PARENT,
264
+ )
265
+ }
311
266
 
312
- val resolvedDetents = resolveDetentSpecs()
313
- if (resolvedDetents == detentSpecs) {
314
- updateScrim()
315
- return
267
+ private fun Window.enableTransparentEdgeToEdge(activity: Activity) {
268
+ @Suppress("DEPRECATION")
269
+ clearFlags(
270
+ WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS or
271
+ WindowManager.LayoutParams.FLAG_TRANSLUCENT_NAVIGATION
272
+ )
273
+ addFlags(WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS)
274
+
275
+ WindowCompat.setDecorFitsSystemWindows(this, false)
276
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
277
+ attributes =
278
+ attributes.apply {
279
+ layoutInDisplayCutoutMode =
280
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
281
+ WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_ALWAYS
282
+ } else {
283
+ WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES
284
+ }
285
+ }
316
286
  }
317
287
 
318
- val previousMaxHeight = resolvedMaxDetentHeight()
319
- // Whether the scrim is currently fully opaque, i.e. the sheet is settled at
320
- // or above the first non-zero detent. If so, a detent resize must not dip
321
- // the scrim while the sheet re-anchors to the new geometry.
322
- val wasScrimFull =
323
- modal &&
324
- firstNonZeroDetentHeight > 0f &&
325
- currentSheetHeight() + 0.5f >= firstNonZeroDetentHeight
326
- detentSpecs = resolvedDetents
327
- if (width > 0 && height > 0 && detentSpecs.isNotEmpty()) {
328
- layoutSheetContainer(width, height)
329
-
330
- if (hasLaidOut && !isPanning) {
331
- targetIndex = targetIndex.coerceIn(0, detentSpecs.size - 1)
332
- val newMaxHeight = resolvedMaxDetentHeight()
333
- val targetTy = translationY(targetIndex)
334
- if (activeAnimation != null && isTargetingClosedDetent) {
335
- suppressScrimForClosingTarget = true
336
- hideScrim()
337
- }
338
- if (activeAnimation != null) {
339
- val currentTy = sheetContainer.translationY
340
- val shouldEmitSettle = activeAnimationEmitsSettle
341
- activeAnimation?.cancel()
342
- activeAnimation = null
343
- activeAnimationEmitsSettle = false
344
- // Re-anchor the in-flight position to the new container height so the
345
- // sheet surface keeps the same on-screen height across the resize.
346
- val visibleHeight = previousMaxHeight - currentTy
347
- sheetContainer.translationY = (newMaxHeight - visibleHeight).coerceIn(0f, newMaxHeight)
348
- scrimPinnedFull = scrimPinnedFull || wasScrimFull
349
- emitPosition()
350
- snapToIndex(
351
- targetIndex,
352
- 0f,
353
- emitIndexChange = false,
354
- emitSettle = shouldEmitSettle,
355
- preserveScrimPin = true,
356
- )
357
- } else {
358
- val currentVisibleHeight = previousMaxHeight - sheetContainer.translationY
359
- val targetHeight = detentSpecs.getOrNull(targetIndex)?.height ?: 0f
360
- if (kotlin.math.abs(targetHeight - currentVisibleHeight) <= 0.5f) {
361
- // No meaningful change.
362
- sheetContainer.translationY = targetTy
363
- emitPosition()
364
- } else {
365
- // The content detent changed (grew or shrank): re-anchor at the
366
- // current visible height, then animate to the new target. The
367
- // surface covers the full sheet, so a shrink no longer exposes
368
- // blank space.
369
- sheetContainer.translationY =
370
- (newMaxHeight - currentVisibleHeight).coerceIn(0f, newMaxHeight)
371
- scrimPinnedFull = scrimPinnedFull || wasScrimFull
372
- emitPosition()
373
- snapToIndex(
374
- targetIndex,
375
- 0f,
376
- emitIndexChange = false,
377
- emitSettle = false,
378
- preserveScrimPin = true,
379
- )
380
- }
381
- }
288
+ @Suppress("DEPRECATION")
289
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
290
+ isStatusBarContrastEnforced = false
291
+ isNavigationBarContrastEnforced = false
292
+ }
293
+ @Suppress("DEPRECATION")
294
+ statusBarColor = Color.TRANSPARENT
295
+ @Suppress("DEPRECATION")
296
+ navigationBarColor = Color.TRANSPARENT
297
+
298
+ @Suppress("DEPRECATION")
299
+ decorView.systemUiVisibility =
300
+ View.SYSTEM_UI_FLAG_LAYOUT_STABLE or
301
+ View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN or
302
+ View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
303
+
304
+ runCatching {
305
+ val activityController =
306
+ WindowCompat.getInsetsController(activity.window, activity.window.decorView)
307
+ WindowCompat.getInsetsController(this, decorView).apply {
308
+ isAppearanceLightStatusBars = activityController.isAppearanceLightStatusBars
309
+ isAppearanceLightNavigationBars = activityController.isAppearanceLightNavigationBars
382
310
  }
383
311
  }
384
-
385
- requestLayout()
386
- updateScrim()
387
- }
388
-
389
- private fun currentContentHeight(): Float {
390
- val marker = contentHeightMarker ?: return Float.NaN
391
- return marker.top.toFloat()
392
312
  }
393
313
 
394
- private fun validContentHeight(): Float {
395
- return currentContentHeight().takeIf { it.isFinite() && it > 0f } ?: Float.NaN
396
- }
397
-
398
- private fun isInvalidContentDetentTarget(index: Int): Boolean {
399
- return rawDetentSpecs.getOrNull(index)?.kind == DetentKind.CONTENT &&
400
- !validContentHeight().isFinite()
401
- }
402
-
403
- private fun refreshContentHeightMarker() {
404
- surfaceView = findSurfaceView()
405
- val marker = findContentHeightMarker()
406
- if (marker === contentHeightMarker) return
407
- contentHeightMarker?.removeOnLayoutChangeListener(contentHeightMarkerLayoutListener)
408
- contentHeightMarker = marker
409
- contentHeightMarker?.addOnLayoutChangeListener(contentHeightMarkerLayoutListener)
410
- }
411
-
412
- private fun findSurfaceView(): View? {
413
- for (i in 0 until sheetContainer.childCount) {
414
- val child = sheetContainer.getChildAt(i)
415
- if (child is BottomSheetSurfaceView) return child
314
+ /**
315
+ * Toggles the overlay window's touchability/focusability with the sheet's interactivity. While
316
+ * the sheet is closed the window is transparent to touch and focus, so the screen behind stays
317
+ * usable; once it animates open or shows its scrim the window captures input (the scrim handles
318
+ * dismissal).
319
+ */
320
+ private fun updateOverlayTouchability(interactive: Boolean) {
321
+ val window = overlayDialog?.window ?: return
322
+ if (interactive == overlayInteractive) return
323
+ overlayInteractive = interactive
324
+ if (interactive) {
325
+ window.clearFlags(NON_INTERACTIVE_FLAGS)
326
+ } else {
327
+ window.addFlags(NON_INTERACTIVE_FLAGS)
416
328
  }
417
- return null
418
- }
419
-
420
- private fun findContentHeightMarker(): View? {
421
- // The surface is a sibling of the content wrapper; skip it so the marker is
422
- // always read from the content, never from the surface.
423
- val contentView =
424
- (0 until sheetContainer.childCount)
425
- .map { sheetContainer.getChildAt(it) }
426
- .firstOrNull { it !== surfaceView } as? ViewGroup ?: return null
427
- if (contentView.childCount == 0) return null
428
- return contentView.getChildAt(contentView.childCount - 1)
429
329
  }
430
330
 
431
- // MARK: - Snap logic
331
+ // MARK: - Activity lifecycle
432
332
 
433
- private fun translationY(index: Int): Float {
434
- val maxHeight = resolvedMaxDetentHeight()
435
- val snapHeight = detentSpecs.getOrNull(index)?.height ?: 0f
436
- return maxHeight - snapHeight
333
+ override fun onHostResume() {
334
+ // Restore the overlay if it was torn down while the activity was gone but the
335
+ // sheet should still be presented above it.
336
+ if (nativeOverlay && overlayDialog == null) {
337
+ presentOverlay()
338
+ }
437
339
  }
438
340
 
439
- private val minDetentTranslationY: Float
440
- get() = detentSpecs.indices.minOfOrNull(::translationY) ?: 0f
441
-
442
- private val maxDetentTranslationY: Float
443
- get() = detentSpecs.indices.maxOfOrNull(::translationY) ?: 0f
444
-
445
- private val closedIndex: Int?
446
- get() = detentSpecs.indexOfFirst { it.height == 0f }.takeIf { it >= 0 }
341
+ override fun onHostPause() {}
447
342
 
448
- private val firstNonZeroDetentHeight: Float
449
- get() = detentSpecs.firstOrNull { it.height > 0f }?.height ?: 0f
450
-
451
- private val isTargetingClosedDetent: Boolean
452
- get() = closedIndex?.let { targetIndex == it } == true
453
-
454
- private fun snapCandidateIndices(includeIndex: Int? = null): List<Int> {
455
- val indices = detentSpecs.indices.filter { !detentSpecs[it].programmatic }.toMutableList()
456
- if (
457
- includeIndex != null &&
458
- includeIndex in detentSpecs.indices &&
459
- detentSpecs[includeIndex].programmatic
460
- ) {
461
- indices.add(includeIndex)
343
+ override fun onHostDestroy() {
344
+ // Dismiss before the activity's window token is destroyed to avoid a leaked
345
+ // window. `nativeOverlay` is left intact so `onHostResume` can restore it;
346
+ // the host falls back to inline parenting in the meantime.
347
+ if (overlayDialog != null) {
348
+ dismissOverlay()
462
349
  }
463
- return indices.distinct().sortedBy { detentSpecs[it].height }
464
350
  }
465
351
 
466
- private fun draggableRange(includeIndex: Int? = null): ClosedFloatingPointRange<Float> {
467
- val candidates = snapCandidateIndices(includeIndex)
468
- if (candidates.isEmpty()) return 0f..0f
469
- val translations = candidates.map(::translationY)
470
- return (translations.minOrNull() ?: 0f)..(translations.maxOrNull() ?: 0f)
471
- }
352
+ // MARK: - Cleanup
472
353
 
473
- private fun isAtMaxDragCandidate(includeIndex: Int? = null): Boolean {
474
- val range = draggableRange(includeIndex)
475
- return sheetContainer.translationY <= range.start + 1f
354
+ fun destroy() {
355
+ themedReactContext?.removeLifecycleEventListener(this)
356
+ host.interactionListener = null
357
+ overlayDialog?.let { if (it.isShowing) it.dismiss() }
358
+ overlayDialog = null
359
+ overlayRoot = null
360
+ overlayInteractive = null
361
+ host.destroy()
476
362
  }
477
363
 
478
- private fun emitPosition() {
479
- val maxHeight = resolvedMaxDetentHeight()
480
- val ty = sheetContainer.translationY
481
- val position = maxHeight - ty
482
- updateScrim(position)
483
- updateSheetVisibility(position)
484
- updateInteractionState()
485
- listener?.onPositionChange((position / density).toDouble(), detentIndexAt(position).toDouble())
486
- updateShadowState(ty)
364
+ private companion object {
365
+ const val NON_INTERACTIVE_FLAGS =
366
+ WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE or WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE
487
367
  }
368
+ }
488
369
 
489
- // Fractional detent index in 0..(detentSpecs.size - 1): 0 at the shortest
490
- // detent, 1 at the next, and so on, interpolated by position in between. The
491
- // continuous counterpart of `onIndexChange`, so consumers can drive a backdrop
492
- // or animate per detent without knowing the sheet's height.
493
- private fun detentIndexAt(position: Float): Float =
494
- interpolateAtPosition(position, detentSpecs.indices.map { it.toFloat() })
370
+ private class BottomSheetDialogRootView(context: ThemedReactContext) :
371
+ ReactViewGroup(context), RootView {
495
372
 
496
- private fun updateSheetVisibility(position: Float) {
497
- sheetContainer.alpha = if (position <= 0.5f) 0f else 1f
498
- }
373
+ var eventDispatcher: EventDispatcher? = null
499
374
 
500
- private var lastShadowOffsetY = Float.NaN
501
-
502
- private fun updateShadowState(translationY: Float) {
503
- val maxDetentHeight = resolvedMaxDetentHeight()
504
- val containerTop = height.toFloat() - maxDetentHeight
505
- val offsetY = ((containerTop + translationY) / density).toDouble()
506
- if (offsetY.toFloat() == lastShadowOffsetY) return
507
- lastShadowOffsetY = offsetY.toFloat()
508
- val sw = stateWrapper ?: return
509
- val map = Arguments.createMap()
510
- map.putDouble("contentOffsetY", offsetY)
511
- sw.updateState(map)
512
- }
375
+ private val jSTouchDispatcher = JSTouchDispatcher(this)
376
+ @Suppress("DEPRECATION")
377
+ private val jSPointerDispatcher: JSPointerDispatcher? =
378
+ if (ReactFeatureFlags.dispatchPointerEvents) JSPointerDispatcher(this) else null
379
+ private val reactContext: ThemedReactContext
380
+ get() = context as ThemedReactContext
513
381
 
514
- // MARK: - Spring animation
515
-
516
- private fun snapToIndex(
517
- index: Int,
518
- velocity: Float,
519
- emitIndexChange: Boolean = true,
520
- emitSettle: Boolean = true,
521
- preserveScrimPin: Boolean = false,
522
- ) {
523
- if (index < 0 || index >= detentSpecs.size) return
524
- targetIndex = index
525
- if (!isTargetingClosedDetent) {
526
- suppressScrimForClosingTarget = false
382
+ override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
383
+ super.onMeasure(widthMeasureSpec, heightMeasureSpec)
384
+ val childWidthMeasureSpec = MeasureSpec.makeMeasureSpec(measuredWidth, MeasureSpec.EXACTLY)
385
+ val childHeightMeasureSpec = MeasureSpec.makeMeasureSpec(measuredHeight, MeasureSpec.EXACTLY)
386
+ for (index in 0 until childCount) {
387
+ getChildAt(index).measure(childWidthMeasureSpec, childHeightMeasureSpec)
527
388
  }
528
- if (!preserveScrimPin) {
529
- scrimPinnedFull = false
530
- }
531
-
532
- val targetTy = translationY(index)
533
- val currentTy = sheetContainer.translationY
534
- activeAnimationEmitsSettle = emitSettle
535
- activeAnimation?.cancel()
536
-
537
- val spring =
538
- SpringAnimation(sheetContainer, DynamicAnimation.TRANSLATION_Y, targetTy).apply {
539
- spring =
540
- SpringForce(targetTy).apply {
541
- dampingRatio = SpringForce.DAMPING_RATIO_NO_BOUNCY
542
- stiffness = SpringForce.STIFFNESS_MEDIUM
543
- }
544
- setMinValue(minOf(minDetentTranslationY, currentTy, targetTy))
545
- setMaxValue(maxOf(maxDetentTranslationY, currentTy, targetTy))
546
- setStartVelocity(velocity)
547
- // Forward the position on every frame of the settle. The listener fires
548
- // immediately after the spring writes `translationY`, so `emitPosition`
549
- // reads the value being shown this frame — keeping followers that track
550
- // `onPositionChange` (e.g. a Reanimated view) in lockstep with the sheet.
551
- addUpdateListener { _, _, _ -> emitPosition() }
552
- addEndListener { _, canceled, _, _ ->
553
- if (canceled) {
554
- return@addEndListener
555
- }
556
- activeAnimation = null
557
- activeAnimationEmitsSettle = false
558
- suppressScrimForClosingTarget = false
559
- scrimPinnedFull = false
560
- if (closedIndex == index) {
561
- sheetContainer.translationY = translationY(index)
562
- hideScrim()
563
- }
564
- emitPosition()
565
- updateInteractionState()
566
- if (emitSettle) listener?.onSettle(index)
567
- }
568
- }
569
-
570
- activeAnimation = spring
571
- // Report the index change as soon as the snap is committed, not when it
572
- // finishes: targetIndex is already set, and a programmatic snap's start is
573
- // known to the caller. onSettle remains the signal for movement end.
574
- if (emitIndexChange) listener?.onIndexChange(index)
575
- spring.start()
576
389
  }
577
390
 
578
- private fun bestSnapIndex(currentHeight: Float, velocity: Float, includeIndex: Int? = null): Int {
579
- val candidates = snapCandidateIndices(includeIndex)
580
- if (candidates.isEmpty()) return targetIndex
581
-
582
- val flickThreshold = 600f * density
583
-
584
- if (velocity < -flickThreshold) {
585
- return candidates.firstOrNull { detentSpecs[it].height > currentHeight }
586
- ?: candidates.lastOrNull()
587
- ?: targetIndex
588
- }
589
- if (velocity > flickThreshold) {
590
- return candidates.lastOrNull { detentSpecs[it].height < currentHeight }
591
- ?: candidates.firstOrNull()
592
- ?: targetIndex
391
+ override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
392
+ super.onLayout(changed, left, top, right, bottom)
393
+ for (index in 0 until childCount) {
394
+ getChildAt(index).layout(0, 0, right - left, bottom - top)
593
395
  }
594
-
595
- return candidates.minByOrNull { abs(detentSpecs[it].height - currentHeight) } ?: targetIndex
596
396
  }
597
397
 
598
- // MARK: - Touch handling
398
+ override fun handleException(t: Throwable) {
399
+ reactContext.reactApplicationContext.handleException(RuntimeException(t))
400
+ }
599
401
 
600
402
  override fun onInterceptTouchEvent(event: MotionEvent): Boolean {
601
- val sheetTop = sheetContainer.top + sheetContainer.translationY
602
- if (event.actionMasked == MotionEvent.ACTION_DOWN && event.y < sheetTop) {
603
- if (isScrimVisible()) {
604
- initialTouchX = event.x
605
- initialTouchY = event.y
606
- lastTouchY = event.y
607
- activePointerId = event.getPointerId(0)
608
- scrimPressed = true
609
- scrimTouchActive = true
610
- return true
611
- }
612
- return false
403
+ eventDispatcher?.let { dispatcher ->
404
+ jSTouchDispatcher.handleTouchEvent(event, dispatcher, reactContext)
405
+ jSPointerDispatcher?.handleMotionEvent(event, dispatcher, true)
613
406
  }
614
-
615
- when (event.actionMasked) {
616
- MotionEvent.ACTION_DOWN -> {
617
- initialTouchX = event.x
618
- initialTouchY = event.y
619
- lastTouchY = event.y
620
- activePointerId = event.getPointerId(0)
621
- }
622
- MotionEvent.ACTION_MOVE -> {
623
- if (activePointerId == MotionEvent.INVALID_POINTER_ID) return false
624
- val pointerIndex = event.findPointerIndex(activePointerId)
625
- if (pointerIndex < 0) return false
626
- val x = event.getX(pointerIndex)
627
- val y = event.getY(pointerIndex)
628
- val dx = x - initialTouchX
629
- val dy = y - initialTouchY
630
-
631
- val dragRange = draggableRange(targetIndex)
632
- if (abs(dy) > touchSlop && abs(dy) > abs(dx) && dragRange.start < dragRange.endInclusive) {
633
- if (disableScrollableNegotiation && findScrollableAtTouch() != null) {
634
- return false
635
- }
636
- if (!isAtMaxDragCandidate(targetIndex)) {
637
- lastTouchY = y
638
- requestDisallowInterceptTouchEvent(false)
639
- // Cancel in-flight JS touches. React Native's JSTouchDispatcher
640
- // processes events at the root view level before onInterceptTouchEvent
641
- // runs, so without this the JS side never sees a cancel and Pressable
642
- // would still fire onPress.
643
- NativeGestureUtil.notifyNativeGestureStarted(this, event)
644
- return true
645
- }
646
- if (dy > 0 && isScrollViewAtTop()) {
647
- lastTouchY = y
648
- requestDisallowInterceptTouchEvent(false)
649
- NativeGestureUtil.notifyNativeGestureStarted(this, event)
650
- return true
651
- }
652
- }
653
- }
654
- MotionEvent.ACTION_UP,
655
- MotionEvent.ACTION_CANCEL -> {
656
- initialTouchX = 0f
657
- initialTouchY = 0f
658
- activePointerId = MotionEvent.INVALID_POINTER_ID
659
- scrimPressed = false
660
- scrimTouchActive = false
661
- }
662
- }
663
- return false
407
+ return super.onInterceptTouchEvent(event)
664
408
  }
665
409
 
410
+ @SuppressLint("ClickableViewAccessibility")
666
411
  override fun onTouchEvent(event: MotionEvent): Boolean {
667
- if (scrimTouchActive) {
668
- when (event.actionMasked) {
669
- MotionEvent.ACTION_MOVE -> {
670
- val sheetTop = sheetContainer.top + sheetContainer.translationY
671
- if (event.y >= sheetTop || abs(event.y - initialTouchY) > touchSlop) {
672
- scrimPressed = false
673
- }
674
- return true
675
- }
676
- MotionEvent.ACTION_UP -> {
677
- val closeIndex = closedIndex
678
- val shouldDismiss = scrimPressed && isScrimVisible()
679
- scrimPressed = false
680
- scrimTouchActive = false
681
- activePointerId = MotionEvent.INVALID_POINTER_ID
682
- if (shouldDismiss && closeIndex != null) {
683
- snapToIndex(closeIndex, 0f)
684
- }
685
- return true
686
- }
687
- MotionEvent.ACTION_CANCEL -> {
688
- scrimPressed = false
689
- scrimTouchActive = false
690
- activePointerId = MotionEvent.INVALID_POINTER_ID
691
- return true
692
- }
693
- MotionEvent.ACTION_POINTER_UP -> {
694
- return true
695
- }
696
- }
697
- }
698
-
699
- when (event.actionMasked) {
700
- MotionEvent.ACTION_MOVE -> {
701
- if (!isPanning) beginPan(event)
702
- val pointerIndex = event.findPointerIndex(activePointerId)
703
- if (pointerIndex < 0) return true
704
- val y = event.getY(pointerIndex)
705
- velocityTracker?.addMovement(event)
706
- val dy = y - lastTouchY
707
- lastTouchY = y
708
-
709
- val dragRange = draggableRange(panStartingIndex)
710
- val newTy =
711
- (sheetContainer.translationY + dy).coerceIn(dragRange.start, dragRange.endInclusive)
712
- sheetContainer.translationY = newTy
713
- emitPosition()
714
- return true
715
- }
716
- MotionEvent.ACTION_UP,
717
- MotionEvent.ACTION_CANCEL -> {
718
- isPanning = false
719
- activePointerId = MotionEvent.INVALID_POINTER_ID
720
- val velocity =
721
- velocityTracker?.let { tracker ->
722
- tracker.computeCurrentVelocity(1000)
723
- val v = tracker.yVelocity
724
- tracker.recycle()
725
- v
726
- } ?: 0f
727
- velocityTracker = null
728
- val maxHeight = resolvedMaxDetentHeight()
729
- val currentHeight = maxHeight - sheetContainer.translationY
730
- val index = bestSnapIndex(currentHeight, velocity, panStartingIndex)
731
- panStartingIndex = null
732
- snapToIndex(index, velocity)
733
- return true
734
- }
735
- MotionEvent.ACTION_POINTER_UP -> {
736
- val actionIndex = event.actionIndex
737
- if (event.getPointerId(actionIndex) == activePointerId) {
738
- val newPointerIndex = if (actionIndex == 0) 1 else 0
739
- activePointerId = event.getPointerId(newPointerIndex)
740
- lastTouchY = event.getY(newPointerIndex)
741
- velocityTracker?.clear()
742
- }
743
- return true
744
- }
745
- }
746
- return super.onTouchEvent(event)
747
- }
748
-
749
- private fun beginPan(event: MotionEvent) {
750
- isPanning = true
751
- scrimPinnedFull = false
752
- panStartingIndex = targetIndex
753
- activePointerId = event.getPointerId(0)
754
- lastTouchY = event.y
755
- velocityTracker?.recycle()
756
- velocityTracker = VelocityTracker.obtain()
757
- velocityTracker?.addMovement(event)
758
- activeAnimation?.let {
759
- it.cancel()
760
- activeAnimation = null
412
+ eventDispatcher?.let { dispatcher ->
413
+ jSTouchDispatcher.handleTouchEvent(event, dispatcher, reactContext)
414
+ jSPointerDispatcher?.handleMotionEvent(event, dispatcher, false)
761
415
  }
416
+ super.onTouchEvent(event)
417
+ return true
762
418
  }
763
419
 
764
- // MARK: - Scroll view helpers
765
- //
766
- // Explicit scroll-view detection is required because Android's touch dispatch
767
- // doesn't support mid-gesture handoff. Once a ScrollView claims a gesture it
768
- // keeps it for the entire sequence, and it always claims (returns true from
769
- // onTouchEvent) even when at the scroll boundary. Without this check the sheet
770
- // could never collapse by dragging down when a ScrollView is at the top.
771
-
772
- private fun isScrollViewAtTop(): Boolean {
773
- val scrollView = findScrollableAtTouch() ?: return true
774
- val inverted = isViewInverted(scrollView)
775
- return if (inverted) !scrollView.canScrollVertically(1) else !scrollView.canScrollVertically(-1)
776
- }
777
-
778
- private fun findScrollableAtTouch(): View? {
779
- val containerX = initialTouchX - sheetContainer.left - sheetContainer.translationX
780
- val containerY = initialTouchY - sheetContainer.top - sheetContainer.translationY
781
- if (
782
- containerX < 0f ||
783
- containerX >= sheetContainer.width ||
784
- containerY < 0f ||
785
- containerY >= sheetContainer.height
786
- ) {
787
- return null
788
- }
789
- return findScrollableAtPoint(sheetContainer, containerX, containerY)
790
- }
791
-
792
- private fun findScrollableAtPoint(view: View, x: Float, y: Float): View? {
793
- if (!view.isShown) return null
794
-
795
- if (view is ViewGroup) {
796
- for (i in view.childCount - 1 downTo 0) {
797
- val child = view.getChildAt(i)
798
- val childX = x - child.left - child.translationX
799
- val childY = y - child.top - child.translationY
800
- if (childX < 0f || childX >= child.width || childY < 0f || childY >= child.height) {
801
- continue
802
- }
803
- findScrollableAtPoint(child, childX, childY)?.let {
804
- return it
805
- }
806
- }
807
- }
808
-
809
- if (view.canScrollVertically(1) || view.canScrollVertically(-1)) {
810
- return view
420
+ override fun onInterceptHoverEvent(event: MotionEvent): Boolean {
421
+ eventDispatcher?.let { dispatcher ->
422
+ jSPointerDispatcher?.handleMotionEvent(event, dispatcher, true)
811
423
  }
812
- return null
424
+ return super.onInterceptHoverEvent(event)
813
425
  }
814
426
 
815
- private fun isViewInverted(view: View): Boolean {
816
- val values = FloatArray(9)
817
- var current: View? = view
818
- while (current != null && current !== sheetContainer) {
819
- if (!current.matrix.isIdentity) {
820
- current.matrix.getValues(values)
821
- if (values[android.graphics.Matrix.MSCALE_Y] < 0) return true
822
- }
823
- current = current.parent as? View
427
+ override fun onHoverEvent(event: MotionEvent): Boolean {
428
+ eventDispatcher?.let { dispatcher ->
429
+ jSPointerDispatcher?.handleMotionEvent(event, dispatcher, false)
824
430
  }
825
- return false
826
- }
827
-
828
- // MARK: - Cleanup
829
-
830
- fun destroy() {
831
- activeAnimation?.cancel()
832
- activeAnimation = null
833
- velocityTracker?.recycle()
834
- velocityTracker = null
835
- contentHeightMarker?.removeOnLayoutChangeListener(contentHeightMarkerLayoutListener)
836
- contentHeightMarker = null
837
- surfaceView = null
838
- rawDetentSpecs = emptyList()
839
- detentSpecs = emptyList()
840
- targetIndex = 0
841
- pendingIndex = null
842
- hasLaidOut = false
843
- isPanning = false
844
- panStartingIndex = null
845
- initialTouchY = 0f
846
- initialTouchX = 0f
847
- lastTouchY = 0f
848
- activePointerId = MotionEvent.INVALID_POINTER_ID
849
- scrimPressed = false
850
- scrimTouchActive = false
851
- sheetContainer.translationY = 0f
852
- scrimProgress = 0f
853
- suppressScrimForClosingTarget = false
854
- sheetContainer.removeAllViews()
855
- stateWrapper = null
856
- lastShadowOffsetY = Float.NaN
431
+ return super.onHoverEvent(event)
857
432
  }
858
433
 
859
- private fun updateScrim(position: Float = currentSheetHeight()) {
860
- if (!modal) {
861
- scrimProgress = 0f
862
- invalidate()
863
- return
864
- }
865
-
866
- // When settled at the closed detent, dynamic content updates can briefly
867
- // produce stale non-zero positions. Keep scrim hidden in this state.
868
- if (
869
- (isTargetingClosedDetent && activeAnimation == null && !isPanning) ||
870
- (suppressScrimForClosingTarget && isTargetingClosedDetent)
871
- ) {
872
- hideScrim()
873
- return
874
- }
875
-
876
- // While the sheet is fully open and only its content/detent geometry is
877
- // resizing, the position momentarily lags the grown detent height. Keep the
878
- // scrim pinned to the fully-open opacity instead of dipping it until the
879
- // re-anchor settles.
880
- if (scrimPinnedFull) {
881
- scrimProgress = fullyOpenScrimOpacity()
882
- invalidate()
883
- return
434
+ @OptIn(UnstableReactNativeAPI::class)
435
+ override fun onChildStartedNativeGesture(childView: View?, ev: MotionEvent) {
436
+ eventDispatcher?.let { dispatcher ->
437
+ jSTouchDispatcher.onChildStartedNativeGesture(ev, dispatcher, reactContext)
438
+ jSPointerDispatcher?.onChildStartedNativeGesture(childView, ev, dispatcher)
884
439
  }
885
-
886
- scrimProgress = scrimOpacityAt(position)
887
- invalidate()
888
440
  }
889
441
 
890
- /** The opacity at the tallest detent, held while the sheet re-anchors. */
891
- private fun fullyOpenScrimOpacity(): Float {
892
- val maxHeight = detentSpecs.maxOfOrNull { it.height } ?: return 1f
893
- return scrimOpacityAt(maxHeight)
894
- }
895
-
896
- /**
897
- * Interpolates the scrim opacity for a sheet height by bracketing it between adjacent detent
898
- * heights and lerping each detent index's configured value.
899
- */
900
- private fun scrimOpacityAt(position: Float): Float =
901
- interpolateAtPosition(
902
- position,
903
- detentSpecs.indices.map {
904
- scrimOpacities[it.coerceAtMost(scrimOpacities.size - 1)].coerceIn(0f, 1f)
905
- },
906
- )
907
-
908
- // Interpolates a per-detent value (one per detent, by index) by the sheet
909
- // position, using each detent's resolved height as the breakpoint.
910
- private fun interpolateAtPosition(position: Float, values: List<Float>): Float {
911
- if (detentSpecs.isEmpty()) return 0f
912
- val pairs =
913
- detentSpecs.indices.map { detentSpecs[it].height to values[it] }.sortedBy { it.first }
914
-
915
- val first = pairs.first()
916
- val last = pairs.last()
917
- if (position <= first.first) return first.second
918
- if (position >= last.first) return last.second
919
-
920
- for (i in 1 until pairs.size) {
921
- val upper = pairs[i]
922
- if (position <= upper.first) {
923
- val lower = pairs[i - 1]
924
- val span = upper.first - lower.first
925
- val t = if (span <= 0f) 1f else (position - lower.first) / span
926
- return lower.second + (upper.second - lower.second) * t
927
- }
442
+ override fun onChildEndedNativeGesture(childView: View, ev: MotionEvent) {
443
+ eventDispatcher?.let { dispatcher ->
444
+ jSTouchDispatcher.onChildEndedNativeGesture(ev, dispatcher)
928
445
  }
929
- return last.second
446
+ jSPointerDispatcher?.onChildEndedNativeGesture()
930
447
  }
931
448
 
932
- private fun hideScrim() {
933
- scrimProgress = 0f
934
- invalidate()
935
- }
936
-
937
- private fun updateInteractionState() {
938
- pointerEvents =
939
- if (modal && (activeAnimation != null || isPanning || isScrimVisible())) {
940
- PointerEvents.AUTO
941
- } else {
942
- PointerEvents.BOX_NONE
943
- }
944
- }
945
-
946
- private fun currentSheetHeight(): Float {
947
- val maxHeight = resolvedMaxDetentHeight()
948
- return maxHeight - sheetContainer.translationY
949
- }
950
-
951
- private fun isScrimVisible(): Boolean = modal && scrimProgress > 0.001f
952
-
953
- private fun drawScrim(canvas: Canvas) {
954
- if (!modal || scrimProgress <= 0.001f) {
955
- return
956
- }
957
-
958
- val alpha = (Color.alpha(scrimColor) * scrimProgress).toInt().coerceIn(0, 255)
959
- scrimPaint.color =
960
- Color.argb(alpha, Color.red(scrimColor), Color.green(scrimColor), Color.blue(scrimColor))
961
- canvas.drawRect(0f, 0f, width.toFloat(), height.toFloat(), scrimPaint)
449
+ override fun requestDisallowInterceptTouchEvent(disallowIntercept: Boolean) {
450
+ // Keep receiving root intercept callbacks so JS touch cancellation mirrors React Native Modal.
962
451
  }
963
452
  }