react-native-nitro-modal 0.1.5 → 0.3.0-rc
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 +65 -11
- package/android/src/main/java/com/margelo/nitro/nitromodal/BottomSheetPresenter.kt +335 -34
- package/android/src/main/java/com/margelo/nitro/nitromodal/HybridNitroModal.kt +63 -22
- package/android/src/main/java/com/margelo/nitro/nitromodal/ModalConfig.kt +31 -1
- package/android/src/main/java/com/margelo/nitro/nitromodal/ModalContentRoot.kt +48 -5
- package/android/src/main/java/com/margelo/nitro/nitromodal/ModalPresenter.kt +63 -4
- package/android/src/main/java/com/margelo/nitro/nitromodal/NitroModalHostView.kt +68 -8
- package/ios/HybridNitroModal.swift +111 -20
- package/ios/ModalConfig.swift +44 -2
- package/ios/NitroModalHostView.swift +73 -8
- package/ios/SheetView.swift +575 -0
- package/ios/SheetViewController.swift +19 -450
- package/lib/module/NitroModal.js +39 -7
- package/lib/module/NitroModal.js.map +1 -1
- package/lib/module/NitroModalView.js +4 -3
- package/lib/module/NitroModalView.js.map +1 -1
- package/lib/module/NitroModalView.native.js +3 -0
- package/lib/module/NitroModalView.native.js.map +1 -1
- package/lib/module/NitroModalView.web.js +159 -0
- package/lib/module/NitroModalView.web.js.map +1 -0
- package/lib/module/index.js.map +1 -1
- package/lib/module/web/AnimatedValue.js +126 -0
- package/lib/module/web/AnimatedValue.js.map +1 -0
- package/lib/module/web/ModalConfig.js +30 -0
- package/lib/module/web/ModalConfig.js.map +1 -0
- package/lib/module/web/ModalController.js +306 -0
- package/lib/module/web/ModalController.js.map +1 -0
- package/lib/module/web/ModalPresenter.js +280 -0
- package/lib/module/web/ModalPresenter.js.map +1 -0
- package/lib/module/web/PopupPresenter.js +117 -0
- package/lib/module/web/PopupPresenter.js.map +1 -0
- package/lib/module/web/SheetPresenter.js +494 -0
- package/lib/module/web/SheetPresenter.js.map +1 -0
- package/lib/module/web/dom.js +138 -0
- package/lib/module/web/dom.js.map +1 -0
- package/lib/typescript/src/NitroModal.d.ts +35 -4
- package/lib/typescript/src/NitroModal.d.ts.map +1 -1
- package/lib/typescript/src/NitroModal.nitro.d.ts +29 -2
- package/lib/typescript/src/NitroModal.nitro.d.ts.map +1 -1
- package/lib/typescript/src/NitroModalView.d.ts +2 -1
- package/lib/typescript/src/NitroModalView.d.ts.map +1 -1
- package/lib/typescript/src/NitroModalView.native.d.ts +1 -0
- package/lib/typescript/src/NitroModalView.native.d.ts.map +1 -1
- package/lib/typescript/src/NitroModalView.web.d.ts +6 -0
- package/lib/typescript/src/NitroModalView.web.d.ts.map +1 -0
- package/lib/typescript/src/index.d.ts +2 -2
- package/lib/typescript/src/index.d.ts.map +1 -1
- package/lib/typescript/src/web/AnimatedValue.d.ts +49 -0
- package/lib/typescript/src/web/AnimatedValue.d.ts.map +1 -0
- package/lib/typescript/src/web/ModalConfig.d.ts +30 -0
- package/lib/typescript/src/web/ModalConfig.d.ts.map +1 -0
- package/lib/typescript/src/web/ModalController.d.ts +70 -0
- package/lib/typescript/src/web/ModalController.d.ts.map +1 -0
- package/lib/typescript/src/web/ModalPresenter.d.ts +107 -0
- package/lib/typescript/src/web/ModalPresenter.d.ts.map +1 -0
- package/lib/typescript/src/web/PopupPresenter.d.ts +20 -0
- package/lib/typescript/src/web/PopupPresenter.d.ts.map +1 -0
- package/lib/typescript/src/web/SheetPresenter.d.ts +65 -0
- package/lib/typescript/src/web/SheetPresenter.d.ts.map +1 -0
- package/lib/typescript/src/web/dom.d.ts +42 -0
- package/lib/typescript/src/web/dom.d.ts.map +1 -0
- package/nitrogen/generated/android/c++/JHybridNitroModalSpec.cpp +60 -6
- package/nitrogen/generated/android/c++/JHybridNitroModalSpec.hpp +10 -2
- package/nitrogen/generated/android/c++/JNamedSheetDetent.hpp +64 -0
- package/nitrogen/generated/android/c++/JSheetDetent.cpp +26 -0
- package/nitrogen/generated/android/c++/JSheetDetent.hpp +42 -37
- package/nitrogen/generated/android/c++/views/JHybridNitroModalStateUpdater.cpp +20 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitromodal/HybridNitroModalSpec.kt +40 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitromodal/NamedSheetDetent.kt +25 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitromodal/SheetDetent.kt +47 -10
- package/nitrogen/generated/android/nitromodal+autolinking.cmake +1 -0
- package/nitrogen/generated/ios/NitroModal-Swift-Cxx-Bridge.hpp +53 -8
- package/nitrogen/generated/ios/NitroModal-Swift-Cxx-Umbrella.hpp +4 -3
- package/nitrogen/generated/ios/c++/HybridNitroModalSpecSwift.hpp +32 -5
- package/nitrogen/generated/ios/c++/views/HybridNitroModalComponent.mm +24 -0
- package/nitrogen/generated/ios/swift/HybridNitroModalSpec.swift +4 -0
- package/nitrogen/generated/ios/swift/HybridNitroModalSpec_cxx.swift +110 -5
- package/nitrogen/generated/ios/swift/NamedSheetDetent.swift +48 -0
- package/nitrogen/generated/ios/swift/SheetDetent.swift +15 -33
- package/nitrogen/generated/shared/c++/HybridNitroModalSpec.cpp +8 -0
- package/nitrogen/generated/shared/c++/HybridNitroModalSpec.hpp +14 -5
- package/nitrogen/generated/shared/c++/{SheetDetent.hpp → NamedSheetDetent.hpp} +17 -17
- package/nitrogen/generated/shared/c++/views/HybridNitroModalComponent.cpp +9 -1
- package/nitrogen/generated/shared/c++/views/HybridNitroModalComponent.hpp +15 -2
- package/nitrogen/generated/shared/json/NitroModalConfig.json +4 -0
- package/package.json +3 -2
- package/src/NitroModal.nitro.ts +30 -2
- package/src/NitroModal.tsx +71 -8
- package/src/NitroModalView.native.tsx +4 -0
- package/src/NitroModalView.tsx +9 -4
- package/src/NitroModalView.web.tsx +180 -0
- package/src/index.tsx +2 -0
- package/src/web/AnimatedValue.ts +193 -0
- package/src/web/ModalConfig.ts +67 -0
- package/src/web/ModalController.ts +338 -0
- package/src/web/ModalPresenter.ts +333 -0
- package/src/web/PopupPresenter.ts +129 -0
- package/src/web/SheetPresenter.ts +647 -0
- package/src/web/dom.ts +171 -0
|
@@ -1,21 +1,34 @@
|
|
|
1
1
|
package com.margelo.nitro.nitromodal
|
|
2
2
|
|
|
3
|
+
import android.animation.ValueAnimator
|
|
4
|
+
import android.annotation.SuppressLint
|
|
3
5
|
import android.app.Activity
|
|
6
|
+
import android.content.Context
|
|
4
7
|
import android.graphics.Color
|
|
5
8
|
import android.graphics.Outline
|
|
6
9
|
import android.graphics.drawable.GradientDrawable
|
|
10
|
+
import android.os.Build
|
|
7
11
|
import android.view.Gravity
|
|
12
|
+
import android.view.HapticFeedbackConstants
|
|
13
|
+
import android.view.MotionEvent
|
|
8
14
|
import android.view.View
|
|
15
|
+
import android.view.ViewConfiguration
|
|
9
16
|
import android.view.ViewGroup
|
|
10
17
|
import android.view.ViewOutlineProvider
|
|
18
|
+
import android.view.animation.DecelerateInterpolator
|
|
11
19
|
import android.view.animation.PathInterpolator
|
|
12
20
|
import android.widget.FrameLayout
|
|
13
21
|
import androidx.activity.BackEventCompat
|
|
14
22
|
import androidx.coordinatorlayout.widget.CoordinatorLayout
|
|
15
23
|
import androidx.core.graphics.Insets
|
|
24
|
+
import androidx.core.view.ViewCompat
|
|
16
25
|
import androidx.core.view.doOnLayout
|
|
26
|
+
import com.facebook.react.uimanager.PointerEvents
|
|
27
|
+
import com.facebook.react.uimanager.ReactPointerEventsView
|
|
17
28
|
import com.facebook.react.uimanager.ThemedReactContext
|
|
29
|
+
import com.facebook.react.uimanager.events.NativeGestureUtil
|
|
18
30
|
import com.google.android.material.bottomsheet.BottomSheetBehavior
|
|
31
|
+
import kotlin.math.abs
|
|
19
32
|
import kotlin.math.ceil
|
|
20
33
|
|
|
21
34
|
/**
|
|
@@ -25,6 +38,9 @@ import kotlin.math.ceil
|
|
|
25
38
|
* `BottomSheetBehavior` supports up to three resting states, so detents map
|
|
26
39
|
* onto collapsed / half-expanded / expanded, smallest first. Predictive back
|
|
27
40
|
* uses the behavior's own Material animation, like `BottomSheetDialog`.
|
|
41
|
+
*
|
|
42
|
+
* Inline, the sheet fills the host view instead of a window, with no
|
|
43
|
+
* backdrop, and touches that miss the sheet reach the views behind it.
|
|
28
44
|
*/
|
|
29
45
|
internal class BottomSheetPresenter(
|
|
30
46
|
activity: Activity,
|
|
@@ -32,22 +48,30 @@ internal class BottomSheetPresenter(
|
|
|
32
48
|
contentRoot: ModalContentRoot,
|
|
33
49
|
config: ModalConfig,
|
|
34
50
|
listener: Listener,
|
|
35
|
-
|
|
51
|
+
inlineHost: NitroModalHostView? = null,
|
|
52
|
+
) : ModalPresenter(activity, reactContext, contentRoot, config, listener, inlineHost) {
|
|
36
53
|
|
|
37
|
-
private class
|
|
54
|
+
private class RestingHeight(val index: Int, val height: Int)
|
|
38
55
|
|
|
39
|
-
private val coordinator =
|
|
56
|
+
private val coordinator = SheetLayout(activity)
|
|
40
57
|
private val sheet = FrameLayout(activity)
|
|
41
58
|
private val grabber = View(activity)
|
|
42
|
-
private val behavior =
|
|
59
|
+
private val behavior = SheetBehavior()
|
|
43
60
|
private val sheetBackground = GradientDrawable()
|
|
44
61
|
private var cornerRadiusPx = 0f
|
|
45
62
|
|
|
46
63
|
/** Resting detents sorted by height; position i maps to [states][i]. */
|
|
47
|
-
private var sortedDetents: List<
|
|
64
|
+
private var sortedDetents: List<RestingHeight> = emptyList()
|
|
48
65
|
private var maxContentHeight = 0
|
|
49
66
|
private var presented = false
|
|
50
67
|
private var lastDetentIndex = -1
|
|
68
|
+
private var reportedRestingTop = Int.MIN_VALUE
|
|
69
|
+
|
|
70
|
+
/** The behavior is dragging the sheet (as opposed to a nested scroll moving it). */
|
|
71
|
+
private var sheetDragging = false
|
|
72
|
+
|
|
73
|
+
/** How far the sheet is pulled below its lowest detent, in px. */
|
|
74
|
+
private var pullOffset = 0f
|
|
51
75
|
|
|
52
76
|
/** A predictive back gesture was committed; the behavior is sliding the sheet out. */
|
|
53
77
|
private var committingBack = false
|
|
@@ -59,12 +83,20 @@ internal class BottomSheetPresenter(
|
|
|
59
83
|
finishUserDismissal(DismissReason.SWIPE)
|
|
60
84
|
return
|
|
61
85
|
}
|
|
86
|
+
if (newState == BottomSheetBehavior.STATE_DRAGGING) {
|
|
87
|
+
sheetDragging = true
|
|
88
|
+
coordinator.notifyNativeGesture(started = true)
|
|
89
|
+
} else if (sheetDragging) {
|
|
90
|
+
sheetDragging = false
|
|
91
|
+
coordinator.notifyNativeGesture(started = false)
|
|
92
|
+
}
|
|
62
93
|
val position = states().indexOf(newState)
|
|
63
94
|
if (position < 0 || isDismissing) return
|
|
64
95
|
presented = true
|
|
65
96
|
// Hideable while entering/exiting; at rest, swipe-to-dismiss follows the prop.
|
|
66
97
|
behavior.isHideable = this@BottomSheetPresenter.config.dismissOnSwipe
|
|
67
98
|
notifyPresented()
|
|
99
|
+
reportRestingTop()
|
|
68
100
|
val index = sortedDetents.getOrNull(position)?.index ?: return
|
|
69
101
|
if (index != lastDetentIndex) {
|
|
70
102
|
lastDetentIndex = index
|
|
@@ -79,10 +111,12 @@ internal class BottomSheetPresenter(
|
|
|
79
111
|
}
|
|
80
112
|
|
|
81
113
|
override fun createLayout(): ViewGroup {
|
|
82
|
-
coordinator.addView(backdrop, CoordinatorLayout.LayoutParams(MATCH, MATCH))
|
|
114
|
+
if (!isInline) coordinator.addView(backdrop, CoordinatorLayout.LayoutParams(MATCH, MATCH))
|
|
83
115
|
coordinator.addOnLayoutChangeListener { _, l, t, r, b, oldL, oldT, oldR, oldB ->
|
|
84
116
|
// Rotation / window resize. Posted: detents change the sheet's layout params.
|
|
85
117
|
if (r - l != oldR - oldL || b - t != oldB - oldT) coordinator.post { updateDetents() }
|
|
118
|
+
// Detent heights can move the resting sheet without a state change.
|
|
119
|
+
reportRestingTop()
|
|
86
120
|
}
|
|
87
121
|
|
|
88
122
|
sheet.background = sheetBackground
|
|
@@ -106,7 +140,8 @@ internal class BottomSheetPresenter(
|
|
|
106
140
|
applyAppearance()
|
|
107
141
|
|
|
108
142
|
behavior.isGestureInsetBottomIgnored = true
|
|
109
|
-
|
|
143
|
+
// Inline, the host view decides the width.
|
|
144
|
+
if (!isInline) behavior.maxWidth = dp(MAX_WIDTH_DP)
|
|
110
145
|
behavior.isHideable = true
|
|
111
146
|
behavior.state = BottomSheetBehavior.STATE_HIDDEN
|
|
112
147
|
// Registered after the initial HIDDEN state so it doesn't read as a dismissal.
|
|
@@ -134,6 +169,7 @@ internal class BottomSheetPresenter(
|
|
|
134
169
|
}
|
|
135
170
|
|
|
136
171
|
override fun animateOut(fromBackGesture: Boolean, onEnd: () -> Unit) {
|
|
172
|
+
coordinator.cancelPull()
|
|
137
173
|
if (behavior.state == BottomSheetBehavior.STATE_HIDDEN || !coordinator.isLaidOut) {
|
|
138
174
|
onEnd()
|
|
139
175
|
return
|
|
@@ -180,13 +216,13 @@ internal class BottomSheetPresenter(
|
|
|
180
216
|
override fun onInsetsChanged() {
|
|
181
217
|
sheet.setPadding(systemInsets.left, 0, systemInsets.right, systemInsets.bottom)
|
|
182
218
|
updateDetents()
|
|
183
|
-
|
|
219
|
+
applyTranslation()
|
|
184
220
|
}
|
|
185
221
|
|
|
186
|
-
override fun onKeyboardProgress() =
|
|
222
|
+
override fun onKeyboardProgress() = applyTranslation()
|
|
187
223
|
|
|
188
224
|
override fun onContentSizeChanged() {
|
|
189
|
-
if (config.detents.contains(
|
|
225
|
+
if (config.detents.contains(Detent.FitContent)) updateDetents()
|
|
190
226
|
}
|
|
191
227
|
|
|
192
228
|
override fun snapToDetent(index: Int) {
|
|
@@ -194,6 +230,8 @@ internal class BottomSheetPresenter(
|
|
|
194
230
|
behavior.state = stateFor(index)
|
|
195
231
|
}
|
|
196
232
|
|
|
233
|
+
override fun onTeardown() = coordinator.release()
|
|
234
|
+
|
|
197
235
|
private fun applyAppearance() {
|
|
198
236
|
val radius = activity.dpToPx(config.cornerRadius ?: DEFAULT_CORNER_RADIUS_DP)
|
|
199
237
|
if (radius != cornerRadiusPx) {
|
|
@@ -216,9 +254,9 @@ internal class BottomSheetPresenter(
|
|
|
216
254
|
val bottomInset = systemInsets.bottom
|
|
217
255
|
maxContentHeight = (parentHeight - systemInsets.top - bottomInset).coerceAtLeast(1)
|
|
218
256
|
|
|
219
|
-
val requested = config.detents.ifEmpty { listOf(
|
|
257
|
+
val requested = config.detents.ifEmpty { listOf(Detent.FitContent) }
|
|
220
258
|
val detents = requested
|
|
221
|
-
.mapIndexed { index, detent ->
|
|
259
|
+
.mapIndexed { index, detent -> RestingHeight(index, contentHeightFor(detent) + bottomInset) }
|
|
222
260
|
.distinctBy { it.height }
|
|
223
261
|
.sortedBy { it.height }
|
|
224
262
|
sortedDetents = when {
|
|
@@ -256,18 +294,19 @@ internal class BottomSheetPresenter(
|
|
|
256
294
|
}
|
|
257
295
|
|
|
258
296
|
val availableHeight =
|
|
259
|
-
if (
|
|
297
|
+
if (Detent.FitContent in requested) maxContentHeight else sheetHeight - bottomInset
|
|
260
298
|
val areaHeight = availableHeight -
|
|
261
|
-
if (config.keyboardBehavior == KeyboardBehavior.RESIZE) keyboardOverlap else 0
|
|
299
|
+
if (!isInline && config.keyboardBehavior == KeyboardBehavior.RESIZE) keyboardOverlap else 0
|
|
262
300
|
val sheetWidth = minOf(coordinator.width, behavior.maxWidth.takeIf { it > 0 } ?: Int.MAX_VALUE)
|
|
263
301
|
reportContentArea(sheetWidth - systemInsets.left - systemInsets.right, areaHeight)
|
|
264
302
|
}
|
|
265
303
|
|
|
266
|
-
private fun contentHeightFor(detent:
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
304
|
+
private fun contentHeightFor(detent: Detent): Int = when (detent) {
|
|
305
|
+
Detent.Small -> (maxContentHeight * 0.25f).toInt()
|
|
306
|
+
Detent.Medium -> (maxContentHeight * 0.5f).toInt()
|
|
307
|
+
Detent.Large -> maxContentHeight
|
|
308
|
+
Detent.FitContent -> contentRoot.contentHeight.coerceIn(1, maxContentHeight)
|
|
309
|
+
is Detent.Height -> activity.dpToPx(detent.dp).toInt().coerceIn(1, maxContentHeight)
|
|
271
310
|
}
|
|
272
311
|
|
|
273
312
|
private fun states(): List<Int> = when (sortedDetents.size) {
|
|
@@ -289,21 +328,241 @@ internal class BottomSheetPresenter(
|
|
|
289
328
|
val position = if (target == null) {
|
|
290
329
|
states.lastIndex
|
|
291
330
|
} else {
|
|
292
|
-
sortedDetents.indices.minBy {
|
|
331
|
+
sortedDetents.indices.minBy { abs(sortedDetents[it].height - target) }
|
|
293
332
|
}
|
|
294
333
|
return states[position]
|
|
295
334
|
}
|
|
296
335
|
|
|
297
|
-
/**
|
|
298
|
-
|
|
336
|
+
/**
|
|
337
|
+
* Inline: React lays the content out at the sheet's resting top, so what
|
|
338
|
+
* `measure()` reports (and presses rely on) matches the screen.
|
|
339
|
+
*/
|
|
340
|
+
private fun reportRestingTop() {
|
|
341
|
+
if (!isInline || isDismissing || behavior.state !in states()) return
|
|
342
|
+
val top = sheet.top
|
|
343
|
+
if (top == reportedRestingTop) return
|
|
344
|
+
reportedRestingTop = top
|
|
345
|
+
listener.onRestingTopChange(top)
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Lifts the sheet above the keyboard without relayout (`pan` and `resize`,
|
|
350
|
+
* modal only), and moves it down while it's pulled to refresh.
|
|
351
|
+
*/
|
|
352
|
+
private fun applyTranslation() {
|
|
299
353
|
// The back commit animation owns translationY until the window goes.
|
|
300
354
|
if (committingBack) return
|
|
301
|
-
if (config.keyboardBehavior == KeyboardBehavior.NONE) {
|
|
302
|
-
|
|
303
|
-
|
|
355
|
+
val lift = if (isInline || config.keyboardBehavior == KeyboardBehavior.NONE) {
|
|
356
|
+
0
|
|
357
|
+
} else {
|
|
358
|
+
val headroom = (sheet.top - systemInsets.top).coerceAtLeast(0)
|
|
359
|
+
keyboardOverlap.coerceAtMost(headroom)
|
|
360
|
+
}
|
|
361
|
+
sheet.translationY = pullOffset - lift
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
private fun setPullOffset(offset: Float) {
|
|
365
|
+
pullOffset = offset
|
|
366
|
+
applyTranslation()
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* Gives up dragging the sheet down on nested scrollable content once that
|
|
371
|
+
* content scrolled during the gesture: it scrolls back to its top, and only
|
|
372
|
+
* a new gesture collapses the sheet.
|
|
373
|
+
*/
|
|
374
|
+
private inner class SheetBehavior : BottomSheetBehavior<FrameLayout>() {
|
|
375
|
+
private var contentScrolled = false
|
|
376
|
+
|
|
377
|
+
override fun onInterceptTouchEvent(parent: CoordinatorLayout, child: FrameLayout, event: MotionEvent): Boolean {
|
|
378
|
+
if (event.actionMasked == MotionEvent.ACTION_DOWN && state in states()) {
|
|
379
|
+
// React may have mounted the scrollable content after the last layout
|
|
380
|
+
// pass; laying out again re-finds it. The resting sheet doesn't move.
|
|
381
|
+
onLayoutChild(parent, child, parent.layoutDirection)
|
|
382
|
+
}
|
|
383
|
+
return super.onInterceptTouchEvent(parent, child, event)
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
override fun onStartNestedScroll(
|
|
387
|
+
coordinatorLayout: CoordinatorLayout,
|
|
388
|
+
child: FrameLayout,
|
|
389
|
+
directTargetChild: View,
|
|
390
|
+
target: View,
|
|
391
|
+
axes: Int,
|
|
392
|
+
type: Int,
|
|
393
|
+
): Boolean {
|
|
394
|
+
if (type == ViewCompat.TYPE_TOUCH) contentScrolled = false
|
|
395
|
+
return super.onStartNestedScroll(coordinatorLayout, child, directTargetChild, target, axes, type)
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
override fun onNestedPreScroll(
|
|
399
|
+
coordinatorLayout: CoordinatorLayout,
|
|
400
|
+
child: FrameLayout,
|
|
401
|
+
target: View,
|
|
402
|
+
dx: Int,
|
|
403
|
+
dy: Int,
|
|
404
|
+
consumed: IntArray,
|
|
405
|
+
type: Int,
|
|
406
|
+
) {
|
|
407
|
+
if (dy < 0 && contentScrolled) return
|
|
408
|
+
super.onNestedPreScroll(coordinatorLayout, child, target, dx, dy, consumed, type)
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
override fun onNestedScroll(
|
|
412
|
+
coordinatorLayout: CoordinatorLayout,
|
|
413
|
+
child: FrameLayout,
|
|
414
|
+
target: View,
|
|
415
|
+
dxConsumed: Int,
|
|
416
|
+
dyConsumed: Int,
|
|
417
|
+
dxUnconsumed: Int,
|
|
418
|
+
dyUnconsumed: Int,
|
|
419
|
+
type: Int,
|
|
420
|
+
consumed: IntArray,
|
|
421
|
+
) {
|
|
422
|
+
if (type == ViewCompat.TYPE_TOUCH && dyConsumed != 0) contentScrolled = true
|
|
423
|
+
super.onNestedScroll(
|
|
424
|
+
coordinatorLayout, child, target, dxConsumed, dyConsumed, dxUnconsumed, dyUnconsumed, type, consumed,
|
|
425
|
+
)
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* The sheet's parent. Touches that miss the sheet fall through to the views
|
|
431
|
+
* behind it (inline), JS touches are cancelled when the sheet takes over a
|
|
432
|
+
* gesture, and pulling the sheet down past its lowest detent refreshes:
|
|
433
|
+
* `BottomSheetBehavior` stops there, so that pull is handled here.
|
|
434
|
+
*/
|
|
435
|
+
@SuppressLint("ViewConstructor")
|
|
436
|
+
private inner class SheetLayout(context: Context) : CoordinatorLayout(context), ReactPointerEventsView {
|
|
437
|
+
private val touchSlop = ViewConfiguration.get(context).scaledTouchSlop
|
|
438
|
+
private val refreshThreshold = context.dpToPx(REFRESH_THRESHOLD_DP)
|
|
439
|
+
private var lastEvent: MotionEvent? = null
|
|
440
|
+
private var downX = 0f
|
|
441
|
+
private var downY = 0f
|
|
442
|
+
private var canPull = false
|
|
443
|
+
private var pulling = false
|
|
444
|
+
private var armed = false
|
|
445
|
+
private var pullAnimator: ValueAnimator? = null
|
|
446
|
+
|
|
447
|
+
override val pointerEvents: PointerEvents
|
|
448
|
+
get() = PointerEvents.BOX_NONE
|
|
449
|
+
|
|
450
|
+
override fun dispatchTouchEvent(ev: MotionEvent): Boolean {
|
|
451
|
+
lastEvent?.recycle()
|
|
452
|
+
lastEvent = MotionEvent.obtain(ev)
|
|
453
|
+
return super.dispatchTouchEvent(ev)
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
override fun onInterceptTouchEvent(ev: MotionEvent): Boolean {
|
|
457
|
+
if (trackPull(ev)) return true
|
|
458
|
+
return super.onInterceptTouchEvent(ev)
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
@SuppressLint("ClickableViewAccessibility")
|
|
462
|
+
override fun onTouchEvent(ev: MotionEvent): Boolean {
|
|
463
|
+
if (!pulling && trackPull(ev)) {
|
|
464
|
+
// The behavior had the gesture: let it go of it.
|
|
465
|
+
val cancel = MotionEvent.obtain(ev).apply { action = MotionEvent.ACTION_CANCEL }
|
|
466
|
+
super.onTouchEvent(cancel)
|
|
467
|
+
cancel.recycle()
|
|
468
|
+
return true
|
|
469
|
+
}
|
|
470
|
+
if (!pulling) return super.onTouchEvent(ev)
|
|
471
|
+
when (ev.actionMasked) {
|
|
472
|
+
MotionEvent.ACTION_MOVE -> {
|
|
473
|
+
val offset = rubberBand((ev.y - downY - touchSlop).coerceAtLeast(0f), height.toFloat())
|
|
474
|
+
setPullOffset(offset)
|
|
475
|
+
val nowArmed = offset >= refreshThreshold
|
|
476
|
+
if (nowArmed && !armed) performHapticFeedback(THRESHOLD_FEEDBACK)
|
|
477
|
+
armed = nowArmed
|
|
478
|
+
}
|
|
479
|
+
MotionEvent.ACTION_UP -> endPull(ev, refresh = armed)
|
|
480
|
+
MotionEvent.ACTION_CANCEL -> endPull(ev, refresh = false)
|
|
481
|
+
}
|
|
482
|
+
return true
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/** Follows a gesture until it turns out to be a pull. Returns true once it is. */
|
|
486
|
+
private fun trackPull(ev: MotionEvent): Boolean {
|
|
487
|
+
when (ev.actionMasked) {
|
|
488
|
+
MotionEvent.ACTION_DOWN -> {
|
|
489
|
+
downX = ev.x
|
|
490
|
+
downY = ev.y
|
|
491
|
+
canPull = canStartPull(ev.x, ev.y)
|
|
492
|
+
}
|
|
493
|
+
MotionEvent.ACTION_POINTER_DOWN -> canPull = false
|
|
494
|
+
MotionEvent.ACTION_MOVE -> if (canPull && !pulling) {
|
|
495
|
+
val dx = ev.x - downX
|
|
496
|
+
val dy = ev.y - downY
|
|
497
|
+
if (dy > touchSlop && dy > abs(dx)) {
|
|
498
|
+
pulling = true
|
|
499
|
+
armed = false
|
|
500
|
+
parent?.requestDisallowInterceptTouchEvent(true)
|
|
501
|
+
notifyNativeGesture(started = true)
|
|
502
|
+
return true
|
|
503
|
+
}
|
|
504
|
+
if (abs(dx) > touchSlop || dy < -touchSlop) canPull = false
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
return false
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/**
|
|
511
|
+
* Only from the lowest detent at rest, on the sheet, and where nothing
|
|
512
|
+
* under the finger can scroll up instead.
|
|
513
|
+
*/
|
|
514
|
+
private fun canStartPull(x: Float, y: Float): Boolean {
|
|
515
|
+
if (!config.pullToRefreshEnabled || config.dismissOnSwipe || !presented || isDismissing) return false
|
|
516
|
+
if (pullOffset != 0f || behavior.state != states().firstOrNull()) return false
|
|
517
|
+
val top = sheet.top + sheet.translationY
|
|
518
|
+
if (y < top || x < sheet.left || x >= sheet.right) return false
|
|
519
|
+
return !canScrollUpAt(sheet, x - sheet.left, y - top)
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
private fun endPull(ev: MotionEvent, refresh: Boolean) {
|
|
523
|
+
pulling = false
|
|
524
|
+
armed = false
|
|
525
|
+
canPull = false
|
|
526
|
+
notifyNativeGesture(started = false, event = ev)
|
|
527
|
+
pullAnimator = ValueAnimator.ofFloat(pullOffset, 0f).apply {
|
|
528
|
+
duration = PULL_RELEASE_DURATION
|
|
529
|
+
interpolator = DecelerateInterpolator()
|
|
530
|
+
addUpdateListener { setPullOffset(it.animatedValue as Float) }
|
|
531
|
+
start()
|
|
532
|
+
}
|
|
533
|
+
if (refresh) listener.onPullToRefresh()
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
fun cancelPull() {
|
|
537
|
+
pulling = false
|
|
538
|
+
canPull = false
|
|
539
|
+
pullAnimator?.cancel()
|
|
540
|
+
pullAnimator = null
|
|
541
|
+
setPullOffset(0f)
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/**
|
|
545
|
+
* Inline, the sheet lives inside the React root view, which forwards
|
|
546
|
+
* touches to JS. Tell it when the sheet takes over (or lets go of) a
|
|
547
|
+
* gesture, so a press under the finger is cancelled. A modal's content
|
|
548
|
+
* root hears about it from the touch cancellation instead.
|
|
549
|
+
*/
|
|
550
|
+
fun notifyNativeGesture(started: Boolean, event: MotionEvent? = lastEvent) {
|
|
551
|
+
if (!isInline) return
|
|
552
|
+
val ev = event ?: return
|
|
553
|
+
if (started) {
|
|
554
|
+
NativeGestureUtil.notifyNativeGestureStarted(this, ev)
|
|
555
|
+
} else {
|
|
556
|
+
NativeGestureUtil.notifyNativeGestureEnded(this, ev)
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
fun release() {
|
|
561
|
+
pullAnimator?.cancel()
|
|
562
|
+
pullAnimator = null
|
|
563
|
+
lastEvent?.recycle()
|
|
564
|
+
lastEvent = null
|
|
304
565
|
}
|
|
305
|
-
val headroom = (sheet.top - systemInsets.top).coerceAtLeast(0)
|
|
306
|
-
sheet.translationY = -keyboardOverlap.coerceAtMost(headroom).toFloat()
|
|
307
566
|
}
|
|
308
567
|
|
|
309
568
|
companion object {
|
|
@@ -311,27 +570,69 @@ internal class BottomSheetPresenter(
|
|
|
311
570
|
private const val DEFAULT_CORNER_RADIUS_DP = 28f
|
|
312
571
|
private const val MAX_WIDTH_DP = 640f
|
|
313
572
|
|
|
573
|
+
/** How far below its lowest detent the sheet must be pulled to refresh. */
|
|
574
|
+
private const val REFRESH_THRESHOLD_DP = 56f
|
|
575
|
+
private const val PULL_RELEASE_DURATION = 300L
|
|
576
|
+
private val THRESHOLD_FEEDBACK =
|
|
577
|
+
if (Build.VERSION.SDK_INT >= 34) {
|
|
578
|
+
HapticFeedbackConstants.GESTURE_THRESHOLD_ACTIVATE
|
|
579
|
+
} else {
|
|
580
|
+
HapticFeedbackConstants.CONTEXT_CLICK
|
|
581
|
+
}
|
|
582
|
+
|
|
314
583
|
/** `MaterialBottomContainerBackHelper`'s default hide durations. */
|
|
315
584
|
private const val BACK_HIDE_MAX_DURATION = 300L
|
|
316
585
|
private const val BACK_HIDE_MIN_DURATION = 150L
|
|
317
586
|
private val FAST_OUT_SLOW_IN = PathInterpolator(0.4f, 0f, 0.2f, 1f)
|
|
318
587
|
|
|
588
|
+
private fun rubberBand(offset: Float, dimension: Float): Float {
|
|
589
|
+
if (dimension <= 0f) return 0f
|
|
590
|
+
return (1f - 1f / (offset * 0.55f / dimension + 1f)) * dimension
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
/** Whether a view under (x, y), in [view]'s coordinates, can scroll up. */
|
|
594
|
+
private fun canScrollUpAt(view: View, x: Float, y: Float): Boolean {
|
|
595
|
+
if (view.canScrollVertically(-1)) return true
|
|
596
|
+
if (view !is ViewGroup) return false
|
|
597
|
+
for (i in view.childCount - 1 downTo 0) {
|
|
598
|
+
val child = view.getChildAt(i)
|
|
599
|
+
if (child.visibility != View.VISIBLE) continue
|
|
600
|
+
val childX = x + view.scrollX - child.left - child.translationX
|
|
601
|
+
val childY = y + view.scrollY - child.top - child.translationY
|
|
602
|
+
if (childX >= 0 && childY >= 0 && childX < child.width && childY < child.height &&
|
|
603
|
+
canScrollUpAt(child, childX, childY)
|
|
604
|
+
) {
|
|
605
|
+
return true
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
return false
|
|
609
|
+
}
|
|
610
|
+
|
|
319
611
|
/** Content area before the sheet is on screen, in px. */
|
|
320
612
|
fun estimateContentArea(
|
|
321
613
|
activity: Activity,
|
|
322
|
-
detents: List<
|
|
614
|
+
detents: List<Detent>,
|
|
323
615
|
width: Int,
|
|
324
616
|
height: Int,
|
|
325
617
|
insets: Insets,
|
|
618
|
+
isInline: Boolean,
|
|
326
619
|
): Pair<Int, Int> {
|
|
327
620
|
val maxContent = height - insets.top - insets.bottom
|
|
328
|
-
val
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
621
|
+
val contentHeight = if (detents.isEmpty()) {
|
|
622
|
+
maxContent
|
|
623
|
+
} else {
|
|
624
|
+
detents.maxOf {
|
|
625
|
+
when (it) {
|
|
626
|
+
Detent.Small -> (maxContent * 0.25f).toInt()
|
|
627
|
+
Detent.Medium -> (maxContent * 0.5f).toInt()
|
|
628
|
+
// fitContent content can grow up to the maximum.
|
|
629
|
+
Detent.Large, Detent.FitContent -> maxContent
|
|
630
|
+
is Detent.Height -> activity.dpToPx(it.dp).toInt().coerceIn(1, maxContent.coerceAtLeast(1))
|
|
631
|
+
}
|
|
632
|
+
}
|
|
332
633
|
}
|
|
333
|
-
val sheetWidth = minOf(width, activity.dpToPx(MAX_WIDTH_DP).toInt())
|
|
334
|
-
return Pair(sheetWidth - insets.left - insets.right,
|
|
634
|
+
val sheetWidth = if (isInline) width else minOf(width, activity.dpToPx(MAX_WIDTH_DP).toInt())
|
|
635
|
+
return Pair(sheetWidth - insets.left - insets.right, contentHeight)
|
|
335
636
|
}
|
|
336
637
|
}
|
|
337
638
|
}
|
|
@@ -37,7 +37,8 @@ class HybridNitroModal(private val reactContext: ThemedReactContext) :
|
|
|
37
37
|
}
|
|
38
38
|
|
|
39
39
|
override var mode: ModalMode = ModalMode.BOTTOMSHEET
|
|
40
|
-
override var
|
|
40
|
+
override var isInline: Boolean = false
|
|
41
|
+
override var detents: Array<SheetDetent> = arrayOf(SheetDetent.create(NamedSheetDetent.FITCONTENT))
|
|
41
42
|
override var initialDetentIndex: Double = 0.0
|
|
42
43
|
override var backdropColor: Double = 0xFF000000.toDouble()
|
|
43
44
|
override var backdropOpacity: Double = 0.4
|
|
@@ -50,6 +51,7 @@ class HybridNitroModal(private val reactContext: ThemedReactContext) :
|
|
|
50
51
|
override var contentBackgroundColor: Double? = null
|
|
51
52
|
override var keyboardBehavior: KeyboardBehavior = KeyboardBehavior.PAN
|
|
52
53
|
override var popupAnimation: PopupAnimation = PopupAnimation.SCALE
|
|
54
|
+
override var pullToRefreshEnabled: Boolean = false
|
|
53
55
|
|
|
54
56
|
override var onPresent: (() -> Unit)? = null
|
|
55
57
|
override var onDismiss: ((reason: DismissReason) -> Unit)? = null
|
|
@@ -57,6 +59,8 @@ class HybridNitroModal(private val reactContext: ThemedReactContext) :
|
|
|
57
59
|
override var onBackdropPress: (() -> Unit)? = null
|
|
58
60
|
override var onBackButtonPress: (() -> Unit)? = null
|
|
59
61
|
override var onContentAreaChange: ((area: ModalContentArea) -> Unit)? = null
|
|
62
|
+
override var onPullToRefresh: (() -> Unit)? = null
|
|
63
|
+
override var onRestingTopChange: ((top: Double) -> Unit)? = null
|
|
60
64
|
|
|
61
65
|
// endregion
|
|
62
66
|
|
|
@@ -93,6 +97,14 @@ class HybridNitroModal(private val reactContext: ThemedReactContext) :
|
|
|
93
97
|
scheduleReconcile()
|
|
94
98
|
}
|
|
95
99
|
}
|
|
100
|
+
view.onLayoutChange = {
|
|
101
|
+
presenter?.onHostLayoutChanged() ?: run {
|
|
102
|
+
if (config.isInline) {
|
|
103
|
+
reportEstimatedContentArea()
|
|
104
|
+
if (wantsOpen) scheduleReconcile()
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
96
108
|
view.contentRoot.onContentSizeChange = { presenter?.onContentSizeChanged() }
|
|
97
109
|
}
|
|
98
110
|
|
|
@@ -102,6 +114,7 @@ class HybridNitroModal(private val reactContext: ThemedReactContext) :
|
|
|
102
114
|
val next = makeConfig()
|
|
103
115
|
if (next == config) return
|
|
104
116
|
config = next
|
|
117
|
+
view.isInline = next.isInline
|
|
105
118
|
presenter?.update(next) ?: reportEstimatedContentArea()
|
|
106
119
|
}
|
|
107
120
|
|
|
@@ -176,10 +189,14 @@ class HybridNitroModal(private val reactContext: ThemedReactContext) :
|
|
|
176
189
|
if (!view.isAttachedToWindow) return
|
|
177
190
|
val activity = reactContext.currentActivity ?: return
|
|
178
191
|
if (activity.isFinishing || activity.isDestroyed) return
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
192
|
+
// An inline sheet needs its laid-out area; the host's layout retries.
|
|
193
|
+
if (config.isInline && (view.width == 0 || view.height == 0)) return
|
|
194
|
+
|
|
195
|
+
val next = when {
|
|
196
|
+
config.isInline ->
|
|
197
|
+
BottomSheetPresenter(activity, reactContext, view.contentRoot, config, this, inlineHost = view)
|
|
198
|
+
config.mode == ModalMode.POPUP -> PopupPresenter(activity, reactContext, view.contentRoot, config, this)
|
|
199
|
+
else -> BottomSheetPresenter(activity, reactContext, view.contentRoot, config, this)
|
|
183
200
|
}
|
|
184
201
|
presenter = next
|
|
185
202
|
phase = Phase.PRESENTING
|
|
@@ -263,6 +280,14 @@ class HybridNitroModal(private val reactContext: ThemedReactContext) :
|
|
|
263
280
|
onDetentChange?.invoke(index.toDouble())
|
|
264
281
|
}
|
|
265
282
|
|
|
283
|
+
override fun onPullToRefresh() {
|
|
284
|
+
if (!isDropped) onPullToRefresh?.invoke()
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
override fun onRestingTopChange(top: Int) {
|
|
288
|
+
if (!isDropped) onRestingTopChange?.invoke(kotlin.math.round(reactContext.pxToDp(top)))
|
|
289
|
+
}
|
|
290
|
+
|
|
266
291
|
override fun onContentAreaChange(width: Int, height: Int) {
|
|
267
292
|
val area = ModalContentArea(
|
|
268
293
|
kotlin.math.floor(reactContext.pxToDp(width)),
|
|
@@ -292,26 +317,41 @@ class HybridNitroModal(private val reactContext: ThemedReactContext) :
|
|
|
292
317
|
|
|
293
318
|
// endregion
|
|
294
319
|
|
|
295
|
-
private fun makeConfig()
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
320
|
+
private fun makeConfig(): ModalConfig {
|
|
321
|
+
// An inline sheet is part of the screen; the user can't dismiss it.
|
|
322
|
+
val inline = isInline && mode == ModalMode.BOTTOMSHEET
|
|
323
|
+
return ModalConfig(
|
|
324
|
+
mode = mode,
|
|
325
|
+
inlinePresentation = isInline,
|
|
326
|
+
detents = detents.map(Detent::from),
|
|
327
|
+
initialDetentIndex = if (initialDetentIndex.isNaN()) 0 else initialDetentIndex.toInt(),
|
|
328
|
+
backdropColor = backdropColor.toProcessedColor(),
|
|
329
|
+
backdropOpacity = backdropOpacity.toFloat(),
|
|
330
|
+
backdropBlurRadius = backdropBlurRadius.toFloat(),
|
|
331
|
+
dismissOnBackdropPress = dismissOnBackdropPress && !inline,
|
|
332
|
+
dismissOnSwipe = dismissOnSwipe && !inline,
|
|
333
|
+
dismissOnBackButton = dismissOnBackButton && !inline,
|
|
334
|
+
grabberVisible = grabberVisible,
|
|
335
|
+
cornerRadius = cornerRadius.takeIf { it >= 0 }?.toFloat(),
|
|
336
|
+
contentBackgroundColor = contentBackgroundColor?.toProcessedColor(),
|
|
337
|
+
keyboardBehavior = keyboardBehavior,
|
|
338
|
+
popupAnimation = popupAnimation,
|
|
339
|
+
pullToRefreshEnabled = pullToRefreshEnabled,
|
|
340
|
+
)
|
|
341
|
+
}
|
|
311
342
|
|
|
312
343
|
/** Lets React size the content before the first presentation. */
|
|
313
344
|
private fun reportEstimatedContentArea() {
|
|
314
345
|
val activity = reactContext.currentActivity ?: return
|
|
346
|
+
if (config.isInline) {
|
|
347
|
+
if (view.width == 0 || view.height == 0) return
|
|
348
|
+
// Insets are refined once the sheet is shown.
|
|
349
|
+
val (width, height) = BottomSheetPresenter.estimateContentArea(
|
|
350
|
+
activity, config.detents, view.width, view.height, Insets.NONE, isInline = true,
|
|
351
|
+
)
|
|
352
|
+
onContentAreaChange(width, height)
|
|
353
|
+
return
|
|
354
|
+
}
|
|
315
355
|
val decor = activity.window?.decorView ?: return
|
|
316
356
|
if (decor.width == 0 || decor.height == 0) return
|
|
317
357
|
val insets = ViewCompat.getRootWindowInsets(decor)
|
|
@@ -319,7 +359,8 @@ class HybridNitroModal(private val reactContext: ThemedReactContext) :
|
|
|
319
359
|
?: Insets.NONE
|
|
320
360
|
val (width, height) = when (config.mode) {
|
|
321
361
|
ModalMode.POPUP -> PopupPresenter.estimateContentArea(activity, decor.width, decor.height, insets)
|
|
322
|
-
ModalMode.BOTTOMSHEET ->
|
|
362
|
+
ModalMode.BOTTOMSHEET ->
|
|
363
|
+
BottomSheetPresenter.estimateContentArea(activity, config.detents, decor.width, decor.height, insets, isInline = false)
|
|
323
364
|
}
|
|
324
365
|
onContentAreaChange(width, height)
|
|
325
366
|
}
|