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.
Files changed (117) hide show
  1. package/LICENSE +20 -0
  2. package/NitroModal.podspec +29 -0
  3. package/README.md +39 -0
  4. package/android/CMakeLists.txt +24 -0
  5. package/android/build.gradle +124 -0
  6. package/android/src/main/AndroidManifest.xml +2 -0
  7. package/android/src/main/cpp/cpp-adapter.cpp +11 -0
  8. package/android/src/main/java/com/margelo/nitro/nitromodal/BottomSheetPresenter.kt +284 -0
  9. package/android/src/main/java/com/margelo/nitro/nitromodal/HybridNitroModal.kt +312 -0
  10. package/android/src/main/java/com/margelo/nitro/nitromodal/ModalConfig.kt +50 -0
  11. package/android/src/main/java/com/margelo/nitro/nitromodal/ModalContentRoot.kt +115 -0
  12. package/android/src/main/java/com/margelo/nitro/nitromodal/ModalPresenter.kt +253 -0
  13. package/android/src/main/java/com/margelo/nitro/nitromodal/NitroModalHostView.kt +52 -0
  14. package/android/src/main/java/com/margelo/nitro/nitromodal/NitroModalPackage.kt +28 -0
  15. package/android/src/main/java/com/margelo/nitro/nitromodal/NitroModalViewManager.kt +67 -0
  16. package/android/src/main/java/com/margelo/nitro/nitromodal/PopupPresenter.kt +156 -0
  17. package/android/src/main/res/values/styles.xml +17 -0
  18. package/ios/BackdropView.swift +64 -0
  19. package/ios/HybridNitroModal.swift +329 -0
  20. package/ios/HybridNitroModalComponent+Children.mm +77 -0
  21. package/ios/ModalConfig.swift +73 -0
  22. package/ios/NitroModalHostView.swift +91 -0
  23. package/ios/PopupViewController.swift +223 -0
  24. package/ios/SheetViewController.swift +244 -0
  25. package/lib/module/NitroModal.js +188 -0
  26. package/lib/module/NitroModal.js.map +1 -0
  27. package/lib/module/NitroModal.nitro.js +4 -0
  28. package/lib/module/NitroModal.nitro.js.map +1 -0
  29. package/lib/module/NitroModalView.js +17 -0
  30. package/lib/module/NitroModalView.js.map +1 -0
  31. package/lib/module/NitroModalView.native.js +6 -0
  32. package/lib/module/NitroModalView.native.js.map +1 -0
  33. package/lib/module/index.js +4 -0
  34. package/lib/module/index.js.map +1 -0
  35. package/lib/module/package.json +1 -0
  36. package/lib/typescript/package.json +1 -0
  37. package/lib/typescript/src/NitroModal.d.ts +59 -0
  38. package/lib/typescript/src/NitroModal.d.ts.map +1 -0
  39. package/lib/typescript/src/NitroModal.nitro.d.ts +78 -0
  40. package/lib/typescript/src/NitroModal.nitro.d.ts.map +1 -0
  41. package/lib/typescript/src/NitroModalView.d.ts +4 -0
  42. package/lib/typescript/src/NitroModalView.d.ts.map +1 -0
  43. package/lib/typescript/src/NitroModalView.native.d.ts +3 -0
  44. package/lib/typescript/src/NitroModalView.native.d.ts.map +1 -0
  45. package/lib/typescript/src/index.d.ts +3 -0
  46. package/lib/typescript/src/index.d.ts.map +1 -0
  47. package/nitro.json +23 -0
  48. package/nitrogen/generated/android/c++/JDismissReason.hpp +64 -0
  49. package/nitrogen/generated/android/c++/JFunc_void.hpp +75 -0
  50. package/nitrogen/generated/android/c++/JFunc_void_DismissReason.hpp +77 -0
  51. package/nitrogen/generated/android/c++/JFunc_void_ModalContentArea.hpp +77 -0
  52. package/nitrogen/generated/android/c++/JFunc_void_double.hpp +75 -0
  53. package/nitrogen/generated/android/c++/JHybridNitroModalSpec.cpp +344 -0
  54. package/nitrogen/generated/android/c++/JHybridNitroModalSpec.hpp +106 -0
  55. package/nitrogen/generated/android/c++/JKeyboardBehavior.hpp +61 -0
  56. package/nitrogen/generated/android/c++/JModalContentArea.hpp +61 -0
  57. package/nitrogen/generated/android/c++/JModalMode.hpp +58 -0
  58. package/nitrogen/generated/android/c++/JPopupAnimation.hpp +61 -0
  59. package/nitrogen/generated/android/c++/JSheetDetent.hpp +64 -0
  60. package/nitrogen/generated/android/c++/views/JHybridNitroModalStateUpdater.cpp +174 -0
  61. package/nitrogen/generated/android/c++/views/JHybridNitroModalStateUpdater.hpp +54 -0
  62. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitromodal/DismissReason.kt +25 -0
  63. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitromodal/Func_void.kt +78 -0
  64. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitromodal/Func_void_DismissReason.kt +78 -0
  65. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitromodal/Func_void_ModalContentArea.kt +78 -0
  66. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitromodal/Func_void_double.kt +78 -0
  67. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitromodal/HybridNitroModalSpec.kt +237 -0
  68. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitromodal/KeyboardBehavior.kt +24 -0
  69. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitromodal/ModalContentArea.kt +56 -0
  70. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitromodal/ModalMode.kt +23 -0
  71. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitromodal/PopupAnimation.kt +24 -0
  72. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitromodal/SheetDetent.kt +25 -0
  73. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitromodal/nitromodalOnLoad.kt +35 -0
  74. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitromodal/views/HybridNitroModalManager.kt +96 -0
  75. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitromodal/views/HybridNitroModalStateUpdater.kt +23 -0
  76. package/nitrogen/generated/android/nitromodal+autolinking.cmake +83 -0
  77. package/nitrogen/generated/android/nitromodal+autolinking.gradle +27 -0
  78. package/nitrogen/generated/android/nitromodalOnLoad.cpp +64 -0
  79. package/nitrogen/generated/android/nitromodalOnLoad.hpp +34 -0
  80. package/nitrogen/generated/ios/NitroModal+autolinking.rb +62 -0
  81. package/nitrogen/generated/ios/NitroModal-Swift-Cxx-Bridge.cpp +65 -0
  82. package/nitrogen/generated/ios/NitroModal-Swift-Cxx-Bridge.hpp +237 -0
  83. package/nitrogen/generated/ios/NitroModal-Swift-Cxx-Umbrella.hpp +64 -0
  84. package/nitrogen/generated/ios/NitroModalAutolinking.mm +33 -0
  85. package/nitrogen/generated/ios/NitroModalAutolinking.swift +26 -0
  86. package/nitrogen/generated/ios/c++/HybridNitroModalSpecSwift.cpp +11 -0
  87. package/nitrogen/generated/ios/c++/HybridNitroModalSpecSwift.hpp +247 -0
  88. package/nitrogen/generated/ios/c++/views/HybridNitroModalComponent.mm +264 -0
  89. package/nitrogen/generated/ios/swift/DismissReason.swift +48 -0
  90. package/nitrogen/generated/ios/swift/Func_void.swift +46 -0
  91. package/nitrogen/generated/ios/swift/Func_void_DismissReason.swift +46 -0
  92. package/nitrogen/generated/ios/swift/Func_void_ModalContentArea.swift +46 -0
  93. package/nitrogen/generated/ios/swift/Func_void_double.swift +46 -0
  94. package/nitrogen/generated/ios/swift/HybridNitroModalSpec.swift +77 -0
  95. package/nitrogen/generated/ios/swift/HybridNitroModalSpec_cxx.swift +554 -0
  96. package/nitrogen/generated/ios/swift/KeyboardBehavior.swift +44 -0
  97. package/nitrogen/generated/ios/swift/ModalContentArea.swift +34 -0
  98. package/nitrogen/generated/ios/swift/ModalMode.swift +40 -0
  99. package/nitrogen/generated/ios/swift/PopupAnimation.swift +44 -0
  100. package/nitrogen/generated/ios/swift/SheetDetent.swift +48 -0
  101. package/nitrogen/generated/shared/c++/DismissReason.hpp +84 -0
  102. package/nitrogen/generated/shared/c++/HybridNitroModalSpec.cpp +65 -0
  103. package/nitrogen/generated/shared/c++/HybridNitroModalSpec.hpp +124 -0
  104. package/nitrogen/generated/shared/c++/KeyboardBehavior.hpp +80 -0
  105. package/nitrogen/generated/shared/c++/ModalContentArea.hpp +87 -0
  106. package/nitrogen/generated/shared/c++/ModalMode.hpp +76 -0
  107. package/nitrogen/generated/shared/c++/PopupAnimation.hpp +80 -0
  108. package/nitrogen/generated/shared/c++/SheetDetent.hpp +84 -0
  109. package/nitrogen/generated/shared/c++/views/HybridNitroModalComponent.cpp +74 -0
  110. package/nitrogen/generated/shared/c++/views/HybridNitroModalComponent.hpp +151 -0
  111. package/nitrogen/generated/shared/json/NitroModalConfig.json +30 -0
  112. package/package.json +183 -0
  113. package/src/NitroModal.nitro.ts +92 -0
  114. package/src/NitroModal.tsx +292 -0
  115. package/src/NitroModalView.native.tsx +8 -0
  116. package/src/NitroModalView.tsx +24 -0
  117. 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
+ }