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