react-native-nitro-modal 0.1.4 → 0.2.0
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 +19 -7
- package/android/src/main/java/com/margelo/nitro/nitromodal/BottomSheetPresenter.kt +72 -21
- package/android/src/main/java/com/margelo/nitro/nitromodal/HybridNitroModal.kt +17 -3
- package/android/src/main/java/com/margelo/nitro/nitromodal/ModalPresenter.kt +121 -12
- package/android/src/main/java/com/margelo/nitro/nitromodal/PopupPresenter.kt +92 -31
- package/lib/module/NitroModal.js +1 -2
- 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 +129 -0
- package/lib/module/NitroModalView.web.js.map +1 -0
- package/lib/module/web/AnimatedValue.js +123 -0
- package/lib/module/web/AnimatedValue.js.map +1 -0
- package/lib/module/web/ModalConfig.js +26 -0
- package/lib/module/web/ModalConfig.js.map +1 -0
- package/lib/module/web/ModalController.js +279 -0
- package/lib/module/web/ModalController.js.map +1 -0
- package/lib/module/web/ModalPresenter.js +245 -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 +469 -0
- package/lib/module/web/SheetPresenter.js.map +1 -0
- package/lib/module/web/dom.js +125 -0
- package/lib/module/web/dom.js.map +1 -0
- package/lib/typescript/src/NitroModal.d.ts +2 -2
- package/lib/typescript/src/NitroModal.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/web/AnimatedValue.d.ts +48 -0
- package/lib/typescript/src/web/AnimatedValue.d.ts.map +1 -0
- package/lib/typescript/src/web/ModalConfig.d.ts +24 -0
- package/lib/typescript/src/web/ModalConfig.d.ts.map +1 -0
- package/lib/typescript/src/web/ModalController.d.ts +65 -0
- package/lib/typescript/src/web/ModalController.d.ts.map +1 -0
- package/lib/typescript/src/web/ModalPresenter.d.ts +98 -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 +40 -0
- package/lib/typescript/src/web/dom.d.ts.map +1 -0
- package/package.json +3 -2
- package/src/NitroModal.tsx +3 -4
- package/src/NitroModalView.native.tsx +4 -0
- package/src/NitroModalView.tsx +9 -4
- package/src/NitroModalView.web.tsx +138 -0
- package/src/web/AnimatedValue.ts +189 -0
- package/src/web/ModalConfig.ts +57 -0
- package/src/web/ModalController.ts +305 -0
- package/src/web/ModalPresenter.ts +298 -0
- package/src/web/PopupPresenter.ts +129 -0
- package/src/web/SheetPresenter.ts +582 -0
- package/src/web/dom.ts +154 -0
package/README.md
CHANGED
|
@@ -7,13 +7,13 @@
|
|
|
7
7
|
[](https://www.npmjs.com/package/react-native-nitro-modal)
|
|
8
8
|
[](https://www.npmjs.com/package/react-native-nitro-modal)
|
|
9
9
|
[](LICENSE)
|
|
10
|
-

|
|
10
|
+

|
|
11
11
|
|
|
12
12
|
</div>
|
|
13
13
|
|
|
14
14
|
---
|
|
15
15
|
|
|
16
|
-
`react-native-nitro-modal` renders your React content inside native modal presentations — an edge-attached UIKit sheet on iOS and Material `BottomSheetBehavior` on Android — so gestures, detent snapping, keyboard handling and transitions are handled natively, not re-implemented in JavaScript.
|
|
16
|
+
`react-native-nitro-modal` renders your React content inside native modal presentations — an edge-attached UIKit sheet on iOS and Material `BottomSheetBehavior` on Android — so gestures, detent snapping, keyboard handling and transitions are handled natively, not re-implemented in JavaScript. On the web (React Native Web), the same API renders a DOM sheet and popup with matching behavior.
|
|
17
17
|
|
|
18
18
|
## Features
|
|
19
19
|
|
|
@@ -35,9 +35,10 @@
|
|
|
35
35
|
| `react-native-nitro-modules` | `^0.37.1` |
|
|
36
36
|
| iOS | 15.0+ (16.0+ for `small` and `fitContent`) |
|
|
37
37
|
| Android | API 24 (Android 7.0)+ |
|
|
38
|
+
| Web | via `react-native-web` (tested with 0.21) |
|
|
38
39
|
|
|
39
40
|
> [!NOTE]
|
|
40
|
-
>
|
|
41
|
+
> Other platforms (e.g. macOS, Windows) are not supported. There the component renders nothing and logs a one-time warning.
|
|
41
42
|
|
|
42
43
|
## Installation
|
|
43
44
|
|
|
@@ -171,10 +172,10 @@ const sheet = useRef<NitroModalRef>(null);
|
|
|
171
172
|
| `initialDetentIndex` | `number` | `0` | Index into `detents` the sheet opens at. |
|
|
172
173
|
| `backdropColor` | `ColorValue` | `'black'` | Backdrop color. |
|
|
173
174
|
| `backdropOpacity` | `number` | `0.4` | Backdrop opacity, `0`–`1`. |
|
|
174
|
-
| `backdropBlur` | `number` | `0` | Blur radius (dp/pt) behind the modal. `0` disables it. See [platform notes](#platform-notes).
|
|
175
|
+
| `backdropBlur` | `number` | `0` | Blur radius (dp/pt/px) behind the modal. `0` disables it. See [platform notes](#platform-notes). |
|
|
175
176
|
| `dismissOnBackdropPress` | `boolean` | `true` | Tapping the backdrop closes the modal. |
|
|
176
177
|
| `dismissOnSwipe` | `boolean` | `true` | Swiping down closes a bottom sheet. |
|
|
177
|
-
| `dismissOnBackButton` | `boolean` | `true` | The Android back button/gesture closes the modal.
|
|
178
|
+
| `dismissOnBackButton` | `boolean` | `true` | The Android back button/gesture (Escape on web) closes the modal. |
|
|
178
179
|
| `showGrabber` | `boolean` | `false` | Shows the drag handle on a bottom sheet. |
|
|
179
180
|
| `cornerRadius` | `number` | platform default | Corner radius of the sheet/card. |
|
|
180
181
|
| `backgroundColor` | `ColorValue` | system surface | Background of the sheet/card. |
|
|
@@ -192,7 +193,7 @@ const sheet = useRef<NitroModalRef>(null);
|
|
|
192
193
|
| `onDismiss` | `(reason: DismissReason) => void` | The modal is fully gone. Fires exactly once per presentation. |
|
|
193
194
|
| `onDetentChange` | `(index: number) => void` | A bottom sheet settled on a different detent. |
|
|
194
195
|
| `onBackdropPress` | `() => void` | The backdrop was tapped (fires even when `dismissOnBackdropPress` is `false`). |
|
|
195
|
-
| `onBackButtonPress` | `() => void` | The Android hardware/gesture back was pressed.
|
|
196
|
+
| `onBackButtonPress` | `() => void` | The Android hardware/gesture back (Escape on web) was pressed. |
|
|
196
197
|
|
|
197
198
|
### `NitroModalRef`
|
|
198
199
|
|
|
@@ -250,6 +251,17 @@ import type {
|
|
|
250
251
|
- Bottom sheets use Material Components' `BottomSheetBehavior`, which supports at most three detents.
|
|
251
252
|
- `backdropBlur` requires Android 12 (API 31)+; it is ignored on older versions.
|
|
252
253
|
- `onBackButtonPress` and `dismissOnBackButton` apply to both the hardware back button and the system back gesture.
|
|
254
|
+
- Predictive back (Android 14+, when the app opts in or targets SDK 36) previews the exit while the gesture runs: the sheet uses Material's bottom-sheet animation and the popup scales down.
|
|
255
|
+
- Transitions are interruptible: closing during the enter animation turns it around, and reopening during the exit animation brings the same modal back (no extra `onDismiss`/`onPresent`). Once the exit starts, taps and back presses reach the screen below, as with a native dialog.
|
|
256
|
+
|
|
257
|
+
**Web**
|
|
258
|
+
|
|
259
|
+
- Renders through React Native Web's `Modal`, so it stacks with your other modals, traps focus while open and returns it on close. Nothing is rendered in place.
|
|
260
|
+
- Bottom sheets can be dragged with touch or mouse, with the same detent snapping, fling-to-dismiss and hand-off to scrollable content as on iOS. A touch that starts on content already scrolled down scrolls it natively instead of moving the sheet.
|
|
261
|
+
- The Escape key acts as the back button: it fires `onBackButtonPress` and, unless `dismissOnBackButton` is `false`, dismisses the topmost modal with reason `'backButton'`. The browser's history back is not intercepted.
|
|
262
|
+
- `backdropBlur` uses CSS `backdrop-filter`. Without `backgroundColor`, the sheet/card uses the CSS `Canvas` system color, which follows your page's `color-scheme`.
|
|
263
|
+
- `keyboardBehavior` follows the on-screen keyboard through the `visualViewport` API (mobile browsers). Safe-area insets are respected when the page uses `viewport-fit=cover`.
|
|
264
|
+
- Sheets are at most 640px wide and centered. Animations are skipped when the user prefers reduced motion.
|
|
253
265
|
|
|
254
266
|
**Colors**
|
|
255
267
|
|
|
@@ -261,7 +273,7 @@ The repository includes an example app that covers content-sized sheets, multi-d
|
|
|
261
273
|
|
|
262
274
|
```sh
|
|
263
275
|
yarn
|
|
264
|
-
yarn example ios # or: yarn example android
|
|
276
|
+
yarn example ios # or: yarn example android / yarn example web
|
|
265
277
|
```
|
|
266
278
|
|
|
267
279
|
## Contributing
|
|
@@ -1,28 +1,30 @@
|
|
|
1
1
|
package com.margelo.nitro.nitromodal
|
|
2
2
|
|
|
3
3
|
import android.app.Activity
|
|
4
|
-
import android.content.res.ColorStateList
|
|
5
4
|
import android.graphics.Color
|
|
5
|
+
import android.graphics.Outline
|
|
6
6
|
import android.graphics.drawable.GradientDrawable
|
|
7
7
|
import android.view.Gravity
|
|
8
8
|
import android.view.View
|
|
9
9
|
import android.view.ViewGroup
|
|
10
10
|
import android.view.ViewOutlineProvider
|
|
11
|
+
import android.view.animation.PathInterpolator
|
|
11
12
|
import android.widget.FrameLayout
|
|
13
|
+
import androidx.activity.BackEventCompat
|
|
12
14
|
import androidx.coordinatorlayout.widget.CoordinatorLayout
|
|
13
15
|
import androidx.core.graphics.Insets
|
|
14
16
|
import androidx.core.view.doOnLayout
|
|
15
17
|
import com.facebook.react.uimanager.ThemedReactContext
|
|
16
18
|
import com.google.android.material.bottomsheet.BottomSheetBehavior
|
|
17
|
-
import
|
|
18
|
-
import com.google.android.material.shape.ShapeAppearanceModel
|
|
19
|
+
import kotlin.math.ceil
|
|
19
20
|
|
|
20
21
|
/**
|
|
21
22
|
* `bottomSheet` mode, driven by [BottomSheetBehavior]. Dragging, flinging,
|
|
22
23
|
* snapping and the backdrop fade all run on the UI thread with no JS.
|
|
23
24
|
*
|
|
24
25
|
* `BottomSheetBehavior` supports up to three resting states, so detents map
|
|
25
|
-
* onto collapsed / half-expanded / expanded, smallest first.
|
|
26
|
+
* onto collapsed / half-expanded / expanded, smallest first. Predictive back
|
|
27
|
+
* uses the behavior's own Material animation, like `BottomSheetDialog`.
|
|
26
28
|
*/
|
|
27
29
|
internal class BottomSheetPresenter(
|
|
28
30
|
activity: Activity,
|
|
@@ -38,7 +40,8 @@ internal class BottomSheetPresenter(
|
|
|
38
40
|
private val sheet = FrameLayout(activity)
|
|
39
41
|
private val grabber = View(activity)
|
|
40
42
|
private val behavior = BottomSheetBehavior<FrameLayout>()
|
|
41
|
-
private val sheetBackground =
|
|
43
|
+
private val sheetBackground = GradientDrawable()
|
|
44
|
+
private var cornerRadiusPx = 0f
|
|
42
45
|
|
|
43
46
|
/** Resting detents sorted by height; position i maps to [states][i]. */
|
|
44
47
|
private var sortedDetents: List<Detent> = emptyList()
|
|
@@ -46,6 +49,10 @@ internal class BottomSheetPresenter(
|
|
|
46
49
|
private var presented = false
|
|
47
50
|
private var lastDetentIndex = -1
|
|
48
51
|
|
|
52
|
+
/** A predictive back gesture was committed; the behavior is sliding the sheet out. */
|
|
53
|
+
private var committingBack = false
|
|
54
|
+
private var backProgress = 0f
|
|
55
|
+
|
|
49
56
|
private val callback = object : BottomSheetBehavior.BottomSheetCallback() {
|
|
50
57
|
override fun onStateChanged(bottomSheet: View, newState: Int) {
|
|
51
58
|
if (newState == BottomSheetBehavior.STATE_HIDDEN) {
|
|
@@ -54,11 +61,10 @@ internal class BottomSheetPresenter(
|
|
|
54
61
|
}
|
|
55
62
|
val position = states().indexOf(newState)
|
|
56
63
|
if (position < 0 || isDismissing) return
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
}
|
|
64
|
+
presented = true
|
|
65
|
+
// Hideable while entering/exiting; at rest, swipe-to-dismiss follows the prop.
|
|
66
|
+
behavior.isHideable = this@BottomSheetPresenter.config.dismissOnSwipe
|
|
67
|
+
notifyPresented()
|
|
62
68
|
val index = sortedDetents.getOrNull(position)?.index ?: return
|
|
63
69
|
if (index != lastDetentIndex) {
|
|
64
70
|
lastDetentIndex = index
|
|
@@ -80,7 +86,15 @@ internal class BottomSheetPresenter(
|
|
|
80
86
|
}
|
|
81
87
|
|
|
82
88
|
sheet.background = sheetBackground
|
|
83
|
-
sheet.outlineProvider = ViewOutlineProvider
|
|
89
|
+
sheet.outlineProvider = object : ViewOutlineProvider() {
|
|
90
|
+
override fun getOutline(view: View, outline: Outline) {
|
|
91
|
+
// Extends past the bottom edge so only the top corners round. A round
|
|
92
|
+
// rect clips cheaply on every API level; a path outline only clips on
|
|
93
|
+
// API 33+, and slowly.
|
|
94
|
+
val overhang = ceil(cornerRadiusPx).toInt()
|
|
95
|
+
outline.setRoundRect(0, 0, view.width, view.height + overhang, cornerRadiusPx)
|
|
96
|
+
}
|
|
97
|
+
}
|
|
84
98
|
sheet.clipToOutline = true
|
|
85
99
|
sheet.addView(contentRoot, FrameLayout.LayoutParams(MATCH, MATCH))
|
|
86
100
|
sheet.addView(
|
|
@@ -110,25 +124,54 @@ internal class BottomSheetPresenter(
|
|
|
110
124
|
|
|
111
125
|
override fun animateIn() {
|
|
112
126
|
coordinator.doOnLayout {
|
|
127
|
+
if (isDismissing) return@doOnLayout
|
|
113
128
|
updateDetents()
|
|
114
|
-
// Settles
|
|
115
|
-
|
|
129
|
+
// Settles with the behavior's own animation, from HIDDEN on show or from
|
|
130
|
+
// wherever a reversed exit got to (back to the detent it left).
|
|
131
|
+
val index = if (lastDetentIndex >= 0) lastDetentIndex else config.clampedInitialDetentIndex
|
|
132
|
+
behavior.state = stateFor(index)
|
|
116
133
|
}
|
|
117
134
|
}
|
|
118
135
|
|
|
119
|
-
override fun animateOut(onEnd: () -> Unit) {
|
|
136
|
+
override fun animateOut(fromBackGesture: Boolean, onEnd: () -> Unit) {
|
|
120
137
|
if (behavior.state == BottomSheetBehavior.STATE_HIDDEN || !coordinator.isLaidOut) {
|
|
121
138
|
onEnd()
|
|
122
139
|
return
|
|
123
140
|
}
|
|
124
141
|
behavior.isHideable = true
|
|
125
|
-
|
|
142
|
+
if (fromBackGesture) {
|
|
143
|
+
// Slides out from the gesture's preview. onSlide doesn't run for this
|
|
144
|
+
// animation, so fade the backdrop alongside it, timed like Material's
|
|
145
|
+
// slide (shorter the further the gesture got).
|
|
146
|
+
committingBack = true
|
|
147
|
+
val duration = BACK_HIDE_MAX_DURATION -
|
|
148
|
+
((BACK_HIDE_MAX_DURATION - BACK_HIDE_MIN_DURATION) * backProgress).toLong()
|
|
149
|
+
backdrop.animate().alpha(0f).setDuration(duration).setInterpolator(FAST_OUT_SLOW_IN).start()
|
|
150
|
+
behavior.handleBackInvoked()
|
|
151
|
+
} else {
|
|
152
|
+
behavior.state = BottomSheetBehavior.STATE_HIDDEN
|
|
153
|
+
}
|
|
126
154
|
// STATE_HIDDEN reaches finishUserDismissal(), which keeps our reason.
|
|
127
155
|
}
|
|
128
156
|
|
|
157
|
+
override val canReverseDismiss: Boolean
|
|
158
|
+
get() = !committingBack
|
|
159
|
+
|
|
160
|
+
override fun onBackGestureStarted(event: BackEventCompat) {
|
|
161
|
+
backProgress = event.progress
|
|
162
|
+
behavior.startBackProgress(event)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
override fun onBackGestureProgressed(event: BackEventCompat) {
|
|
166
|
+
backProgress = event.progress
|
|
167
|
+
behavior.updateBackProgress(event)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
override fun onBackGestureCancelled() = behavior.cancelBackProgress()
|
|
171
|
+
|
|
129
172
|
override fun onConfigChanged(previous: ModalConfig) {
|
|
130
173
|
applyAppearance()
|
|
131
|
-
if (presented) behavior.isHideable = config.dismissOnSwipe
|
|
174
|
+
if (presented && !isDismissing) behavior.isHideable = config.dismissOnSwipe
|
|
132
175
|
if (previous.detents != config.detents || previous.keyboardBehavior != config.keyboardBehavior) {
|
|
133
176
|
updateDetents()
|
|
134
177
|
}
|
|
@@ -153,11 +196,12 @@ internal class BottomSheetPresenter(
|
|
|
153
196
|
|
|
154
197
|
private fun applyAppearance() {
|
|
155
198
|
val radius = activity.dpToPx(config.cornerRadius ?: DEFAULT_CORNER_RADIUS_DP)
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
.
|
|
159
|
-
.
|
|
160
|
-
|
|
199
|
+
if (radius != cornerRadiusPx) {
|
|
200
|
+
cornerRadiusPx = radius
|
|
201
|
+
sheetBackground.cornerRadii = floatArrayOf(radius, radius, radius, radius, 0f, 0f, 0f, 0f)
|
|
202
|
+
sheet.invalidateOutline()
|
|
203
|
+
}
|
|
204
|
+
sheetBackground.setColor(config.surfaceColor(activity))
|
|
161
205
|
grabber.visibility = if (config.grabberVisible) View.VISIBLE else View.GONE
|
|
162
206
|
grabber.background = GradientDrawable().apply {
|
|
163
207
|
cornerRadius = activity.dpToPx(2f)
|
|
@@ -252,6 +296,8 @@ internal class BottomSheetPresenter(
|
|
|
252
296
|
|
|
253
297
|
/** Lifts the sheet above the keyboard without relayout (`pan` and `resize`). */
|
|
254
298
|
private fun applyKeyboardOffset() {
|
|
299
|
+
// The back commit animation owns translationY until the window goes.
|
|
300
|
+
if (committingBack) return
|
|
255
301
|
if (config.keyboardBehavior == KeyboardBehavior.NONE) {
|
|
256
302
|
sheet.translationY = 0f
|
|
257
303
|
return
|
|
@@ -265,6 +311,11 @@ internal class BottomSheetPresenter(
|
|
|
265
311
|
private const val DEFAULT_CORNER_RADIUS_DP = 28f
|
|
266
312
|
private const val MAX_WIDTH_DP = 640f
|
|
267
313
|
|
|
314
|
+
/** `MaterialBottomContainerBackHelper`'s default hide durations. */
|
|
315
|
+
private const val BACK_HIDE_MAX_DURATION = 300L
|
|
316
|
+
private const val BACK_HIDE_MIN_DURATION = 150L
|
|
317
|
+
private val FAST_OUT_SLOW_IN = PathInterpolator(0.4f, 0f, 0.2f, 1f)
|
|
318
|
+
|
|
268
319
|
/** Content area before the sheet is on screen, in px. */
|
|
269
320
|
fun estimateContentArea(
|
|
270
321
|
activity: Activity,
|
|
@@ -17,6 +17,9 @@ import com.facebook.react.uimanager.ThemedReactContext
|
|
|
17
17
|
* Presentation is a small state machine so that rapid `isOpen` toggles,
|
|
18
18
|
* native dismissals and unmounts always settle on a consistent state, and
|
|
19
19
|
* every accepted present request ends with exactly one `onDismiss`.
|
|
20
|
+
* Transitions are interrupted rather than queued: closing mid-enter turns the
|
|
21
|
+
* enter around, and opening mid-exit turns the exit around (that session
|
|
22
|
+
* carries on, so it gets no `onDismiss` and at most one `onPresent`).
|
|
20
23
|
*/
|
|
21
24
|
@DoNotStrip
|
|
22
25
|
class HybridNitroModal(private val reactContext: ThemedReactContext) :
|
|
@@ -68,6 +71,9 @@ class HybridNitroModal(private val reactContext: ThemedReactContext) :
|
|
|
68
71
|
|
|
69
72
|
/** A present request was accepted and its `onDismiss` is still owed. */
|
|
70
73
|
private var sessionActive = false
|
|
74
|
+
|
|
75
|
+
/** `onPresent` was sent for the current session. */
|
|
76
|
+
private var presentSent = false
|
|
71
77
|
private var dismissReason = DismissReason.PROGRAMMATIC
|
|
72
78
|
private var reconcileScheduled = false
|
|
73
79
|
private var isDropped = false
|
|
@@ -151,8 +157,12 @@ class HybridNitroModal(private val reactContext: ThemedReactContext) :
|
|
|
151
157
|
|
|
152
158
|
private fun reconcile() {
|
|
153
159
|
when (phase) {
|
|
154
|
-
Phase.PRESENTING, Phase.
|
|
155
|
-
|
|
160
|
+
Phase.PRESENTING, Phase.PRESENTED -> if (!wantsOpen) startDismiss()
|
|
161
|
+
// Not reversible (e.g. a committed back gesture): finishDismiss reconciles.
|
|
162
|
+
Phase.DISMISSING -> if (wantsOpen && presenter?.cancelDismiss() == true) {
|
|
163
|
+
phase = Phase.PRESENTING
|
|
164
|
+
dismissReason = DismissReason.PROGRAMMATIC
|
|
165
|
+
}
|
|
156
166
|
Phase.IDLE -> when {
|
|
157
167
|
wantsOpen -> startPresent()
|
|
158
168
|
sessionActive -> endSession() // Cancelled before anything appeared.
|
|
@@ -200,6 +210,7 @@ class HybridNitroModal(private val reactContext: ThemedReactContext) :
|
|
|
200
210
|
if (!sessionActive) return
|
|
201
211
|
val reason = dismissReason
|
|
202
212
|
dismissReason = DismissReason.PROGRAMMATIC
|
|
213
|
+
presentSent = false
|
|
203
214
|
// A present requested while we were dismissing starts a new session.
|
|
204
215
|
sessionActive = wantsOpen
|
|
205
216
|
if (!isDropped) onDismiss?.invoke(reason)
|
|
@@ -218,7 +229,10 @@ class HybridNitroModal(private val reactContext: ThemedReactContext) :
|
|
|
218
229
|
override fun onPresented() {
|
|
219
230
|
if (phase != Phase.PRESENTING) return
|
|
220
231
|
phase = Phase.PRESENTED
|
|
221
|
-
if (!
|
|
232
|
+
if (!presentSent) {
|
|
233
|
+
presentSent = true
|
|
234
|
+
if (!isDropped) onPresent?.invoke()
|
|
235
|
+
}
|
|
222
236
|
reconcile()
|
|
223
237
|
}
|
|
224
238
|
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
package com.margelo.nitro.nitromodal
|
|
2
2
|
|
|
3
3
|
import android.app.Activity
|
|
4
|
+
import android.content.Context
|
|
4
5
|
import android.os.Build
|
|
5
6
|
import android.view.View
|
|
6
7
|
import android.view.ViewGroup
|
|
7
8
|
import android.view.WindowManager
|
|
8
9
|
import android.view.inputmethod.InputMethodManager
|
|
10
|
+
import androidx.activity.BackEventCompat
|
|
9
11
|
import androidx.activity.ComponentDialog
|
|
10
12
|
import androidx.activity.OnBackPressedCallback
|
|
11
13
|
import androidx.core.graphics.Insets
|
|
@@ -20,6 +22,10 @@ import com.facebook.react.uimanager.ThemedReactContext
|
|
|
20
22
|
* drive the enter/exit animations; this class handles the window, edge-to-edge
|
|
21
23
|
* insets, keyboard tracking, back handling and teardown.
|
|
22
24
|
*
|
|
25
|
+
* Transitions are interruptible like native ones: dismissing mid-enter turns
|
|
26
|
+
* the enter around, and [cancelDismiss] turns an exit back around. Once an
|
|
27
|
+
* exit starts the window stops taking input, as a dismissed dialog would.
|
|
28
|
+
*
|
|
23
29
|
* Everything here runs on the UI thread.
|
|
24
30
|
*/
|
|
25
31
|
internal abstract class ModalPresenter(
|
|
@@ -30,6 +36,7 @@ internal abstract class ModalPresenter(
|
|
|
30
36
|
protected val listener: Listener,
|
|
31
37
|
) {
|
|
32
38
|
interface Listener {
|
|
39
|
+
/** An enter transition settled: the first one, or one that reversed an exit. */
|
|
33
40
|
fun onPresented()
|
|
34
41
|
/** The window is gone. Called exactly once per presenter. */
|
|
35
42
|
fun onDismissed(reason: DismissReason)
|
|
@@ -44,7 +51,7 @@ internal abstract class ModalPresenter(
|
|
|
44
51
|
private set
|
|
45
52
|
|
|
46
53
|
protected val dialog = ComponentDialog(activity, R.style.NitroModal_Dialog)
|
|
47
|
-
protected val backdrop =
|
|
54
|
+
protected val backdrop: View = BackdropView(activity)
|
|
48
55
|
|
|
49
56
|
/** System bars + display cutout. */
|
|
50
57
|
protected var systemInsets: Insets = Insets.NONE
|
|
@@ -55,16 +62,51 @@ internal abstract class ModalPresenter(
|
|
|
55
62
|
private set
|
|
56
63
|
|
|
57
64
|
private var imeAnimating = false
|
|
58
|
-
private var
|
|
65
|
+
private var entering = false
|
|
66
|
+
private var backGestureActive = false
|
|
59
67
|
private var finished = false
|
|
60
68
|
private var dismissReason = DismissReason.PROGRAMMATIC
|
|
61
69
|
|
|
62
70
|
protected var isDismissing = false
|
|
63
71
|
private set
|
|
64
72
|
|
|
73
|
+
private val backCallback = object : OnBackPressedCallback(true) {
|
|
74
|
+
// Predictive back (Android 14+): preview the exit while the gesture runs.
|
|
75
|
+
override fun handleOnBackStarted(backEvent: BackEventCompat) {
|
|
76
|
+
if (!config.dismissOnBackButton || entering || isDismissing || finished) return
|
|
77
|
+
backGestureActive = true
|
|
78
|
+
onBackGestureStarted(backEvent)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
override fun handleOnBackProgressed(backEvent: BackEventCompat) {
|
|
82
|
+
if (backGestureActive) onBackGestureProgressed(backEvent)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
override fun handleOnBackCancelled() = cancelBackGesture()
|
|
86
|
+
|
|
87
|
+
override fun handleOnBackPressed() {
|
|
88
|
+
// Already on its way out: the back press is spent.
|
|
89
|
+
if (isDismissing || finished) return
|
|
90
|
+
listener.onBackPress()
|
|
91
|
+
// Still here (back didn't dismiss): settle the preview.
|
|
92
|
+
cancelBackGesture()
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
65
96
|
protected abstract fun createLayout(): ViewGroup
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Animates from the current state to presented, then calls
|
|
100
|
+
* [notifyPresented]. Runs on show and again when an exit is reversed.
|
|
101
|
+
*/
|
|
66
102
|
protected abstract fun animateIn()
|
|
67
|
-
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Animates from the current state (possibly mid-enter) to gone, then calls
|
|
106
|
+
* [onEnd]. [fromBackGesture]: a predictive back preview is being committed.
|
|
107
|
+
*/
|
|
108
|
+
protected abstract fun animateOut(fromBackGesture: Boolean, onEnd: () -> Unit)
|
|
109
|
+
|
|
68
110
|
protected abstract fun onConfigChanged(previous: ModalConfig)
|
|
69
111
|
|
|
70
112
|
/** System insets or the settled keyboard height changed. */
|
|
@@ -77,18 +119,24 @@ internal abstract class ModalPresenter(
|
|
|
77
119
|
|
|
78
120
|
open fun snapToDetent(index: Int) = Unit
|
|
79
121
|
|
|
122
|
+
protected open fun onBackGestureStarted(event: BackEventCompat) = Unit
|
|
123
|
+
protected open fun onBackGestureProgressed(event: BackEventCompat) = Unit
|
|
124
|
+
protected open fun onBackGestureCancelled() = Unit
|
|
125
|
+
|
|
126
|
+
/** Whether the running exit can still be turned around. */
|
|
127
|
+
protected open val canReverseDismiss: Boolean
|
|
128
|
+
get() = true
|
|
129
|
+
|
|
80
130
|
fun show() {
|
|
81
131
|
(contentRoot.parent as? ViewGroup)?.removeView(contentRoot)
|
|
82
132
|
val root = createLayout()
|
|
83
133
|
backdrop.setBackgroundColor(config.effectiveBackdropColor)
|
|
84
134
|
backdrop.alpha = 0f
|
|
85
|
-
backdrop.setOnClickListener { listener.onBackdropPress() }
|
|
135
|
+
backdrop.setOnClickListener { if (!isDismissing) listener.onBackdropPress() }
|
|
86
136
|
|
|
87
137
|
dialog.setContentView(root, ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT))
|
|
88
138
|
dialog.setCancelable(false)
|
|
89
|
-
dialog.onBackPressedDispatcher.addCallback(dialog,
|
|
90
|
-
override fun handleOnBackPressed() = listener.onBackPress()
|
|
91
|
-
})
|
|
139
|
+
dialog.onBackPressedDispatcher.addCallback(dialog, backCallback)
|
|
92
140
|
observeInsets(root)
|
|
93
141
|
|
|
94
142
|
val window = requireNotNull(dialog.window)
|
|
@@ -122,6 +170,7 @@ internal abstract class ModalPresenter(
|
|
|
122
170
|
window.clearFlags(WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE)
|
|
123
171
|
reactContext.onExtraWindowCreate(window)
|
|
124
172
|
|
|
173
|
+
entering = true
|
|
125
174
|
animateIn()
|
|
126
175
|
}
|
|
127
176
|
|
|
@@ -134,13 +183,34 @@ internal abstract class ModalPresenter(
|
|
|
134
183
|
onConfigChanged(previous)
|
|
135
184
|
}
|
|
136
185
|
|
|
137
|
-
/**
|
|
186
|
+
/**
|
|
187
|
+
* Animated dismissal, also from mid-enter. [Listener.onDismissed] follows
|
|
188
|
+
* when it completes, unless [cancelDismiss] turns it around first.
|
|
189
|
+
*/
|
|
138
190
|
fun dismiss(reason: DismissReason) {
|
|
139
191
|
if (isDismissing || finished) return
|
|
140
192
|
isDismissing = true
|
|
193
|
+
entering = false
|
|
141
194
|
dismissReason = reason
|
|
195
|
+
val fromBackGesture = backGestureActive
|
|
196
|
+
backGestureActive = false
|
|
197
|
+
releaseInput()
|
|
142
198
|
hideKeyboard()
|
|
143
|
-
animateOut { finish() }
|
|
199
|
+
animateOut(fromBackGesture) { finish() }
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Reverses a running exit from wherever it is. [Listener.onPresented]
|
|
204
|
+
* follows once it settles. Returns false when the exit can't be reversed.
|
|
205
|
+
*/
|
|
206
|
+
fun cancelDismiss(): Boolean {
|
|
207
|
+
if (!isDismissing || finished || !canReverseDismiss) return false
|
|
208
|
+
isDismissing = false
|
|
209
|
+
dismissReason = DismissReason.PROGRAMMATIC
|
|
210
|
+
dialog.window?.clearFlags(INPUT_RELEASE_FLAGS)
|
|
211
|
+
entering = true
|
|
212
|
+
animateIn()
|
|
213
|
+
return true
|
|
144
214
|
}
|
|
145
215
|
|
|
146
216
|
/** Tears the window down without animating or notifying the listener. */
|
|
@@ -150,10 +220,10 @@ internal abstract class ModalPresenter(
|
|
|
150
220
|
teardown()
|
|
151
221
|
}
|
|
152
222
|
|
|
153
|
-
/** For subclasses: the enter animation
|
|
223
|
+
/** For subclasses: the running enter animation settled. */
|
|
154
224
|
protected fun notifyPresented() {
|
|
155
|
-
if (
|
|
156
|
-
|
|
225
|
+
if (!entering || isDismissing || finished) return
|
|
226
|
+
entering = false
|
|
157
227
|
listener.onPresented()
|
|
158
228
|
}
|
|
159
229
|
|
|
@@ -174,6 +244,26 @@ internal abstract class ModalPresenter(
|
|
|
174
244
|
|
|
175
245
|
protected fun dp(value: Float): Int = activity.dpToPx(value).toInt()
|
|
176
246
|
|
|
247
|
+
private fun cancelBackGesture() {
|
|
248
|
+
if (!backGestureActive) return
|
|
249
|
+
backGestureActive = false
|
|
250
|
+
onBackGestureCancelled()
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* A dismissed native dialog leaves the screen at once, so taps and back
|
|
255
|
+
* presses during the exit reach whatever is below. Do the same while the
|
|
256
|
+
* exit animates. Focus is kept while the keyboard is up: giving it away
|
|
257
|
+
* then would cut the keyboard's hide animation short.
|
|
258
|
+
*/
|
|
259
|
+
private fun releaseInput() {
|
|
260
|
+
val window = dialog.window ?: return
|
|
261
|
+
val keyboardVisible = imeAnimating || imeHeight > 0
|
|
262
|
+
window.addFlags(
|
|
263
|
+
if (keyboardVisible) WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE else INPUT_RELEASE_FLAGS,
|
|
264
|
+
)
|
|
265
|
+
}
|
|
266
|
+
|
|
177
267
|
private fun finish() {
|
|
178
268
|
if (finished) return
|
|
179
269
|
finished = true
|
|
@@ -250,4 +340,23 @@ internal abstract class ModalPresenter(
|
|
|
250
340
|
},
|
|
251
341
|
)
|
|
252
342
|
}
|
|
343
|
+
|
|
344
|
+
private companion object {
|
|
345
|
+
const val INPUT_RELEASE_FLAGS =
|
|
346
|
+
WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE or WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Full-window scrim. It draws a single rect, so fading it needs no offscreen
|
|
352
|
+
* layer; the default (overlapping) would allocate a full-screen buffer for
|
|
353
|
+
* every animated frame.
|
|
354
|
+
*/
|
|
355
|
+
private class BackdropView(context: Context) : View(context) {
|
|
356
|
+
init {
|
|
357
|
+
// Native dialogs don't click when tapped outside.
|
|
358
|
+
isSoundEffectsEnabled = false
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
override fun hasOverlappingRendering(): Boolean = false
|
|
253
362
|
}
|