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