react-native-nitro-modal 0.2.0 → 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 +48 -4
- 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 +38 -5
- package/lib/module/NitroModal.js.map +1 -1
- package/lib/module/NitroModalView.web.js +55 -25
- 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 +8 -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 +54 -29
- package/lib/module/web/SheetPresenter.js.map +1 -1
- package/lib/module/web/dom.js +13 -0
- package/lib/module/web/dom.js.map +1 -1
- package/lib/typescript/src/NitroModal.d.ts +33 -2
- 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.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 +6 -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 +9 -0
- package/lib/typescript/src/web/ModalPresenter.d.ts.map +1 -1
- package/lib/typescript/src/web/SheetPresenter.d.ts +1 -1
- package/lib/typescript/src/web/SheetPresenter.d.ts.map +1 -1
- package/lib/typescript/src/web/dom.d.ts +2 -0
- package/lib/typescript/src/web/dom.d.ts.map +1 -1
- 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 +1 -1
- package/src/NitroModal.nitro.ts +30 -2
- package/src/NitroModal.tsx +68 -4
- package/src/NitroModalView.web.tsx +54 -12
- package/src/index.tsx +2 -0
- package/src/web/AnimatedValue.ts +4 -0
- package/src/web/ModalConfig.ts +13 -3
- package/src/web/ModalController.ts +35 -2
- package/src/web/ModalPresenter.ts +46 -11
- package/src/web/SheetPresenter.ts +98 -33
- package/src/web/dom.ts +17 -0
package/README.md
CHANGED
|
@@ -17,7 +17,9 @@
|
|
|
17
17
|
|
|
18
18
|
## Features
|
|
19
19
|
|
|
20
|
-
- 📄 **Native bottom sheets** with multiple detents (`small`, `medium`, `large`, `fitContent`) and swipe-to-dismiss
|
|
20
|
+
- 📄 **Native bottom sheets** with multiple detents (`small`, `medium`, `large`, `fitContent` or a height) and swipe-to-dismiss
|
|
21
|
+
- 📌 **Inline sheets** — a persistent sheet that is part of your screen, with the views behind it still usable
|
|
22
|
+
- 🔄 **Pull to refresh** — pulling a sheet down past its lowest detent
|
|
21
23
|
- 🪟 **Centered popups** with native `scale` / `fade` transitions
|
|
22
24
|
- 📏 **Content-sized sheets** — `fitContent` measures your React content and grows with it
|
|
23
25
|
- ⌨️ **Keyboard aware** — the sheet or card follows the keyboard animation (`pan` or `resize`)
|
|
@@ -111,6 +113,38 @@ Detents are listed smallest first. `initialDetentIndex` selects where the sheet
|
|
|
111
113
|
</NitroModal>
|
|
112
114
|
```
|
|
113
115
|
|
|
116
|
+
### Inline (persistent) bottom sheet
|
|
117
|
+
|
|
118
|
+
`presentation="inline"` keeps the sheet inside the component's own frame instead of presenting it over the app — like a sheet that is part of the screen. There is no backdrop, the views behind it stay interactive, it moves with your screen during navigation, and the user can't dismiss it. Position the frame with `style` (it fills its parent by default); the sheet rests against its bottom edge and its largest detent reaches its top edge.
|
|
119
|
+
|
|
120
|
+
```tsx
|
|
121
|
+
<View style={{ flex: 1 }}>
|
|
122
|
+
<ScreenContent />
|
|
123
|
+
|
|
124
|
+
<NitroModal
|
|
125
|
+
isOpen
|
|
126
|
+
presentation="inline"
|
|
127
|
+
style={{ top: headerHeight }}
|
|
128
|
+
detents={[320, 'large']}
|
|
129
|
+
onPullToRefresh={refresh}
|
|
130
|
+
>
|
|
131
|
+
<FlatList data={rows} renderItem={renderRow} nestedScrollEnabled />
|
|
132
|
+
</NitroModal>
|
|
133
|
+
</View>
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
A number in `detents` is the content height in dp/pt above the bottom safe area.
|
|
137
|
+
|
|
138
|
+
### Scrolling content and pull to refresh
|
|
139
|
+
|
|
140
|
+
A sheet hands drags off to the scroll view under the finger:
|
|
141
|
+
|
|
142
|
+
- Dragging up expands the sheet first, then scrolls the content.
|
|
143
|
+
- Dragging down scrolls the content back to its top. Once the content has scrolled during a drag, that drag never moves the sheet; the next drag collapses it.
|
|
144
|
+
- With `onPullToRefresh` set, a drag that starts with the sheet resting on its lowest detent can pull it further down. Letting go past the threshold calls `onPullToRefresh` (with haptic feedback when it is crossed). This needs a sheet that can't be swiped away: inline, or `dismissOnSwipe={false}`.
|
|
145
|
+
|
|
146
|
+
On Android, scrollable content takes part through nested scrolling, so set `nestedScrollEnabled` on it.
|
|
147
|
+
|
|
114
148
|
### Popup
|
|
115
149
|
|
|
116
150
|
```tsx
|
|
@@ -168,7 +202,9 @@ const sheet = useRef<NitroModalRef>(null);
|
|
|
168
202
|
| ------------------------ | ---------------------------------------------------- | ------------------ | ------------------------------------------------------------------------------------------------- |
|
|
169
203
|
| `isOpen` | `boolean` | — | Controls visibility. Leave undefined to use the [ref API](#nitromodalref) instead. |
|
|
170
204
|
| `mode` | `'bottomSheet' \| 'popup'` | `'bottomSheet'` | How the modal is presented. |
|
|
171
|
-
| `
|
|
205
|
+
| `presentation` | `'modal' \| 'inline'` | `'modal'` | `inline` keeps a bottom sheet inside the component's frame, as part of the screen. See [Inline](#inline-persistent-bottom-sheet). |
|
|
206
|
+
| `style` | `StyleProp<ViewStyle>` | `absoluteFill` | Inline only: the frame the sheet lives in. |
|
|
207
|
+
| `detents` | `SheetDetent[]` | `['fitContent']` | Sheet heights, smallest first: named sizes or a content height in dp/pt. Android uses at most three. |
|
|
172
208
|
| `initialDetentIndex` | `number` | `0` | Index into `detents` the sheet opens at. |
|
|
173
209
|
| `backdropColor` | `ColorValue` | `'black'` | Backdrop color. |
|
|
174
210
|
| `backdropOpacity` | `number` | `0.4` | Backdrop opacity, `0`–`1`. |
|
|
@@ -194,6 +230,7 @@ const sheet = useRef<NitroModalRef>(null);
|
|
|
194
230
|
| `onDetentChange` | `(index: number) => void` | A bottom sheet settled on a different detent. |
|
|
195
231
|
| `onBackdropPress` | `() => void` | The backdrop was tapped (fires even when `dismissOnBackdropPress` is `false`). |
|
|
196
232
|
| `onBackButtonPress` | `() => void` | The Android hardware/gesture back (Escape on web) was pressed. |
|
|
233
|
+
| `onPullToRefresh` | `() => void` | The sheet was pulled down past its lowest detent and released. See [pull to refresh](#scrolling-content-and-pull-to-refresh). |
|
|
197
234
|
|
|
198
235
|
### `NitroModalRef`
|
|
199
236
|
|
|
@@ -208,12 +245,17 @@ const sheet = useRef<NitroModalRef>(null);
|
|
|
208
245
|
```ts
|
|
209
246
|
type ModalMode = 'bottomSheet' | 'popup';
|
|
210
247
|
|
|
211
|
-
type
|
|
248
|
+
type ModalPresentation = 'modal' | 'inline';
|
|
249
|
+
|
|
250
|
+
type NamedSheetDetent =
|
|
212
251
|
| 'small' // ~25% of the available height
|
|
213
252
|
| 'medium' // ~50% of the available height
|
|
214
253
|
| 'large' // the full available height
|
|
215
254
|
| 'fitContent'; // the measured height of your content
|
|
216
255
|
|
|
256
|
+
// A named height, or the content height in dp/pt above the bottom safe area.
|
|
257
|
+
type SheetDetent = NamedSheetDetent | number;
|
|
258
|
+
|
|
217
259
|
type KeyboardBehavior = 'pan' | 'resize' | 'none';
|
|
218
260
|
|
|
219
261
|
type PopupAnimation = 'scale' | 'fade' | 'none';
|
|
@@ -228,6 +270,8 @@ import type {
|
|
|
228
270
|
DismissReason,
|
|
229
271
|
KeyboardBehavior,
|
|
230
272
|
ModalMode,
|
|
273
|
+
ModalPresentation,
|
|
274
|
+
NamedSheetDetent,
|
|
231
275
|
NitroModalProps,
|
|
232
276
|
NitroModalRef,
|
|
233
277
|
PopupAnimation,
|
|
@@ -237,7 +281,7 @@ import type {
|
|
|
237
281
|
|
|
238
282
|
## How it works
|
|
239
283
|
|
|
240
|
-
`<NitroModal>` mounts a zero-size placeholder in your React tree. When opened, the native side presents a real view controller (iOS) or window (Android) and hosts your React children inside it. Children mount when the modal opens and stay mounted until the native exit animation completes, so content never disappears mid-transition. The native side also reports the exact area available to the content (accounting for rotation, keyboard and sheet size), and the content container is sized accordingly.
|
|
284
|
+
`<NitroModal>` mounts a zero-size placeholder in your React tree. When opened, the native side presents a real view controller (iOS) or window (Android) and hosts your React children inside it. An inline sheet instead lives in a view laid out with `style`, in your screen's own hierarchy: touches that miss the sheet go to the views behind it, and `keyboardBehavior` doesn't apply (the screen's own keyboard handling does). Children mount when the modal opens and stay mounted until the native exit animation completes, so content never disappears mid-transition. The native side also reports the exact area available to the content (accounting for rotation, keyboard and sheet size), and the content container is sized accordingly.
|
|
241
285
|
|
|
242
286
|
## Platform notes
|
|
243
287
|
|
|
@@ -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
|
}
|