react-native-nitro-modal 0.1.4 → 0.1.5
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 +2 -0
- 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/package.json +1 -1
package/README.md
CHANGED
|
@@ -250,6 +250,8 @@ import type {
|
|
|
250
250
|
- Bottom sheets use Material Components' `BottomSheetBehavior`, which supports at most three detents.
|
|
251
251
|
- `backdropBlur` requires Android 12 (API 31)+; it is ignored on older versions.
|
|
252
252
|
- `onBackButtonPress` and `dismissOnBackButton` apply to both the hardware back button and the system back gesture.
|
|
253
|
+
- 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.
|
|
254
|
+
- 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.
|
|
253
255
|
|
|
254
256
|
**Colors**
|
|
255
257
|
|
|
@@ -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
|
}
|
|
@@ -3,18 +3,21 @@ package com.margelo.nitro.nitromodal
|
|
|
3
3
|
import android.app.Activity
|
|
4
4
|
import android.graphics.drawable.GradientDrawable
|
|
5
5
|
import android.view.Gravity
|
|
6
|
+
import android.view.View
|
|
6
7
|
import android.view.ViewGroup
|
|
7
8
|
import android.view.ViewOutlineProvider
|
|
8
9
|
import android.view.animation.AccelerateInterpolator
|
|
9
10
|
import android.view.animation.PathInterpolator
|
|
10
11
|
import android.widget.FrameLayout
|
|
12
|
+
import androidx.activity.BackEventCompat
|
|
11
13
|
import androidx.core.graphics.Insets
|
|
12
14
|
import com.facebook.react.uimanager.ThemedReactContext
|
|
13
15
|
|
|
14
16
|
/**
|
|
15
17
|
* `popup` mode: a centered card over the backdrop. Enter/exit are
|
|
16
18
|
* [android.view.ViewPropertyAnimator]s on hardware layers, so they run on the
|
|
17
|
-
* UI/render threads without touching JS.
|
|
19
|
+
* UI/render threads without touching JS. Each one starts from the current
|
|
20
|
+
* values, so a transition can be turned around mid-flight.
|
|
18
21
|
*/
|
|
19
22
|
internal class PopupPresenter(
|
|
20
23
|
activity: Activity,
|
|
@@ -25,8 +28,13 @@ internal class PopupPresenter(
|
|
|
25
28
|
) : ModalPresenter(activity, reactContext, contentRoot, config, listener) {
|
|
26
29
|
|
|
27
30
|
private val root = FrameLayout(activity)
|
|
31
|
+
|
|
32
|
+
/** Insets + margin; the backdrop sits outside it so it covers the whole window. */
|
|
33
|
+
private val cardFrame = FrameLayout(activity)
|
|
28
34
|
private val card = FrameLayout(activity)
|
|
29
35
|
private val cardBackground = GradientDrawable()
|
|
36
|
+
private var started = false
|
|
37
|
+
private var backPreview = false
|
|
30
38
|
|
|
31
39
|
override fun createLayout(): ViewGroup {
|
|
32
40
|
root.addView(backdrop, FrameLayout.LayoutParams(MATCH, MATCH))
|
|
@@ -34,55 +42,78 @@ internal class PopupPresenter(
|
|
|
34
42
|
card.outlineProvider = ViewOutlineProvider.BACKGROUND
|
|
35
43
|
card.clipToOutline = true
|
|
36
44
|
card.addView(contentRoot, FrameLayout.LayoutParams(MATCH, MATCH))
|
|
37
|
-
|
|
38
|
-
|
|
45
|
+
cardFrame.addView(card, FrameLayout.LayoutParams(0, 0, Gravity.CENTER))
|
|
46
|
+
cardFrame.addOnLayoutChangeListener { _, l, t, r, b, oldL, oldT, oldR, oldB ->
|
|
39
47
|
if (r - l != oldR - oldL || b - t != oldB - oldT) reportArea()
|
|
40
48
|
}
|
|
49
|
+
root.addView(cardFrame, FrameLayout.LayoutParams(MATCH, MATCH))
|
|
41
50
|
applyAppearance()
|
|
42
51
|
updateCardSize()
|
|
43
52
|
return root
|
|
44
53
|
}
|
|
45
54
|
|
|
55
|
+
/** One frame after show, so the card has its size before it scales in. */
|
|
56
|
+
private val firstEnter = Runnable {
|
|
57
|
+
if (config.popupAnimation == PopupAnimation.SCALE) {
|
|
58
|
+
card.scaleX = ENTER_SCALE
|
|
59
|
+
card.scaleY = ENTER_SCALE
|
|
60
|
+
}
|
|
61
|
+
runEnter()
|
|
62
|
+
}
|
|
63
|
+
|
|
46
64
|
override fun animateIn() {
|
|
65
|
+
if (started) return runEnter()
|
|
66
|
+
started = true
|
|
47
67
|
card.alpha = 0f
|
|
48
|
-
root.post
|
|
49
|
-
if (isDismissing) return@post
|
|
50
|
-
if (config.popupAnimation == PopupAnimation.NONE) {
|
|
51
|
-
backdrop.alpha = 1f
|
|
52
|
-
card.alpha = 1f
|
|
53
|
-
notifyPresented()
|
|
54
|
-
return@post
|
|
55
|
-
}
|
|
56
|
-
val scale = if (config.popupAnimation == PopupAnimation.SCALE) 0.9f else 1f
|
|
57
|
-
card.scaleX = scale
|
|
58
|
-
card.scaleY = scale
|
|
59
|
-
backdrop.animate().alpha(1f).setDuration(ENTER_DURATION).setInterpolator(EMPHASIZED_DECELERATE).start()
|
|
60
|
-
card.animate()
|
|
61
|
-
.alpha(1f).scaleX(1f).scaleY(1f)
|
|
62
|
-
.setDuration(ENTER_DURATION)
|
|
63
|
-
.setInterpolator(EMPHASIZED_DECELERATE)
|
|
64
|
-
.withLayer()
|
|
65
|
-
.withEndAction { notifyPresented() }
|
|
66
|
-
.start()
|
|
67
|
-
}
|
|
68
|
+
root.post(firstEnter)
|
|
68
69
|
}
|
|
69
70
|
|
|
70
|
-
override fun animateOut(onEnd: () -> Unit) {
|
|
71
|
+
override fun animateOut(fromBackGesture: Boolean, onEnd: () -> Unit) {
|
|
72
|
+
root.removeCallbacks(firstEnter)
|
|
73
|
+
backPreview = false
|
|
71
74
|
if (config.popupAnimation == PopupAnimation.NONE) {
|
|
72
75
|
onEnd()
|
|
73
76
|
return
|
|
74
77
|
}
|
|
75
|
-
|
|
76
|
-
|
|
78
|
+
// From wherever the enter or a back preview left the card.
|
|
79
|
+
val scale = card.scaleX * if (config.popupAnimation == PopupAnimation.SCALE) EXIT_SCALE else 1f
|
|
80
|
+
val duration = if (fromBackGesture) BACK_COMMIT_DURATION else EXIT_DURATION
|
|
81
|
+
backdrop.animate().alpha(0f).setDuration(duration).setInterpolator(ACCELERATE).start()
|
|
77
82
|
card.animate()
|
|
78
83
|
.alpha(0f).scaleX(scale).scaleY(scale)
|
|
79
|
-
.setDuration(
|
|
84
|
+
.setDuration(duration)
|
|
80
85
|
.setInterpolator(ACCELERATE)
|
|
81
86
|
.withLayer()
|
|
82
87
|
.withEndAction { onEnd() }
|
|
83
88
|
.start()
|
|
84
89
|
}
|
|
85
90
|
|
|
91
|
+
override fun onBackGestureStarted(event: BackEventCompat) {
|
|
92
|
+
if (config.popupAnimation == PopupAnimation.NONE) return
|
|
93
|
+
backPreview = true
|
|
94
|
+
card.animate().cancel()
|
|
95
|
+
card.setLayerType(View.LAYER_TYPE_HARDWARE, null)
|
|
96
|
+
onBackGestureProgressed(event)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
override fun onBackGestureProgressed(event: BackEventCompat) {
|
|
100
|
+
if (!backPreview) return
|
|
101
|
+
val scale = 1f - (1f - BACK_MIN_SCALE) * BACK_PROGRESS.getInterpolation(event.progress)
|
|
102
|
+
card.scaleX = scale
|
|
103
|
+
card.scaleY = scale
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
override fun onBackGestureCancelled() {
|
|
107
|
+
if (!backPreview) return
|
|
108
|
+
backPreview = false
|
|
109
|
+
card.animate()
|
|
110
|
+
.scaleX(1f).scaleY(1f)
|
|
111
|
+
.setDuration(BACK_CANCEL_DURATION)
|
|
112
|
+
.setInterpolator(EMPHASIZED_DECELERATE)
|
|
113
|
+
.withEndAction { card.setLayerType(View.LAYER_TYPE_NONE, null) }
|
|
114
|
+
.start()
|
|
115
|
+
}
|
|
116
|
+
|
|
86
117
|
override fun onConfigChanged(previous: ModalConfig) {
|
|
87
118
|
applyAppearance()
|
|
88
119
|
onInsetsChanged()
|
|
@@ -90,7 +121,7 @@ internal class PopupPresenter(
|
|
|
90
121
|
|
|
91
122
|
override fun onInsetsChanged() {
|
|
92
123
|
val margin = dp(MARGIN_DP)
|
|
93
|
-
|
|
124
|
+
cardFrame.setPadding(
|
|
94
125
|
systemInsets.left + margin,
|
|
95
126
|
systemInsets.top + margin,
|
|
96
127
|
systemInsets.right + margin,
|
|
@@ -105,9 +136,29 @@ internal class PopupPresenter(
|
|
|
105
136
|
|
|
106
137
|
override fun onContentSizeChanged() = updateCardSize()
|
|
107
138
|
|
|
139
|
+
private fun runEnter() {
|
|
140
|
+
if (config.popupAnimation == PopupAnimation.NONE) {
|
|
141
|
+
backdrop.alpha = 1f
|
|
142
|
+
card.alpha = 1f
|
|
143
|
+
notifyPresented()
|
|
144
|
+
return
|
|
145
|
+
}
|
|
146
|
+
// Cancelling first restores the layer type a previous animation saved.
|
|
147
|
+
card.animate().cancel()
|
|
148
|
+
card.setLayerType(View.LAYER_TYPE_NONE, null)
|
|
149
|
+
backdrop.animate().alpha(1f).setDuration(ENTER_DURATION).setInterpolator(EMPHASIZED_DECELERATE).start()
|
|
150
|
+
card.animate()
|
|
151
|
+
.alpha(1f).scaleX(1f).scaleY(1f)
|
|
152
|
+
.setDuration(ENTER_DURATION)
|
|
153
|
+
.setInterpolator(EMPHASIZED_DECELERATE)
|
|
154
|
+
.withLayer()
|
|
155
|
+
.withEndAction { notifyPresented() }
|
|
156
|
+
.start()
|
|
157
|
+
}
|
|
158
|
+
|
|
108
159
|
private fun reportArea() {
|
|
109
|
-
val width =
|
|
110
|
-
val height =
|
|
160
|
+
val width = cardFrame.width - cardFrame.paddingLeft - cardFrame.paddingRight
|
|
161
|
+
val height = cardFrame.height - cardFrame.paddingTop - cardFrame.paddingBottom -
|
|
111
162
|
if (config.keyboardBehavior == KeyboardBehavior.RESIZE) keyboardOverlap else 0
|
|
112
163
|
reportContentArea(width, height)
|
|
113
164
|
}
|
|
@@ -134,7 +185,7 @@ internal class PopupPresenter(
|
|
|
134
185
|
card.translationY = 0f
|
|
135
186
|
return
|
|
136
187
|
}
|
|
137
|
-
val headroom = (card.top -
|
|
188
|
+
val headroom = (card.top - cardFrame.paddingTop).coerceAtLeast(0)
|
|
138
189
|
card.translationY = -(keyboardOverlap / 2).coerceAtMost(headroom).toFloat()
|
|
139
190
|
}
|
|
140
191
|
|
|
@@ -142,11 +193,21 @@ internal class PopupPresenter(
|
|
|
142
193
|
private const val MATCH = ViewGroup.LayoutParams.MATCH_PARENT
|
|
143
194
|
private const val MARGIN_DP = 16f
|
|
144
195
|
private const val DEFAULT_CORNER_RADIUS_DP = 16f
|
|
196
|
+
private const val ENTER_SCALE = 0.9f
|
|
197
|
+
private const val EXIT_SCALE = 0.95f
|
|
145
198
|
private const val ENTER_DURATION = 280L
|
|
146
199
|
private const val EXIT_DURATION = 180L
|
|
200
|
+
|
|
201
|
+
/** Predictive back: how far the card shrinks while the gesture runs. */
|
|
202
|
+
private const val BACK_MIN_SCALE = 0.9f
|
|
203
|
+
private const val BACK_COMMIT_DURATION = 150L
|
|
204
|
+
private const val BACK_CANCEL_DURATION = 200L
|
|
147
205
|
private val EMPHASIZED_DECELERATE = PathInterpolator(0.05f, 0.7f, 0.1f, 1f)
|
|
148
206
|
private val ACCELERATE = AccelerateInterpolator()
|
|
149
207
|
|
|
208
|
+
/** Material's predictive back progress curve. */
|
|
209
|
+
private val BACK_PROGRESS = PathInterpolator(0.1f, 0.1f, 0f, 1f)
|
|
210
|
+
|
|
150
211
|
/** Content area before the popup is on screen, in px. */
|
|
151
212
|
fun estimateContentArea(activity: Activity, width: Int, height: Int, insets: Insets): Pair<Int, Int> {
|
|
152
213
|
val margin = activity.dpToPx(MARGIN_DP).toInt() * 2
|