react-native-nitro-modal 0.1.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/LICENSE +20 -0
- package/NitroModal.podspec +29 -0
- package/README.md +39 -0
- package/android/CMakeLists.txt +24 -0
- package/android/build.gradle +124 -0
- package/android/src/main/AndroidManifest.xml +2 -0
- package/android/src/main/cpp/cpp-adapter.cpp +11 -0
- package/android/src/main/java/com/margelo/nitro/nitromodal/BottomSheetPresenter.kt +284 -0
- package/android/src/main/java/com/margelo/nitro/nitromodal/HybridNitroModal.kt +312 -0
- package/android/src/main/java/com/margelo/nitro/nitromodal/ModalConfig.kt +50 -0
- package/android/src/main/java/com/margelo/nitro/nitromodal/ModalContentRoot.kt +115 -0
- package/android/src/main/java/com/margelo/nitro/nitromodal/ModalPresenter.kt +253 -0
- package/android/src/main/java/com/margelo/nitro/nitromodal/NitroModalHostView.kt +52 -0
- package/android/src/main/java/com/margelo/nitro/nitromodal/NitroModalPackage.kt +28 -0
- package/android/src/main/java/com/margelo/nitro/nitromodal/NitroModalViewManager.kt +67 -0
- package/android/src/main/java/com/margelo/nitro/nitromodal/PopupPresenter.kt +156 -0
- package/android/src/main/res/values/styles.xml +17 -0
- package/ios/BackdropView.swift +64 -0
- package/ios/HybridNitroModal.swift +329 -0
- package/ios/HybridNitroModalComponent+Children.mm +77 -0
- package/ios/ModalConfig.swift +73 -0
- package/ios/NitroModalHostView.swift +91 -0
- package/ios/PopupViewController.swift +223 -0
- package/ios/SheetViewController.swift +244 -0
- package/lib/module/NitroModal.js +188 -0
- package/lib/module/NitroModal.js.map +1 -0
- package/lib/module/NitroModal.nitro.js +4 -0
- package/lib/module/NitroModal.nitro.js.map +1 -0
- package/lib/module/NitroModalView.js +17 -0
- package/lib/module/NitroModalView.js.map +1 -0
- package/lib/module/NitroModalView.native.js +6 -0
- package/lib/module/NitroModalView.native.js.map +1 -0
- package/lib/module/index.js +4 -0
- package/lib/module/index.js.map +1 -0
- package/lib/module/package.json +1 -0
- package/lib/typescript/package.json +1 -0
- package/lib/typescript/src/NitroModal.d.ts +59 -0
- package/lib/typescript/src/NitroModal.d.ts.map +1 -0
- package/lib/typescript/src/NitroModal.nitro.d.ts +78 -0
- package/lib/typescript/src/NitroModal.nitro.d.ts.map +1 -0
- package/lib/typescript/src/NitroModalView.d.ts +4 -0
- package/lib/typescript/src/NitroModalView.d.ts.map +1 -0
- package/lib/typescript/src/NitroModalView.native.d.ts +3 -0
- package/lib/typescript/src/NitroModalView.native.d.ts.map +1 -0
- package/lib/typescript/src/index.d.ts +3 -0
- package/lib/typescript/src/index.d.ts.map +1 -0
- package/nitro.json +23 -0
- package/nitrogen/generated/android/c++/JDismissReason.hpp +64 -0
- package/nitrogen/generated/android/c++/JFunc_void.hpp +75 -0
- package/nitrogen/generated/android/c++/JFunc_void_DismissReason.hpp +77 -0
- package/nitrogen/generated/android/c++/JFunc_void_ModalContentArea.hpp +77 -0
- package/nitrogen/generated/android/c++/JFunc_void_double.hpp +75 -0
- package/nitrogen/generated/android/c++/JHybridNitroModalSpec.cpp +344 -0
- package/nitrogen/generated/android/c++/JHybridNitroModalSpec.hpp +106 -0
- package/nitrogen/generated/android/c++/JKeyboardBehavior.hpp +61 -0
- package/nitrogen/generated/android/c++/JModalContentArea.hpp +61 -0
- package/nitrogen/generated/android/c++/JModalMode.hpp +58 -0
- package/nitrogen/generated/android/c++/JPopupAnimation.hpp +61 -0
- package/nitrogen/generated/android/c++/JSheetDetent.hpp +64 -0
- package/nitrogen/generated/android/c++/views/JHybridNitroModalStateUpdater.cpp +174 -0
- package/nitrogen/generated/android/c++/views/JHybridNitroModalStateUpdater.hpp +54 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitromodal/DismissReason.kt +25 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitromodal/Func_void.kt +78 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitromodal/Func_void_DismissReason.kt +78 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitromodal/Func_void_ModalContentArea.kt +78 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitromodal/Func_void_double.kt +78 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitromodal/HybridNitroModalSpec.kt +237 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitromodal/KeyboardBehavior.kt +24 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitromodal/ModalContentArea.kt +56 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitromodal/ModalMode.kt +23 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitromodal/PopupAnimation.kt +24 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitromodal/SheetDetent.kt +25 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitromodal/nitromodalOnLoad.kt +35 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitromodal/views/HybridNitroModalManager.kt +96 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitromodal/views/HybridNitroModalStateUpdater.kt +23 -0
- package/nitrogen/generated/android/nitromodal+autolinking.cmake +83 -0
- package/nitrogen/generated/android/nitromodal+autolinking.gradle +27 -0
- package/nitrogen/generated/android/nitromodalOnLoad.cpp +64 -0
- package/nitrogen/generated/android/nitromodalOnLoad.hpp +34 -0
- package/nitrogen/generated/ios/NitroModal+autolinking.rb +62 -0
- package/nitrogen/generated/ios/NitroModal-Swift-Cxx-Bridge.cpp +65 -0
- package/nitrogen/generated/ios/NitroModal-Swift-Cxx-Bridge.hpp +237 -0
- package/nitrogen/generated/ios/NitroModal-Swift-Cxx-Umbrella.hpp +64 -0
- package/nitrogen/generated/ios/NitroModalAutolinking.mm +33 -0
- package/nitrogen/generated/ios/NitroModalAutolinking.swift +26 -0
- package/nitrogen/generated/ios/c++/HybridNitroModalSpecSwift.cpp +11 -0
- package/nitrogen/generated/ios/c++/HybridNitroModalSpecSwift.hpp +247 -0
- package/nitrogen/generated/ios/c++/views/HybridNitroModalComponent.mm +264 -0
- package/nitrogen/generated/ios/swift/DismissReason.swift +48 -0
- package/nitrogen/generated/ios/swift/Func_void.swift +46 -0
- package/nitrogen/generated/ios/swift/Func_void_DismissReason.swift +46 -0
- package/nitrogen/generated/ios/swift/Func_void_ModalContentArea.swift +46 -0
- package/nitrogen/generated/ios/swift/Func_void_double.swift +46 -0
- package/nitrogen/generated/ios/swift/HybridNitroModalSpec.swift +77 -0
- package/nitrogen/generated/ios/swift/HybridNitroModalSpec_cxx.swift +554 -0
- package/nitrogen/generated/ios/swift/KeyboardBehavior.swift +44 -0
- package/nitrogen/generated/ios/swift/ModalContentArea.swift +34 -0
- package/nitrogen/generated/ios/swift/ModalMode.swift +40 -0
- package/nitrogen/generated/ios/swift/PopupAnimation.swift +44 -0
- package/nitrogen/generated/ios/swift/SheetDetent.swift +48 -0
- package/nitrogen/generated/shared/c++/DismissReason.hpp +84 -0
- package/nitrogen/generated/shared/c++/HybridNitroModalSpec.cpp +65 -0
- package/nitrogen/generated/shared/c++/HybridNitroModalSpec.hpp +124 -0
- package/nitrogen/generated/shared/c++/KeyboardBehavior.hpp +80 -0
- package/nitrogen/generated/shared/c++/ModalContentArea.hpp +87 -0
- package/nitrogen/generated/shared/c++/ModalMode.hpp +76 -0
- package/nitrogen/generated/shared/c++/PopupAnimation.hpp +80 -0
- package/nitrogen/generated/shared/c++/SheetDetent.hpp +84 -0
- package/nitrogen/generated/shared/c++/views/HybridNitroModalComponent.cpp +74 -0
- package/nitrogen/generated/shared/c++/views/HybridNitroModalComponent.hpp +151 -0
- package/nitrogen/generated/shared/json/NitroModalConfig.json +30 -0
- package/package.json +183 -0
- package/src/NitroModal.nitro.ts +92 -0
- package/src/NitroModal.tsx +292 -0
- package/src/NitroModalView.native.tsx +8 -0
- package/src/NitroModalView.tsx +24 -0
- package/src/index.tsx +12 -0
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
package com.margelo.nitro.nitromodal
|
|
2
|
+
|
|
3
|
+
import android.app.Activity
|
|
4
|
+
import android.os.Build
|
|
5
|
+
import android.view.View
|
|
6
|
+
import android.view.ViewGroup
|
|
7
|
+
import android.view.WindowManager
|
|
8
|
+
import android.view.inputmethod.InputMethodManager
|
|
9
|
+
import androidx.activity.ComponentDialog
|
|
10
|
+
import androidx.activity.OnBackPressedCallback
|
|
11
|
+
import androidx.core.graphics.Insets
|
|
12
|
+
import androidx.core.view.ViewCompat
|
|
13
|
+
import androidx.core.view.WindowCompat
|
|
14
|
+
import androidx.core.view.WindowInsetsAnimationCompat
|
|
15
|
+
import androidx.core.view.WindowInsetsCompat
|
|
16
|
+
import com.facebook.react.uimanager.ThemedReactContext
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Owns one modal window from show to dismiss. Subclasses build the layout and
|
|
20
|
+
* drive the enter/exit animations; this class handles the window, edge-to-edge
|
|
21
|
+
* insets, keyboard tracking, back handling and teardown.
|
|
22
|
+
*
|
|
23
|
+
* Everything here runs on the UI thread.
|
|
24
|
+
*/
|
|
25
|
+
internal abstract class ModalPresenter(
|
|
26
|
+
protected val activity: Activity,
|
|
27
|
+
private val reactContext: ThemedReactContext,
|
|
28
|
+
protected val contentRoot: ModalContentRoot,
|
|
29
|
+
config: ModalConfig,
|
|
30
|
+
protected val listener: Listener,
|
|
31
|
+
) {
|
|
32
|
+
interface Listener {
|
|
33
|
+
fun onPresented()
|
|
34
|
+
/** The window is gone. Called exactly once per presenter. */
|
|
35
|
+
fun onDismissed(reason: DismissReason)
|
|
36
|
+
fun onBackdropPress()
|
|
37
|
+
fun onBackPress()
|
|
38
|
+
fun onDetentChange(index: Int)
|
|
39
|
+
/** Area available to the content, in px. */
|
|
40
|
+
fun onContentAreaChange(width: Int, height: Int)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
protected var config: ModalConfig = config
|
|
44
|
+
private set
|
|
45
|
+
|
|
46
|
+
protected val dialog = ComponentDialog(activity, R.style.NitroModal_Dialog)
|
|
47
|
+
protected val backdrop = View(activity)
|
|
48
|
+
|
|
49
|
+
/** System bars + display cutout. */
|
|
50
|
+
protected var systemInsets: Insets = Insets.NONE
|
|
51
|
+
private set
|
|
52
|
+
|
|
53
|
+
/** Current keyboard height, updated every frame while it animates. */
|
|
54
|
+
protected var imeHeight: Int = 0
|
|
55
|
+
private set
|
|
56
|
+
|
|
57
|
+
private var imeAnimating = false
|
|
58
|
+
private var presentedNotified = false
|
|
59
|
+
private var finished = false
|
|
60
|
+
private var dismissReason = DismissReason.PROGRAMMATIC
|
|
61
|
+
|
|
62
|
+
protected var isDismissing = false
|
|
63
|
+
private set
|
|
64
|
+
|
|
65
|
+
protected abstract fun createLayout(): ViewGroup
|
|
66
|
+
protected abstract fun animateIn()
|
|
67
|
+
protected abstract fun animateOut(onEnd: () -> Unit)
|
|
68
|
+
protected abstract fun onConfigChanged(previous: ModalConfig)
|
|
69
|
+
|
|
70
|
+
/** System insets or the settled keyboard height changed. */
|
|
71
|
+
protected abstract fun onInsetsChanged()
|
|
72
|
+
|
|
73
|
+
/** Called every frame while the keyboard animates. Must not trigger layout. */
|
|
74
|
+
protected abstract fun onKeyboardProgress()
|
|
75
|
+
|
|
76
|
+
abstract fun onContentSizeChanged()
|
|
77
|
+
|
|
78
|
+
open fun snapToDetent(index: Int) = Unit
|
|
79
|
+
|
|
80
|
+
fun show() {
|
|
81
|
+
(contentRoot.parent as? ViewGroup)?.removeView(contentRoot)
|
|
82
|
+
val root = createLayout()
|
|
83
|
+
backdrop.setBackgroundColor(config.effectiveBackdropColor)
|
|
84
|
+
backdrop.alpha = 0f
|
|
85
|
+
backdrop.setOnClickListener { listener.onBackdropPress() }
|
|
86
|
+
|
|
87
|
+
dialog.setContentView(root, ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT))
|
|
88
|
+
dialog.setCancelable(false)
|
|
89
|
+
dialog.onBackPressedDispatcher.addCallback(dialog, object : OnBackPressedCallback(true) {
|
|
90
|
+
override fun handleOnBackPressed() = listener.onBackPress()
|
|
91
|
+
})
|
|
92
|
+
observeInsets(root)
|
|
93
|
+
|
|
94
|
+
val window = requireNotNull(dialog.window)
|
|
95
|
+
window.setLayout(WindowManager.LayoutParams.MATCH_PARENT, WindowManager.LayoutParams.MATCH_PARENT)
|
|
96
|
+
WindowCompat.setDecorFitsSystemWindows(window, false)
|
|
97
|
+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
|
98
|
+
window.attributes = window.attributes.apply {
|
|
99
|
+
layoutInDisplayCutoutMode = WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
|
103
|
+
window.isStatusBarContrastEnforced = false
|
|
104
|
+
window.isNavigationBarContrastEnforced = false
|
|
105
|
+
}
|
|
106
|
+
activity.window?.let { activityWindow ->
|
|
107
|
+
val source = WindowCompat.getInsetsController(activityWindow, activityWindow.decorView)
|
|
108
|
+
WindowCompat.getInsetsController(window, window.decorView).apply {
|
|
109
|
+
isAppearanceLightStatusBars = source.isAppearanceLightStatusBars
|
|
110
|
+
isAppearanceLightNavigationBars = source.isAppearanceLightNavigationBars
|
|
111
|
+
}
|
|
112
|
+
if (activityWindow.attributes.flags and WindowManager.LayoutParams.FLAG_SECURE != 0) {
|
|
113
|
+
window.addFlags(WindowManager.LayoutParams.FLAG_SECURE)
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
applyBlur()
|
|
117
|
+
|
|
118
|
+
// Like RN's Modal: not focusable while showing so immersive system bars
|
|
119
|
+
// don't flash, then focusable again so text inputs work.
|
|
120
|
+
window.addFlags(WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE)
|
|
121
|
+
dialog.show()
|
|
122
|
+
window.clearFlags(WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE)
|
|
123
|
+
reactContext.onExtraWindowCreate(window)
|
|
124
|
+
|
|
125
|
+
animateIn()
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
fun update(newConfig: ModalConfig) {
|
|
129
|
+
val previous = config
|
|
130
|
+
if (previous == newConfig) return
|
|
131
|
+
config = newConfig
|
|
132
|
+
backdrop.setBackgroundColor(config.effectiveBackdropColor)
|
|
133
|
+
if (previous.backdropBlurRadius != newConfig.backdropBlurRadius) applyBlur()
|
|
134
|
+
onConfigChanged(previous)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Animated dismissal. [Listener.onDismissed] follows when it completes. */
|
|
138
|
+
fun dismiss(reason: DismissReason) {
|
|
139
|
+
if (isDismissing || finished) return
|
|
140
|
+
isDismissing = true
|
|
141
|
+
dismissReason = reason
|
|
142
|
+
hideKeyboard()
|
|
143
|
+
animateOut { finish() }
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Tears the window down without animating or notifying the listener. */
|
|
147
|
+
fun dismissImmediately() {
|
|
148
|
+
if (finished) return
|
|
149
|
+
finished = true
|
|
150
|
+
teardown()
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** For subclasses: the enter animation finished. */
|
|
154
|
+
protected fun notifyPresented() {
|
|
155
|
+
if (presentedNotified || isDismissing || finished) return
|
|
156
|
+
presentedNotified = true
|
|
157
|
+
listener.onPresented()
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** For subclasses: the user dismissed the modal natively (e.g. swipe). */
|
|
161
|
+
protected fun finishUserDismissal(reason: DismissReason) {
|
|
162
|
+
if (finished) return
|
|
163
|
+
if (!isDismissing) dismissReason = reason
|
|
164
|
+
finish()
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
protected fun reportContentArea(width: Int, height: Int) {
|
|
168
|
+
if (width > 0 && height > 0) listener.onContentAreaChange(width, height)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Keyboard overlap below the bottom system inset, in px. */
|
|
172
|
+
protected val keyboardOverlap: Int
|
|
173
|
+
get() = (imeHeight - systemInsets.bottom).coerceAtLeast(0)
|
|
174
|
+
|
|
175
|
+
protected fun dp(value: Float): Int = activity.dpToPx(value).toInt()
|
|
176
|
+
|
|
177
|
+
private fun finish() {
|
|
178
|
+
if (finished) return
|
|
179
|
+
finished = true
|
|
180
|
+
teardown()
|
|
181
|
+
listener.onDismissed(dismissReason)
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
private fun teardown() {
|
|
185
|
+
backdrop.animate().cancel()
|
|
186
|
+
dialog.window?.let { reactContext.onExtraWindowDestroy(it) }
|
|
187
|
+
if (dialog.isShowing && !activity.isFinishing && !activity.isDestroyed) {
|
|
188
|
+
try {
|
|
189
|
+
dialog.dismiss()
|
|
190
|
+
} catch (e: IllegalArgumentException) {
|
|
191
|
+
// The window was already detached from the window manager.
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
(contentRoot.parent as? ViewGroup)?.removeView(contentRoot)
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
private fun hideKeyboard() {
|
|
198
|
+
val token = dialog.window?.decorView?.windowToken ?: return
|
|
199
|
+
(activity.getSystemService(Activity.INPUT_METHOD_SERVICE) as? InputMethodManager)
|
|
200
|
+
?.hideSoftInputFromWindow(token, 0)
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
private fun applyBlur() {
|
|
204
|
+
val window = dialog.window ?: return
|
|
205
|
+
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) return
|
|
206
|
+
val radius = dp(config.backdropBlurRadius)
|
|
207
|
+
if (radius > 0 && activity.windowManager.isCrossWindowBlurEnabled) {
|
|
208
|
+
window.addFlags(WindowManager.LayoutParams.FLAG_BLUR_BEHIND)
|
|
209
|
+
window.attributes = window.attributes.apply { blurBehindRadius = radius }
|
|
210
|
+
} else {
|
|
211
|
+
window.clearFlags(WindowManager.LayoutParams.FLAG_BLUR_BEHIND)
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
private fun observeInsets(root: View) {
|
|
216
|
+
ViewCompat.setOnApplyWindowInsetsListener(root) { _, insets ->
|
|
217
|
+
systemInsets = insets.getInsets(WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout())
|
|
218
|
+
// While the keyboard animates, the end state arrives up front; the
|
|
219
|
+
// animation callback below feeds the in-between frames instead.
|
|
220
|
+
if (!imeAnimating) imeHeight = insets.getInsets(WindowInsetsCompat.Type.ime()).bottom
|
|
221
|
+
onInsetsChanged()
|
|
222
|
+
WindowInsetsCompat.CONSUMED
|
|
223
|
+
}
|
|
224
|
+
ViewCompat.setWindowInsetsAnimationCallback(
|
|
225
|
+
root,
|
|
226
|
+
object : WindowInsetsAnimationCompat.Callback(WindowInsetsAnimationCompat.Callback.DISPATCH_MODE_STOP) {
|
|
227
|
+
override fun onPrepare(animation: WindowInsetsAnimationCompat) {
|
|
228
|
+
if (animation.typeMask and WindowInsetsCompat.Type.ime() != 0) imeAnimating = true
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
override fun onProgress(
|
|
232
|
+
insets: WindowInsetsCompat,
|
|
233
|
+
runningAnimations: MutableList<WindowInsetsAnimationCompat>,
|
|
234
|
+
): WindowInsetsCompat {
|
|
235
|
+
if (imeAnimating) {
|
|
236
|
+
imeHeight = insets.getInsets(WindowInsetsCompat.Type.ime()).bottom
|
|
237
|
+
onKeyboardProgress()
|
|
238
|
+
}
|
|
239
|
+
return insets
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
override fun onEnd(animation: WindowInsetsAnimationCompat) {
|
|
243
|
+
if (animation.typeMask and WindowInsetsCompat.Type.ime() == 0) return
|
|
244
|
+
imeAnimating = false
|
|
245
|
+
ViewCompat.getRootWindowInsets(root)?.let {
|
|
246
|
+
imeHeight = it.getInsets(WindowInsetsCompat.Type.ime()).bottom
|
|
247
|
+
}
|
|
248
|
+
onInsetsChanged()
|
|
249
|
+
}
|
|
250
|
+
},
|
|
251
|
+
)
|
|
252
|
+
}
|
|
253
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
package com.margelo.nitro.nitromodal
|
|
2
|
+
|
|
3
|
+
import android.annotation.SuppressLint
|
|
4
|
+
import android.view.View
|
|
5
|
+
import android.view.ViewGroup
|
|
6
|
+
import android.view.accessibility.AccessibilityEvent
|
|
7
|
+
import com.facebook.react.uimanager.ThemedReactContext
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The view Nitro mounts into the React tree. It is an invisible placeholder:
|
|
11
|
+
* React children added to it (via [NitroModalViewManager]) are hosted by
|
|
12
|
+
* [contentRoot], which travels into the modal window.
|
|
13
|
+
*/
|
|
14
|
+
@SuppressLint("ViewConstructor")
|
|
15
|
+
class NitroModalHostView(context: ThemedReactContext) : ViewGroup(context) {
|
|
16
|
+
internal val contentRoot = ModalContentRoot(context)
|
|
17
|
+
internal var onAttachedChange: ((attached: Boolean) -> Unit)? = null
|
|
18
|
+
|
|
19
|
+
internal val reactChildCount: Int
|
|
20
|
+
get() = contentRoot.childCount
|
|
21
|
+
|
|
22
|
+
internal fun addReactChild(child: View, index: Int) = contentRoot.addView(child, index)
|
|
23
|
+
|
|
24
|
+
internal fun getReactChildAt(index: Int): View? = contentRoot.getChildAt(index)
|
|
25
|
+
|
|
26
|
+
internal fun removeReactChildAt(index: Int) = contentRoot.removeViewAt(index)
|
|
27
|
+
|
|
28
|
+
override fun setId(id: Int) {
|
|
29
|
+
super.setId(id)
|
|
30
|
+
// Touches that hit no child target the root; give it our React tag.
|
|
31
|
+
contentRoot.id = id
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
|
|
35
|
+
// Laid out by Fabric; our children live in the modal window.
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
override fun onAttachedToWindow() {
|
|
39
|
+
super.onAttachedToWindow()
|
|
40
|
+
onAttachedChange?.invoke(true)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
override fun onDetachedFromWindow() {
|
|
44
|
+
super.onDetachedFromWindow()
|
|
45
|
+
onAttachedChange?.invoke(false)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Accessibility for the content is handled inside the modal window.
|
|
49
|
+
override fun addChildrenForAccessibility(outChildren: ArrayList<View>) = Unit
|
|
50
|
+
|
|
51
|
+
override fun dispatchPopulateAccessibilityEvent(event: AccessibilityEvent): Boolean = false
|
|
52
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
package com.margelo.nitro.nitromodal
|
|
2
|
+
|
|
3
|
+
import com.facebook.react.BaseReactPackage
|
|
4
|
+
import com.facebook.react.bridge.NativeModule
|
|
5
|
+
import com.facebook.react.bridge.ReactApplicationContext
|
|
6
|
+
import com.facebook.react.module.model.ReactModuleInfoProvider
|
|
7
|
+
import com.facebook.react.uimanager.ViewManager
|
|
8
|
+
|
|
9
|
+
class NitroModalPackage : BaseReactPackage() {
|
|
10
|
+
override fun getModule(name: String, reactContext: ReactApplicationContext): NativeModule? {
|
|
11
|
+
return null
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
override fun getReactModuleInfoProvider(): ReactModuleInfoProvider {
|
|
15
|
+
return ReactModuleInfoProvider { HashMap() }
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> {
|
|
19
|
+
// Not nitrogen's generated HybridNitroModalManager: the modal needs children.
|
|
20
|
+
return listOf(NitroModalViewManager())
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
companion object {
|
|
24
|
+
init {
|
|
25
|
+
System.loadLibrary("nitromodal")
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
package com.margelo.nitro.nitromodal
|
|
2
|
+
|
|
3
|
+
import android.view.View
|
|
4
|
+
import com.facebook.react.uimanager.ReactStylesDiffMap
|
|
5
|
+
import com.facebook.react.uimanager.StateWrapper
|
|
6
|
+
import com.facebook.react.uimanager.ThemedReactContext
|
|
7
|
+
import com.facebook.react.uimanager.UIManagerHelper
|
|
8
|
+
import com.facebook.react.uimanager.ViewGroupManager
|
|
9
|
+
import com.margelo.nitro.R.id.associated_hybrid_view_tag
|
|
10
|
+
import com.margelo.nitro.nitromodal.views.HybridNitroModalStateUpdater
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* View manager for the "NitroModal" Nitro view.
|
|
14
|
+
*
|
|
15
|
+
* Nitrogen generates a `SimpleViewManager`, which cannot have children. This
|
|
16
|
+
* is the same manager as a `ViewGroupManager`: props still flow through the
|
|
17
|
+
* generated C++ state updater, and React children are redirected into the
|
|
18
|
+
* modal's [ModalContentRoot].
|
|
19
|
+
*/
|
|
20
|
+
class NitroModalViewManager : ViewGroupManager<NitroModalHostView>() {
|
|
21
|
+
private class HybridViewHolder(val hybridView: HybridNitroModal, var lastState: StateWrapper? = null)
|
|
22
|
+
|
|
23
|
+
override fun getName(): String = "NitroModal"
|
|
24
|
+
|
|
25
|
+
override fun createViewInstance(reactContext: ThemedReactContext): NitroModalHostView {
|
|
26
|
+
val hybridView = HybridNitroModal(reactContext)
|
|
27
|
+
val view = hybridView.view
|
|
28
|
+
view.setTag(associated_hybrid_view_tag, HybridViewHolder(hybridView))
|
|
29
|
+
return view
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
override fun addEventEmitters(reactContext: ThemedReactContext, view: NitroModalHostView) {
|
|
33
|
+
view.contentRoot.eventDispatcher = UIManagerHelper.getEventDispatcher(reactContext)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
override fun updateState(view: NitroModalHostView, props: ReactStylesDiffMap, stateWrapper: StateWrapper): Any? {
|
|
37
|
+
val holder = holder(view) ?: throw IllegalStateException("Couldn't find the HybridNitroModal for $view")
|
|
38
|
+
val hybridView = holder.hybridView
|
|
39
|
+
hybridView.beforeUpdate()
|
|
40
|
+
HybridNitroModalStateUpdater.updateViewProps(hybridView, stateWrapper, holder.lastState)
|
|
41
|
+
hybridView.afterUpdate()
|
|
42
|
+
holder.lastState = stateWrapper
|
|
43
|
+
return super.updateState(view, props, stateWrapper)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
override fun onDropViewInstance(view: NitroModalHostView) {
|
|
47
|
+
holder(view)?.let {
|
|
48
|
+
it.lastState = null
|
|
49
|
+
it.hybridView.onDropView()
|
|
50
|
+
}
|
|
51
|
+
super.onDropViewInstance(view)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// region Children live in the modal window, not in the host view.
|
|
55
|
+
|
|
56
|
+
override fun addView(parent: NitroModalHostView, child: View, index: Int) = parent.addReactChild(child, index)
|
|
57
|
+
|
|
58
|
+
override fun getChildCount(parent: NitroModalHostView): Int = parent.reactChildCount
|
|
59
|
+
|
|
60
|
+
override fun getChildAt(parent: NitroModalHostView, index: Int): View? = parent.getReactChildAt(index)
|
|
61
|
+
|
|
62
|
+
override fun removeViewAt(parent: NitroModalHostView, index: Int) = parent.removeReactChildAt(index)
|
|
63
|
+
|
|
64
|
+
// endregion
|
|
65
|
+
|
|
66
|
+
private fun holder(view: View): HybridViewHolder? = view.getTag(associated_hybrid_view_tag) as? HybridViewHolder
|
|
67
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
package com.margelo.nitro.nitromodal
|
|
2
|
+
|
|
3
|
+
import android.app.Activity
|
|
4
|
+
import android.graphics.drawable.GradientDrawable
|
|
5
|
+
import android.view.Gravity
|
|
6
|
+
import android.view.ViewGroup
|
|
7
|
+
import android.view.ViewOutlineProvider
|
|
8
|
+
import android.view.animation.AccelerateInterpolator
|
|
9
|
+
import android.view.animation.PathInterpolator
|
|
10
|
+
import android.widget.FrameLayout
|
|
11
|
+
import androidx.core.graphics.Insets
|
|
12
|
+
import com.facebook.react.uimanager.ThemedReactContext
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* `popup` mode: a centered card over the backdrop. Enter/exit are
|
|
16
|
+
* [android.view.ViewPropertyAnimator]s on hardware layers, so they run on the
|
|
17
|
+
* UI/render threads without touching JS.
|
|
18
|
+
*/
|
|
19
|
+
internal class PopupPresenter(
|
|
20
|
+
activity: Activity,
|
|
21
|
+
reactContext: ThemedReactContext,
|
|
22
|
+
contentRoot: ModalContentRoot,
|
|
23
|
+
config: ModalConfig,
|
|
24
|
+
listener: Listener,
|
|
25
|
+
) : ModalPresenter(activity, reactContext, contentRoot, config, listener) {
|
|
26
|
+
|
|
27
|
+
private val root = FrameLayout(activity)
|
|
28
|
+
private val card = FrameLayout(activity)
|
|
29
|
+
private val cardBackground = GradientDrawable()
|
|
30
|
+
|
|
31
|
+
override fun createLayout(): ViewGroup {
|
|
32
|
+
root.addView(backdrop, FrameLayout.LayoutParams(MATCH, MATCH))
|
|
33
|
+
card.background = cardBackground
|
|
34
|
+
card.outlineProvider = ViewOutlineProvider.BACKGROUND
|
|
35
|
+
card.clipToOutline = true
|
|
36
|
+
card.addView(contentRoot, FrameLayout.LayoutParams(MATCH, MATCH))
|
|
37
|
+
root.addView(card, FrameLayout.LayoutParams(0, 0, Gravity.CENTER))
|
|
38
|
+
root.addOnLayoutChangeListener { _, l, t, r, b, oldL, oldT, oldR, oldB ->
|
|
39
|
+
if (r - l != oldR - oldL || b - t != oldB - oldT) reportArea()
|
|
40
|
+
}
|
|
41
|
+
applyAppearance()
|
|
42
|
+
updateCardSize()
|
|
43
|
+
return root
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
override fun animateIn() {
|
|
47
|
+
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
|
+
}
|
|
69
|
+
|
|
70
|
+
override fun animateOut(onEnd: () -> Unit) {
|
|
71
|
+
if (config.popupAnimation == PopupAnimation.NONE) {
|
|
72
|
+
onEnd()
|
|
73
|
+
return
|
|
74
|
+
}
|
|
75
|
+
val scale = if (config.popupAnimation == PopupAnimation.SCALE) 0.95f else 1f
|
|
76
|
+
backdrop.animate().alpha(0f).setDuration(EXIT_DURATION).setInterpolator(ACCELERATE).start()
|
|
77
|
+
card.animate()
|
|
78
|
+
.alpha(0f).scaleX(scale).scaleY(scale)
|
|
79
|
+
.setDuration(EXIT_DURATION)
|
|
80
|
+
.setInterpolator(ACCELERATE)
|
|
81
|
+
.withLayer()
|
|
82
|
+
.withEndAction { onEnd() }
|
|
83
|
+
.start()
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
override fun onConfigChanged(previous: ModalConfig) {
|
|
87
|
+
applyAppearance()
|
|
88
|
+
onInsetsChanged()
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
override fun onInsetsChanged() {
|
|
92
|
+
val margin = dp(MARGIN_DP)
|
|
93
|
+
root.setPadding(
|
|
94
|
+
systemInsets.left + margin,
|
|
95
|
+
systemInsets.top + margin,
|
|
96
|
+
systemInsets.right + margin,
|
|
97
|
+
systemInsets.bottom + margin,
|
|
98
|
+
)
|
|
99
|
+
updateCardSize()
|
|
100
|
+
applyKeyboardOffset()
|
|
101
|
+
reportArea()
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
override fun onKeyboardProgress() = applyKeyboardOffset()
|
|
105
|
+
|
|
106
|
+
override fun onContentSizeChanged() = updateCardSize()
|
|
107
|
+
|
|
108
|
+
private fun reportArea() {
|
|
109
|
+
val width = root.width - root.paddingLeft - root.paddingRight
|
|
110
|
+
val height = root.height - root.paddingTop - root.paddingBottom -
|
|
111
|
+
if (config.keyboardBehavior == KeyboardBehavior.RESIZE) keyboardOverlap else 0
|
|
112
|
+
reportContentArea(width, height)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
private fun applyAppearance() {
|
|
116
|
+
cardBackground.cornerRadius = activity.dpToPx(config.cornerRadius ?: DEFAULT_CORNER_RADIUS_DP)
|
|
117
|
+
cardBackground.setColor(config.surfaceColor(activity))
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
private fun updateCardSize() {
|
|
121
|
+
val params = card.layoutParams ?: return
|
|
122
|
+
val width = contentRoot.contentWidth
|
|
123
|
+
val height = contentRoot.contentHeight
|
|
124
|
+
if (params.width != width || params.height != height) {
|
|
125
|
+
params.width = width
|
|
126
|
+
params.height = height
|
|
127
|
+
card.layoutParams = params
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Keeps the card centered in the space above the keyboard. */
|
|
132
|
+
private fun applyKeyboardOffset() {
|
|
133
|
+
if (config.keyboardBehavior == KeyboardBehavior.NONE) {
|
|
134
|
+
card.translationY = 0f
|
|
135
|
+
return
|
|
136
|
+
}
|
|
137
|
+
val headroom = (card.top - root.paddingTop).coerceAtLeast(0)
|
|
138
|
+
card.translationY = -(keyboardOverlap / 2).coerceAtMost(headroom).toFloat()
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
companion object {
|
|
142
|
+
private const val MATCH = ViewGroup.LayoutParams.MATCH_PARENT
|
|
143
|
+
private const val MARGIN_DP = 16f
|
|
144
|
+
private const val DEFAULT_CORNER_RADIUS_DP = 16f
|
|
145
|
+
private const val ENTER_DURATION = 280L
|
|
146
|
+
private const val EXIT_DURATION = 180L
|
|
147
|
+
private val EMPHASIZED_DECELERATE = PathInterpolator(0.05f, 0.7f, 0.1f, 1f)
|
|
148
|
+
private val ACCELERATE = AccelerateInterpolator()
|
|
149
|
+
|
|
150
|
+
/** Content area before the popup is on screen, in px. */
|
|
151
|
+
fun estimateContentArea(activity: Activity, width: Int, height: Int, insets: Insets): Pair<Int, Int> {
|
|
152
|
+
val margin = activity.dpToPx(MARGIN_DP).toInt() * 2
|
|
153
|
+
return Pair(width - insets.left - insets.right - margin, height - insets.top - insets.bottom - margin)
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="utf-8"?>
|
|
2
|
+
<resources>
|
|
3
|
+
<!--
|
|
4
|
+
Full-screen, transparent, undimmed window. The backdrop, sheet and card are
|
|
5
|
+
regular views inside it so their animations can be driven per frame.
|
|
6
|
+
-->
|
|
7
|
+
<style name="NitroModal.Dialog" parent="@android:style/Theme.Translucent.NoTitleBar">
|
|
8
|
+
<item name="android:windowIsFloating">false</item>
|
|
9
|
+
<item name="android:windowBackground">@android:color/transparent</item>
|
|
10
|
+
<item name="android:backgroundDimEnabled">false</item>
|
|
11
|
+
<item name="android:windowAnimationStyle">@null</item>
|
|
12
|
+
<item name="android:windowDrawsSystemBarBackgrounds">true</item>
|
|
13
|
+
<item name="android:statusBarColor">@android:color/transparent</item>
|
|
14
|
+
<item name="android:navigationBarColor">@android:color/transparent</item>
|
|
15
|
+
<item name="android:windowSoftInputMode">adjustResize</item>
|
|
16
|
+
</style>
|
|
17
|
+
</resources>
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
//
|
|
2
|
+
// BackdropView.swift
|
|
3
|
+
// NitroModal
|
|
4
|
+
//
|
|
5
|
+
|
|
6
|
+
import UIKit
|
|
7
|
+
|
|
8
|
+
/// Dimming (and optional blur) layer behind the modal. Its `alpha` is driven
|
|
9
|
+
/// by the presentation transitions, so it fades with the modal, including
|
|
10
|
+
/// interactive swipe-to-dismiss.
|
|
11
|
+
final class BackdropView: UIView {
|
|
12
|
+
var onTap: (() -> Void)?
|
|
13
|
+
|
|
14
|
+
private let dimView = UIView()
|
|
15
|
+
private var blurView: UIVisualEffectView?
|
|
16
|
+
|
|
17
|
+
override init(frame: CGRect) {
|
|
18
|
+
super.init(frame: frame)
|
|
19
|
+
dimView.frame = bounds
|
|
20
|
+
dimView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
|
21
|
+
addSubview(dimView)
|
|
22
|
+
addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(handleTap)))
|
|
23
|
+
isAccessibilityElement = true
|
|
24
|
+
accessibilityTraits = .button
|
|
25
|
+
accessibilityLabel = "Dismiss"
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
@available(*, unavailable)
|
|
29
|
+
required init?(coder: NSCoder) {
|
|
30
|
+
fatalError("init(coder:) is not supported")
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
func configure(color: UIColor, opacity: CGFloat, blurRadius: CGFloat) {
|
|
34
|
+
var alpha: CGFloat = 1
|
|
35
|
+
color.getRed(nil, green: nil, blue: nil, alpha: &alpha)
|
|
36
|
+
dimView.backgroundColor = color.withAlphaComponent(alpha * min(max(opacity, 0), 1))
|
|
37
|
+
|
|
38
|
+
guard blurRadius > 0 else {
|
|
39
|
+
blurView?.removeFromSuperview()
|
|
40
|
+
blurView = nil
|
|
41
|
+
return
|
|
42
|
+
}
|
|
43
|
+
let blurView = self.blurView ?? {
|
|
44
|
+
let view = UIVisualEffectView(effect: UIBlurEffect(style: .systemUltraThinMaterial))
|
|
45
|
+
view.frame = bounds
|
|
46
|
+
view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
|
47
|
+
insertSubview(view, belowSubview: dimView)
|
|
48
|
+
self.blurView = view
|
|
49
|
+
return view
|
|
50
|
+
}()
|
|
51
|
+
// UIKit has no public blur radius. Blend a thin system material instead,
|
|
52
|
+
// reaching full strength at ~30pt.
|
|
53
|
+
blurView.alpha = min(blurRadius / 30, 1)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
@objc private func handleTap() {
|
|
57
|
+
onTap?()
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
override func accessibilityActivate() -> Bool {
|
|
61
|
+
onTap?()
|
|
62
|
+
return true
|
|
63
|
+
}
|
|
64
|
+
}
|