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,329 @@
|
|
|
1
|
+
//
|
|
2
|
+
// HybridNitroModal.swift
|
|
3
|
+
// NitroModal
|
|
4
|
+
//
|
|
5
|
+
|
|
6
|
+
import NitroModules
|
|
7
|
+
import UIKit
|
|
8
|
+
|
|
9
|
+
/// The Nitro Hybrid View. Props arrive on the main thread (Fabric
|
|
10
|
+
/// `updateProps`); methods arrive on the JS thread and hop to main.
|
|
11
|
+
///
|
|
12
|
+
/// Presentation is a small state machine so that rapid `isOpen` toggles,
|
|
13
|
+
/// interactive dismissals and unmounts always settle on a consistent state,
|
|
14
|
+
/// and every accepted present request ends with exactly one `onDismiss`.
|
|
15
|
+
final class HybridNitroModal: HybridNitroModalSpec {
|
|
16
|
+
let view = NitroModalHostView()
|
|
17
|
+
|
|
18
|
+
// MARK: Props
|
|
19
|
+
|
|
20
|
+
var isOpen = false {
|
|
21
|
+
didSet {
|
|
22
|
+
guard isOpen != oldValue else { return }
|
|
23
|
+
if isOpen {
|
|
24
|
+
requestPresent()
|
|
25
|
+
} else {
|
|
26
|
+
requestDismiss(.programmatic)
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
var mode: ModalMode = .bottomsheet
|
|
32
|
+
var detents: [SheetDetent] = [.fitcontent]
|
|
33
|
+
var initialDetentIndex: Double = 0
|
|
34
|
+
var backdropColor: Double = 0xFF00_0000
|
|
35
|
+
var backdropOpacity: Double = 0.4
|
|
36
|
+
var backdropBlurRadius: Double = 0
|
|
37
|
+
var dismissOnBackdropPress = true
|
|
38
|
+
var dismissOnSwipe = true
|
|
39
|
+
var dismissOnBackButton = true
|
|
40
|
+
var grabberVisible = false
|
|
41
|
+
var cornerRadius: Double = -1
|
|
42
|
+
var contentBackgroundColor: Double?
|
|
43
|
+
var keyboardBehavior: KeyboardBehavior = .pan
|
|
44
|
+
var popupAnimation: PopupAnimation = .scale
|
|
45
|
+
|
|
46
|
+
var onPresent: (() -> Void)?
|
|
47
|
+
var onDismiss: ((DismissReason) -> Void)?
|
|
48
|
+
var onDetentChange: ((Double) -> Void)?
|
|
49
|
+
var onBackdropPress: (() -> Void)?
|
|
50
|
+
var onBackButtonPress: (() -> Void)?
|
|
51
|
+
var onContentAreaChange: ((ModalContentArea) -> Void)?
|
|
52
|
+
|
|
53
|
+
// MARK: State
|
|
54
|
+
|
|
55
|
+
private enum Phase {
|
|
56
|
+
case idle, presenting, presented, dismissing
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
private var config = ModalConfig()
|
|
60
|
+
private var phase = Phase.idle
|
|
61
|
+
/// What React (or an imperative call) asked for.
|
|
62
|
+
private var wantsOpen = false
|
|
63
|
+
/// A present request was accepted and its `onDismiss` is still owed.
|
|
64
|
+
private var sessionActive = false
|
|
65
|
+
private var dismissReason: DismissReason = .programmatic
|
|
66
|
+
private var reconcileScheduled = false
|
|
67
|
+
private var isDropped = false
|
|
68
|
+
private var presentation: ModalPresenting?
|
|
69
|
+
private var lastContentArea: CGSize?
|
|
70
|
+
|
|
71
|
+
override init() {
|
|
72
|
+
super.init()
|
|
73
|
+
view.onWindowChange = { [weak self] in
|
|
74
|
+
self?.hostWindowDidChange()
|
|
75
|
+
}
|
|
76
|
+
view.contentView.onContentSizeChange = { [weak self] _ in
|
|
77
|
+
self?.presentation?.contentSizeDidChange()
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
deinit {
|
|
82
|
+
// The last reference can be dropped from the JS thread; UIKit work must
|
|
83
|
+
// happen on main. `controller` is captured strongly on purpose.
|
|
84
|
+
guard let controller = presentation else { return }
|
|
85
|
+
DispatchQueue.main.async {
|
|
86
|
+
controller.presentingViewController?.dismiss(animated: false)
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// MARK: HybridView
|
|
91
|
+
|
|
92
|
+
func afterUpdate() {
|
|
93
|
+
let next = makeConfig()
|
|
94
|
+
guard next != config else { return }
|
|
95
|
+
config = next
|
|
96
|
+
if let presentation {
|
|
97
|
+
presentation.apply(next)
|
|
98
|
+
} else {
|
|
99
|
+
reportEstimatedContentArea()
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
func onDropView() {
|
|
104
|
+
isDropped = true
|
|
105
|
+
wantsOpen = false
|
|
106
|
+
sessionActive = false
|
|
107
|
+
guard let controller = presentation else { return }
|
|
108
|
+
presentation = nil
|
|
109
|
+
phase = .idle
|
|
110
|
+
controller.presentingViewController?.dismiss(animated: false)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// MARK: Methods
|
|
114
|
+
|
|
115
|
+
func present() throws {
|
|
116
|
+
DispatchQueue.main.async { [weak self] in
|
|
117
|
+
self?.requestPresent()
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
func dismiss() throws {
|
|
122
|
+
DispatchQueue.main.async { [weak self] in
|
|
123
|
+
self?.requestDismiss(.programmatic)
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
func snapToDetent(index: Double) throws {
|
|
128
|
+
guard index.isFinite else { return }
|
|
129
|
+
DispatchQueue.main.async { [weak self] in
|
|
130
|
+
self?.presentation?.snapToDetent(at: Int(index))
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// MARK: State machine
|
|
135
|
+
|
|
136
|
+
private func requestPresent() {
|
|
137
|
+
guard !isDropped else { return }
|
|
138
|
+
wantsOpen = true
|
|
139
|
+
sessionActive = true
|
|
140
|
+
scheduleReconcile()
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
private func requestDismiss(_ reason: DismissReason) {
|
|
144
|
+
if wantsOpen {
|
|
145
|
+
dismissReason = reason
|
|
146
|
+
}
|
|
147
|
+
wantsOpen = false
|
|
148
|
+
scheduleReconcile()
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/// Deferred to the next run loop turn so the children and layout committed
|
|
152
|
+
/// in the same Fabric transaction are mounted before we present.
|
|
153
|
+
private func scheduleReconcile() {
|
|
154
|
+
guard !reconcileScheduled else { return }
|
|
155
|
+
reconcileScheduled = true
|
|
156
|
+
DispatchQueue.main.async { [weak self] in
|
|
157
|
+
guard let self else { return }
|
|
158
|
+
self.reconcileScheduled = false
|
|
159
|
+
self.reconcile()
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
private func reconcile() {
|
|
164
|
+
switch phase {
|
|
165
|
+
case .presenting, .dismissing:
|
|
166
|
+
return // The transition's completion reconciles again.
|
|
167
|
+
case .presented:
|
|
168
|
+
if !wantsOpen {
|
|
169
|
+
startDismiss()
|
|
170
|
+
}
|
|
171
|
+
case .idle:
|
|
172
|
+
if wantsOpen {
|
|
173
|
+
startPresent()
|
|
174
|
+
} else if sessionActive {
|
|
175
|
+
endSession() // Cancelled before anything appeared.
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
private func startPresent() {
|
|
181
|
+
// Without a window there is nothing to present from; `hostWindowDidChange` retries.
|
|
182
|
+
guard let presenter = topViewController() else { return }
|
|
183
|
+
|
|
184
|
+
let controller: ModalPresenting = config.mode == .popup
|
|
185
|
+
? PopupViewController(contentView: view.contentView, config: config)
|
|
186
|
+
: SheetViewController(contentView: view.contentView, config: config)
|
|
187
|
+
controller.presentationDelegate = self
|
|
188
|
+
presentation = controller
|
|
189
|
+
phase = .presenting
|
|
190
|
+
|
|
191
|
+
presenter.present(controller, animated: isAnimated) { [weak self, weak controller] in
|
|
192
|
+
guard let self, let controller, self.presentation === controller else { return }
|
|
193
|
+
self.phase = .presented
|
|
194
|
+
if !self.isDropped {
|
|
195
|
+
self.onPresent?()
|
|
196
|
+
}
|
|
197
|
+
UIAccessibility.post(notification: .screenChanged, argument: controller.view)
|
|
198
|
+
self.reconcile()
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if controller.presentingViewController == nil {
|
|
202
|
+
// UIKit refused (e.g. another transition is still running). Retry shortly.
|
|
203
|
+
presentation = nil
|
|
204
|
+
phase = .idle
|
|
205
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in
|
|
206
|
+
self?.reconcile()
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
private func startDismiss() {
|
|
212
|
+
guard let controller = presentation, let presenting = controller.presentingViewController else {
|
|
213
|
+
finishDismiss()
|
|
214
|
+
return
|
|
215
|
+
}
|
|
216
|
+
phase = .dismissing
|
|
217
|
+
controller.view.endEditing(true)
|
|
218
|
+
presenting.dismiss(animated: isAnimated) { [weak self] in
|
|
219
|
+
self?.finishDismiss()
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
private func finishDismiss() {
|
|
224
|
+
phase = .idle
|
|
225
|
+
presentation = nil
|
|
226
|
+
view.contentView.removeFromSuperview()
|
|
227
|
+
endSession()
|
|
228
|
+
reconcile()
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
private func endSession() {
|
|
232
|
+
guard sessionActive else { return }
|
|
233
|
+
let reason = dismissReason
|
|
234
|
+
dismissReason = .programmatic
|
|
235
|
+
// A present requested while we were dismissing starts a new session.
|
|
236
|
+
sessionActive = wantsOpen
|
|
237
|
+
if !isDropped {
|
|
238
|
+
onDismiss?(reason)
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// MARK: Helpers
|
|
243
|
+
|
|
244
|
+
private var isAnimated: Bool {
|
|
245
|
+
!(config.mode == .popup && config.popupAnimation == .none)
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
private func makeConfig() -> ModalConfig {
|
|
249
|
+
ModalConfig(
|
|
250
|
+
mode: mode,
|
|
251
|
+
detents: detents,
|
|
252
|
+
initialDetentIndex: initialDetentIndex.isFinite ? Int(initialDetentIndex) : 0,
|
|
253
|
+
backdropColor: UIColor(processedColor: backdropColor),
|
|
254
|
+
backdropOpacity: CGFloat(backdropOpacity),
|
|
255
|
+
backdropBlurRadius: CGFloat(backdropBlurRadius),
|
|
256
|
+
dismissOnBackdropPress: dismissOnBackdropPress,
|
|
257
|
+
dismissOnSwipe: dismissOnSwipe,
|
|
258
|
+
grabberVisible: grabberVisible,
|
|
259
|
+
cornerRadius: cornerRadius >= 0 ? CGFloat(cornerRadius) : nil,
|
|
260
|
+
contentBackgroundColor: contentBackgroundColor.map(UIColor.init(processedColor:)) ?? .systemBackground,
|
|
261
|
+
keyboardBehavior: keyboardBehavior,
|
|
262
|
+
popupAnimation: popupAnimation
|
|
263
|
+
)
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
private func topViewController() -> UIViewController? {
|
|
267
|
+
var top = view.window?.rootViewController
|
|
268
|
+
while let presented = top?.presentedViewController, !presented.isBeingDismissed {
|
|
269
|
+
top = presented
|
|
270
|
+
}
|
|
271
|
+
return top
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
private func hostWindowDidChange() {
|
|
275
|
+
guard view.window != nil else { return }
|
|
276
|
+
if presentation == nil {
|
|
277
|
+
reportEstimatedContentArea()
|
|
278
|
+
}
|
|
279
|
+
scheduleReconcile()
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/// Lets React size the content before the first presentation.
|
|
283
|
+
private func reportEstimatedContentArea() {
|
|
284
|
+
guard let window = view.window else { return }
|
|
285
|
+
switch config.mode {
|
|
286
|
+
case .popup:
|
|
287
|
+
reportContentArea(PopupViewController.availableRect(in: window.bounds, safeArea: window.safeAreaInsets).size)
|
|
288
|
+
case .bottomsheet:
|
|
289
|
+
let maximum = SheetViewController.estimatedMaximumDetentValue(in: window)
|
|
290
|
+
reportContentArea(CGSize(
|
|
291
|
+
width: SheetViewController.estimatedWidth(in: window),
|
|
292
|
+
height: SheetViewController.contentHeight(for: config.detents, maximum: maximum)
|
|
293
|
+
))
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
private func reportContentArea(_ size: CGSize) {
|
|
298
|
+
let area = CGSize(width: size.width.rounded(.down), height: size.height.rounded(.down))
|
|
299
|
+
guard area != lastContentArea, area.width > 0, area.height > 0 else { return }
|
|
300
|
+
lastContentArea = area
|
|
301
|
+
onContentAreaChange?(ModalContentArea(width: Double(area.width), height: Double(area.height)))
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// MARK: - ModalPresentationDelegate
|
|
306
|
+
|
|
307
|
+
extension HybridNitroModal: ModalPresentationDelegate {
|
|
308
|
+
func modalPresentationDidTapBackdrop() {
|
|
309
|
+
onBackdropPress?()
|
|
310
|
+
guard config.dismissOnBackdropPress, phase == .presenting || phase == .presented else { return }
|
|
311
|
+
requestDismiss(.backdrop)
|
|
312
|
+
reconcile()
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
func modalPresentationDidDismissInteractively() {
|
|
316
|
+
guard phase == .presented else { return }
|
|
317
|
+
wantsOpen = false
|
|
318
|
+
dismissReason = .swipe
|
|
319
|
+
finishDismiss()
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
func modalPresentationDidChangeDetent(_ index: Int) {
|
|
323
|
+
onDetentChange?(Double(index))
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
func modalPresentationDidChangeContentArea(_ size: CGSize) {
|
|
327
|
+
reportContentArea(size)
|
|
328
|
+
}
|
|
329
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
//
|
|
2
|
+
// HybridNitroModalComponent+Children.mm
|
|
3
|
+
// NitroModal
|
|
4
|
+
//
|
|
5
|
+
// Nitro's generated Fabric component view mounts React children as its own
|
|
6
|
+
// subviews. A modal needs them inside the presented view controller instead,
|
|
7
|
+
// so this category routes child (un)mounting to the Swift host view, and
|
|
8
|
+
// attaches a Fabric touch handler to the view that hosts them (it lives
|
|
9
|
+
// outside the React root view, so the root's touch handler never sees it).
|
|
10
|
+
//
|
|
11
|
+
// This mirrors what React Native's own RCTModalHostViewComponentView does.
|
|
12
|
+
//
|
|
13
|
+
|
|
14
|
+
#import <UIKit/UIKit.h>
|
|
15
|
+
#import <React/RCTSurfaceTouchHandler.h>
|
|
16
|
+
#import <React/RCTViewComponentView.h>
|
|
17
|
+
|
|
18
|
+
/// Implemented in Swift by `NitroModalHostView`. The pod does not emit an
|
|
19
|
+
/// ObjC header for its Swift code, so the methods are reached by selector.
|
|
20
|
+
@protocol NitroModalChildHost <NSObject>
|
|
21
|
+
- (void)nitroModalMountChild:(UIView *)child atIndex:(NSInteger)index;
|
|
22
|
+
- (void)nitroModalUnmountChild:(UIView *)child;
|
|
23
|
+
@property (nonatomic, readonly) UIView *nitroModalTouchRoot;
|
|
24
|
+
@end
|
|
25
|
+
|
|
26
|
+
/// Defined by nitrogen in HybridNitroModalComponent.mm.
|
|
27
|
+
@interface HybridNitroModalComponent : RCTViewComponentView
|
|
28
|
+
@end
|
|
29
|
+
|
|
30
|
+
@implementation HybridNitroModalComponent (NitroModalChildren)
|
|
31
|
+
|
|
32
|
+
- (nullable id<NitroModalChildHost>)nitroModal_childHost
|
|
33
|
+
{
|
|
34
|
+
UIView *host = self.contentView;
|
|
35
|
+
if ([host respondsToSelector:@selector(nitroModalMountChild:atIndex:)] &&
|
|
36
|
+
[host respondsToSelector:@selector(nitroModalUnmountChild:)] &&
|
|
37
|
+
[host respondsToSelector:@selector(nitroModalTouchRoot)]) {
|
|
38
|
+
return (id<NitroModalChildHost>)host;
|
|
39
|
+
}
|
|
40
|
+
return nil;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
- (void)mountChildComponentView:(UIView<RCTComponentViewProtocol> *)childComponentView index:(NSInteger)index
|
|
44
|
+
{
|
|
45
|
+
id<NitroModalChildHost> host = [self nitroModal_childHost];
|
|
46
|
+
if (host == nil) {
|
|
47
|
+
[super mountChildComponentView:childComponentView index:index];
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
UIView *touchRoot = host.nitroModalTouchRoot;
|
|
52
|
+
BOOL hasTouchHandler = NO;
|
|
53
|
+
for (UIGestureRecognizer *recognizer in touchRoot.gestureRecognizers) {
|
|
54
|
+
if ([recognizer isKindOfClass:[RCTSurfaceTouchHandler class]]) {
|
|
55
|
+
hasTouchHandler = YES;
|
|
56
|
+
break;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
if (!hasTouchHandler) {
|
|
60
|
+
// The view retains the recognizer; it goes away together with the host.
|
|
61
|
+
[[RCTSurfaceTouchHandler new] attachToView:touchRoot];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
[host nitroModalMountChild:childComponentView atIndex:index];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
- (void)unmountChildComponentView:(UIView<RCTComponentViewProtocol> *)childComponentView index:(NSInteger)index
|
|
68
|
+
{
|
|
69
|
+
id<NitroModalChildHost> host = [self nitroModal_childHost];
|
|
70
|
+
if (host == nil) {
|
|
71
|
+
[super unmountChildComponentView:childComponentView index:index];
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
[host nitroModalUnmountChild:childComponentView];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
@end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
//
|
|
2
|
+
// ModalConfig.swift
|
|
3
|
+
// NitroModal
|
|
4
|
+
//
|
|
5
|
+
|
|
6
|
+
import UIKit
|
|
7
|
+
|
|
8
|
+
/// Snapshot of the React props that shape a presentation.
|
|
9
|
+
struct ModalConfig: Equatable {
|
|
10
|
+
var mode: ModalMode = .bottomsheet
|
|
11
|
+
var detents: [SheetDetent] = [.fitcontent]
|
|
12
|
+
var initialDetentIndex = 0
|
|
13
|
+
var backdropColor: UIColor = .black
|
|
14
|
+
var backdropOpacity: CGFloat = 0.4
|
|
15
|
+
var backdropBlurRadius: CGFloat = 0
|
|
16
|
+
var dismissOnBackdropPress = true
|
|
17
|
+
var dismissOnSwipe = true
|
|
18
|
+
var grabberVisible = false
|
|
19
|
+
/// `nil` uses the platform default.
|
|
20
|
+
var cornerRadius: CGFloat?
|
|
21
|
+
var contentBackgroundColor: UIColor = .systemBackground
|
|
22
|
+
var keyboardBehavior: KeyboardBehavior = .pan
|
|
23
|
+
var popupAnimation: PopupAnimation = .scale
|
|
24
|
+
|
|
25
|
+
var clampedInitialDetentIndex: Int {
|
|
26
|
+
min(max(initialDetentIndex, 0), max(detents.count - 1, 0))
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/// Callbacks from a presented modal controller back to `HybridNitroModal`.
|
|
31
|
+
protocol ModalPresentationDelegate: AnyObject {
|
|
32
|
+
func modalPresentationDidTapBackdrop()
|
|
33
|
+
/// The user swiped the sheet away (the dismissal already happened).
|
|
34
|
+
func modalPresentationDidDismissInteractively()
|
|
35
|
+
func modalPresentationDidChangeDetent(_ index: Int)
|
|
36
|
+
func modalPresentationDidChangeContentArea(_ size: CGSize)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/// A view controller that presents the React content in one of the modal modes.
|
|
40
|
+
protocol ModalPresenting: UIViewController {
|
|
41
|
+
var presentationDelegate: ModalPresentationDelegate? { get set }
|
|
42
|
+
func apply(_ config: ModalConfig)
|
|
43
|
+
func contentSizeDidChange()
|
|
44
|
+
func snapToDetent(at index: Int)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
extension UIColor {
|
|
48
|
+
/// Decodes a color produced by React Native's `processColor` (0xAARRGGBB).
|
|
49
|
+
convenience init(processedColor value: Double) {
|
|
50
|
+
let argb = UInt32(truncatingIfNeeded: Int64(value))
|
|
51
|
+
self.init(
|
|
52
|
+
red: CGFloat((argb >> 16) & 0xFF) / 255,
|
|
53
|
+
green: CGFloat((argb >> 8) & 0xFF) / 255,
|
|
54
|
+
blue: CGFloat(argb & 0xFF) / 255,
|
|
55
|
+
alpha: CGFloat((argb >> 24) & 0xFF) / 255
|
|
56
|
+
)
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
extension Notification {
|
|
61
|
+
/// Runs `animations` with the keyboard's own duration and curve so layout
|
|
62
|
+
/// changes move in lockstep with the keyboard.
|
|
63
|
+
func animateAlongsideKeyboard(_ animations: @escaping () -> Void) {
|
|
64
|
+
let duration = (userInfo?[UIResponder.keyboardAnimationDurationUserInfoKey] as? Double) ?? 0.25
|
|
65
|
+
let curve = (userInfo?[UIResponder.keyboardAnimationCurveUserInfoKey] as? UInt) ?? 7
|
|
66
|
+
UIView.animate(
|
|
67
|
+
withDuration: duration,
|
|
68
|
+
delay: 0,
|
|
69
|
+
options: [UIView.AnimationOptions(rawValue: curve << 16), .beginFromCurrentState, .allowUserInteraction],
|
|
70
|
+
animations: animations
|
|
71
|
+
)
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
//
|
|
2
|
+
// NitroModalHostView.swift
|
|
3
|
+
// NitroModal
|
|
4
|
+
//
|
|
5
|
+
|
|
6
|
+
import UIKit
|
|
7
|
+
|
|
8
|
+
/// The view Nitro mounts into the React tree. It is an invisible placeholder:
|
|
9
|
+
/// React children mounted into it (see `HybridNitroModalComponent+Children.mm`)
|
|
10
|
+
/// are hosted by `contentView`, which travels into the presented controller.
|
|
11
|
+
final class NitroModalHostView: UIView {
|
|
12
|
+
let contentView = NitroModalContentView()
|
|
13
|
+
var onWindowChange: (() -> Void)?
|
|
14
|
+
|
|
15
|
+
override init(frame: CGRect) {
|
|
16
|
+
super.init(frame: frame)
|
|
17
|
+
isUserInteractionEnabled = false
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
@available(*, unavailable)
|
|
21
|
+
required init?(coder: NSCoder) {
|
|
22
|
+
fatalError("init(coder:) is not supported")
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
override func didMoveToWindow() {
|
|
26
|
+
super.didMoveToWindow()
|
|
27
|
+
onWindowChange?()
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
@objc(nitroModalMountChild:atIndex:)
|
|
31
|
+
func mountChild(_ child: UIView, atIndex index: Int) {
|
|
32
|
+
contentView.insertReactChild(child, at: index)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
@objc(nitroModalUnmountChild:)
|
|
36
|
+
func unmountChild(_ child: UIView) {
|
|
37
|
+
contentView.removeReactChild(child)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
@objc var nitroModalTouchRoot: UIView {
|
|
41
|
+
contentView
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/// Holds the React children inside the presented modal and reports the size
|
|
46
|
+
/// they occupy, which drives the `fitContent` detent and the popup card size.
|
|
47
|
+
final class NitroModalContentView: UIView {
|
|
48
|
+
var onContentSizeChange: ((CGSize) -> Void)?
|
|
49
|
+
|
|
50
|
+
private var reactChildren: [UIView] = []
|
|
51
|
+
private var observations: [ObjectIdentifier: [NSKeyValueObservation]] = [:]
|
|
52
|
+
private var lastReportedSize: CGSize = .zero
|
|
53
|
+
|
|
54
|
+
/// Bounding size of the React children. Fabric positions them with
|
|
55
|
+
/// `center`/`bounds`, so this stays correct even with transforms applied.
|
|
56
|
+
var contentSize: CGSize {
|
|
57
|
+
reactChildren.reduce(.zero) { size, child in
|
|
58
|
+
CGSize(
|
|
59
|
+
width: max(size.width, child.center.x + child.bounds.width / 2),
|
|
60
|
+
height: max(size.height, child.center.y + child.bounds.height / 2)
|
|
61
|
+
)
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
func insertReactChild(_ child: UIView, at index: Int) {
|
|
66
|
+
let index = min(max(index, 0), reactChildren.count)
|
|
67
|
+
reactChildren.insert(child, at: index)
|
|
68
|
+
insertSubview(child, at: index)
|
|
69
|
+
// Fabric writes `center` then `bounds`; coalesce both into one layout pass.
|
|
70
|
+
observations[ObjectIdentifier(child)] = [
|
|
71
|
+
child.layer.observe(\.bounds) { [weak self] _, _ in self?.setNeedsLayout() },
|
|
72
|
+
child.layer.observe(\.position) { [weak self] _, _ in self?.setNeedsLayout() },
|
|
73
|
+
]
|
|
74
|
+
setNeedsLayout()
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
func removeReactChild(_ child: UIView) {
|
|
78
|
+
observations.removeValue(forKey: ObjectIdentifier(child))?.forEach { $0.invalidate() }
|
|
79
|
+
reactChildren.removeAll { $0 === child }
|
|
80
|
+
child.removeFromSuperview()
|
|
81
|
+
setNeedsLayout()
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
override func layoutSubviews() {
|
|
85
|
+
super.layoutSubviews()
|
|
86
|
+
let size = contentSize
|
|
87
|
+
guard size != lastReportedSize else { return }
|
|
88
|
+
lastReportedSize = size
|
|
89
|
+
onContentSizeChange?(size)
|
|
90
|
+
}
|
|
91
|
+
}
|