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