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.
Files changed (61) hide show
  1. package/README.md +19 -7
  2. package/android/src/main/java/com/margelo/nitro/nitromodal/BottomSheetPresenter.kt +72 -21
  3. package/android/src/main/java/com/margelo/nitro/nitromodal/HybridNitroModal.kt +17 -3
  4. package/android/src/main/java/com/margelo/nitro/nitromodal/ModalPresenter.kt +121 -12
  5. package/android/src/main/java/com/margelo/nitro/nitromodal/PopupPresenter.kt +92 -31
  6. package/lib/module/NitroModal.js +1 -2
  7. package/lib/module/NitroModal.js.map +1 -1
  8. package/lib/module/NitroModalView.js +4 -3
  9. package/lib/module/NitroModalView.js.map +1 -1
  10. package/lib/module/NitroModalView.native.js +3 -0
  11. package/lib/module/NitroModalView.native.js.map +1 -1
  12. package/lib/module/NitroModalView.web.js +129 -0
  13. package/lib/module/NitroModalView.web.js.map +1 -0
  14. package/lib/module/web/AnimatedValue.js +123 -0
  15. package/lib/module/web/AnimatedValue.js.map +1 -0
  16. package/lib/module/web/ModalConfig.js +26 -0
  17. package/lib/module/web/ModalConfig.js.map +1 -0
  18. package/lib/module/web/ModalController.js +279 -0
  19. package/lib/module/web/ModalController.js.map +1 -0
  20. package/lib/module/web/ModalPresenter.js +245 -0
  21. package/lib/module/web/ModalPresenter.js.map +1 -0
  22. package/lib/module/web/PopupPresenter.js +117 -0
  23. package/lib/module/web/PopupPresenter.js.map +1 -0
  24. package/lib/module/web/SheetPresenter.js +469 -0
  25. package/lib/module/web/SheetPresenter.js.map +1 -0
  26. package/lib/module/web/dom.js +125 -0
  27. package/lib/module/web/dom.js.map +1 -0
  28. package/lib/typescript/src/NitroModal.d.ts +2 -2
  29. package/lib/typescript/src/NitroModal.d.ts.map +1 -1
  30. package/lib/typescript/src/NitroModalView.d.ts +2 -1
  31. package/lib/typescript/src/NitroModalView.d.ts.map +1 -1
  32. package/lib/typescript/src/NitroModalView.native.d.ts +1 -0
  33. package/lib/typescript/src/NitroModalView.native.d.ts.map +1 -1
  34. package/lib/typescript/src/NitroModalView.web.d.ts +6 -0
  35. package/lib/typescript/src/NitroModalView.web.d.ts.map +1 -0
  36. package/lib/typescript/src/web/AnimatedValue.d.ts +48 -0
  37. package/lib/typescript/src/web/AnimatedValue.d.ts.map +1 -0
  38. package/lib/typescript/src/web/ModalConfig.d.ts +24 -0
  39. package/lib/typescript/src/web/ModalConfig.d.ts.map +1 -0
  40. package/lib/typescript/src/web/ModalController.d.ts +65 -0
  41. package/lib/typescript/src/web/ModalController.d.ts.map +1 -0
  42. package/lib/typescript/src/web/ModalPresenter.d.ts +98 -0
  43. package/lib/typescript/src/web/ModalPresenter.d.ts.map +1 -0
  44. package/lib/typescript/src/web/PopupPresenter.d.ts +20 -0
  45. package/lib/typescript/src/web/PopupPresenter.d.ts.map +1 -0
  46. package/lib/typescript/src/web/SheetPresenter.d.ts +65 -0
  47. package/lib/typescript/src/web/SheetPresenter.d.ts.map +1 -0
  48. package/lib/typescript/src/web/dom.d.ts +40 -0
  49. package/lib/typescript/src/web/dom.d.ts.map +1 -0
  50. package/package.json +3 -2
  51. package/src/NitroModal.tsx +3 -4
  52. package/src/NitroModalView.native.tsx +4 -0
  53. package/src/NitroModalView.tsx +9 -4
  54. package/src/NitroModalView.web.tsx +138 -0
  55. package/src/web/AnimatedValue.ts +189 -0
  56. package/src/web/ModalConfig.ts +57 -0
  57. package/src/web/ModalController.ts +305 -0
  58. package/src/web/ModalPresenter.ts +298 -0
  59. package/src/web/PopupPresenter.ts +129 -0
  60. package/src/web/SheetPresenter.ts +582 -0
  61. package/src/web/dom.ts +154 -0
package/README.md CHANGED
@@ -7,13 +7,13 @@
7
7
  [![npm version](https://img.shields.io/npm/v/react-native-nitro-modal.svg?style=flat-square)](https://www.npmjs.com/package/react-native-nitro-modal)
8
8
  [![npm downloads](https://img.shields.io/npm/dm/react-native-nitro-modal.svg?style=flat-square)](https://www.npmjs.com/package/react-native-nitro-modal)
9
9
  [![license](https://img.shields.io/npm/l/react-native-nitro-modal.svg?style=flat-square)](LICENSE)
10
- ![platforms](https://img.shields.io/badge/platforms-iOS%20%7C%20Android-lightgrey.svg?style=flat-square)
10
+ ![platforms](https://img.shields.io/badge/platforms-iOS%20%7C%20Android%20%7C%20Web-lightgrey.svg?style=flat-square)
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
- > Web and other platforms are not supported. On those platforms the component renders nothing and logs a one-time warning.
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 com.google.android.material.shape.MaterialShapeDrawable
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 = MaterialShapeDrawable()
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
- if (!presented) {
58
- presented = true
59
- behavior.isHideable = this@BottomSheetPresenter.config.dismissOnSwipe
60
- notifyPresented()
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.BACKGROUND
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 from HIDDEN with the behavior's own animation.
115
- behavior.state = stateFor(config.clampedInitialDetentIndex)
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
- behavior.state = BottomSheetBehavior.STATE_HIDDEN
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
- sheetBackground.shapeAppearanceModel = ShapeAppearanceModel.builder()
157
- .setTopLeftCornerSize(radius)
158
- .setTopRightCornerSize(radius)
159
- .build()
160
- sheetBackground.fillColor = ColorStateList.valueOf(config.surfaceColor(activity))
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.DISMISSING -> Unit // The transition reconciles when done.
155
- Phase.PRESENTED -> if (!wantsOpen) startDismiss()
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 (!isDropped) onPresent?.invoke()
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 = View(activity)
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 presentedNotified = false
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
- protected abstract fun animateOut(onEnd: () -> Unit)
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, object : OnBackPressedCallback(true) {
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
- /** Animated dismissal. [Listener.onDismissed] follows when it completes. */
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 finished. */
223
+ /** For subclasses: the running enter animation settled. */
154
224
  protected fun notifyPresented() {
155
- if (presentedNotified || isDismissing || finished) return
156
- presentedNotified = true
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
  }