@swmansion/react-native-bottom-sheet 0.15.0-next.4 → 0.15.0-next.6

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