@swmansion/react-native-bottom-sheet 0.15.0-next.5 → 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.
- package/README.md +25 -2
- package/android/src/main/java/com/swmansion/reactnativebottomsheet/BottomSheetHostView.kt +979 -0
- package/android/src/main/java/com/swmansion/reactnativebottomsheet/BottomSheetView.kt +347 -858
- package/android/src/main/java/com/swmansion/reactnativebottomsheet/BottomSheetViewManager.kt +7 -1
- package/ios/BottomSheetComponentView.mm +149 -0
- package/ios/BottomSheetContentView.h +1 -0
- package/ios/BottomSheetContentView.mm +5 -0
- package/ios/BottomSheetHostingView.swift +4 -0
- package/lib/module/BottomSheet.js +26 -16
- package/lib/module/BottomSheet.js.map +1 -1
- package/lib/module/BottomSheetNativeComponent.ts +1 -0
- package/lib/module/ModalBottomSheet.js +12 -5
- package/lib/module/ModalBottomSheet.js.map +1 -1
- package/lib/typescript/src/BottomSheet.d.ts +17 -8
- package/lib/typescript/src/BottomSheet.d.ts.map +1 -1
- package/lib/typescript/src/BottomSheetNativeComponent.d.ts +1 -0
- package/lib/typescript/src/BottomSheetNativeComponent.d.ts.map +1 -1
- package/lib/typescript/src/ModalBottomSheet.d.ts +36 -2
- package/lib/typescript/src/ModalBottomSheet.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/BottomSheet.tsx +45 -23
- package/src/BottomSheetNativeComponent.ts +1 -0
- package/src/ModalBottomSheet.tsx +50 -8
|
@@ -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
|
+
}
|