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