concentric-sheet 0.0.2

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 (45) hide show
  1. package/LICENSE +21 -0
  2. package/NitroConcentricSheet.podspec +31 -0
  3. package/README.md +118 -0
  4. package/ios/Bridge.h +8 -0
  5. package/ios/SheetModalController.swift +912 -0
  6. package/lib/NativeSheetModal.d.ts +10 -0
  7. package/lib/NativeSheetModal.js +243 -0
  8. package/lib/index.d.ts +3 -0
  9. package/lib/index.js +1 -0
  10. package/lib/specs/SheetModalController.nitro.d.ts +46 -0
  11. package/lib/specs/SheetModalController.nitro.js +1 -0
  12. package/nitro.json +23 -0
  13. package/nitrogen/generated/.gitattributes +1 -0
  14. package/nitrogen/generated/ios/NitroConcentricSheet+autolinking.rb +60 -0
  15. package/nitrogen/generated/ios/NitroConcentricSheet-Swift-Cxx-Bridge.cpp +33 -0
  16. package/nitrogen/generated/ios/NitroConcentricSheet-Swift-Cxx-Bridge.hpp +269 -0
  17. package/nitrogen/generated/ios/NitroConcentricSheet-Swift-Cxx-Umbrella.hpp +69 -0
  18. package/nitrogen/generated/ios/NitroConcentricSheetAutolinking.mm +33 -0
  19. package/nitrogen/generated/ios/NitroConcentricSheetAutolinking.swift +26 -0
  20. package/nitrogen/generated/ios/c++/HybridSheetModalControllerSpecSwift.cpp +11 -0
  21. package/nitrogen/generated/ios/c++/HybridSheetModalControllerSpecSwift.hpp +130 -0
  22. package/nitrogen/generated/ios/swift/HybridSheetModalControllerSpec.swift +58 -0
  23. package/nitrogen/generated/ios/swift/HybridSheetModalControllerSpec_cxx.swift +181 -0
  24. package/nitrogen/generated/ios/swift/ModalCornerConfiguration.swift +83 -0
  25. package/nitrogen/generated/ios/swift/ModalCornerConfigurationType.swift +48 -0
  26. package/nitrogen/generated/ios/swift/ModalViewBackground.swift +40 -0
  27. package/nitrogen/generated/ios/swift/PresentedModalConfig.swift +129 -0
  28. package/nitrogen/generated/ios/swift/PresentedModalDetent.swift +71 -0
  29. package/nitrogen/generated/ios/swift/SheetDetentIdentifier.swift +40 -0
  30. package/nitrogen/generated/ios/swift/SheetPresentationConfig.swift +238 -0
  31. package/nitrogen/generated/ios/swift/Variant_NullType_PresentedModalDetent.swift +18 -0
  32. package/nitrogen/generated/shared/c++/HybridSheetModalControllerSpec.cpp +24 -0
  33. package/nitrogen/generated/shared/c++/HybridSheetModalControllerSpec.hpp +71 -0
  34. package/nitrogen/generated/shared/c++/ModalCornerConfiguration.hpp +97 -0
  35. package/nitrogen/generated/shared/c++/ModalCornerConfigurationType.hpp +84 -0
  36. package/nitrogen/generated/shared/c++/ModalViewBackground.hpp +76 -0
  37. package/nitrogen/generated/shared/c++/PresentedModalConfig.hpp +115 -0
  38. package/nitrogen/generated/shared/c++/PresentedModalDetent.hpp +94 -0
  39. package/nitrogen/generated/shared/c++/SheetDetentIdentifier.hpp +76 -0
  40. package/nitrogen/generated/shared/c++/SheetPresentationConfig.hpp +130 -0
  41. package/package.json +103 -0
  42. package/react-native.config.js +13 -0
  43. package/src/NativeSheetModal.tsx +343 -0
  44. package/src/index.ts +16 -0
  45. package/src/specs/SheetModalController.nitro.ts +54 -0
@@ -0,0 +1,912 @@
1
+ import Foundation
2
+ import ObjectiveC.runtime
3
+ import UIKit
4
+
5
+ public final class SheetModalController: HybridSheetModalControllerSpec {
6
+
7
+ private static var didInstallModalHostHooks = false
8
+ private static weak var sharedInstance: SheetModalController?
9
+ private static var cachedPresentedModalConfigs: [PresentedModalConfig] = []
10
+ private static var modalHostViewWillAppearSwizzleKey: UInt8 = 0
11
+ private static var viewControllerPresentSwizzleKey: UInt8 = 0
12
+ private static var modalHostInstanceIdKey: UInt8 = 0
13
+
14
+ public override init() {
15
+ super.init()
16
+ Self.sharedInstance = self
17
+ // JS reload can recreate this controller while static state remains in-process.
18
+ // Drop stale config entries so fresh modal mounts don't consume old configs.
19
+ Self.cachedPresentedModalConfigs.removeAll()
20
+ Self.installModalHostHooksIfNeeded()
21
+ }
22
+
23
+ public func cachePresentedModalConfig(config: PresentedModalConfig) throws -> Bool {
24
+ return try onMainThread {
25
+ Self.cachedPresentedModalConfigs.append(config)
26
+ Self.debugLog(
27
+ "cachePresentedModalConfig queued config=\(Self.describe(config)) queueCount=\(Self.cachedPresentedModalConfigs.count)"
28
+ )
29
+ return true
30
+ }
31
+ }
32
+
33
+ public func applyPresentedModalConfig(config: PresentedModalConfig) throws -> Bool {
34
+ return try onMainThread {
35
+ let modalInstanceId = Self.modalInstanceId(from: config)
36
+ guard let controller = targetPresentedViewController(forModalInstanceId: modalInstanceId)
37
+ else {
38
+ Self.debugLog(
39
+ "applyPresentedModalConfig no target controller modalInstanceId=\(modalInstanceId.map(String.init) ?? "nil") config=\(Self.describe(config))"
40
+ )
41
+ return false
42
+ }
43
+
44
+ let didApply = applyConfig(config, to: controller)
45
+ Self.disableBackgroundModalHostInterceptionIfNeeded()
46
+ Self.debugLog(
47
+ "applyPresentedModalConfig controller=\(Self.describe(controller)) modalInstanceId=\(modalInstanceId.map(String.init) ?? "nil") didApply=\(didApply) config=\(Self.describe(config))"
48
+ )
49
+ return didApply
50
+ }
51
+ }
52
+
53
+ public func getPresentedModalDetent(modalInstanceId: Double) throws -> Variant_NullType_PresentedModalDetent {
54
+ return try onMainThread {
55
+ guard #available(iOS 15.0, *) else {
56
+ return .first(.null)
57
+ }
58
+
59
+ guard
60
+ let controller = targetPresentedViewController(
61
+ forModalInstanceId: Int(modalInstanceId)
62
+ ),
63
+ let sheet = controller.sheetPresentationController,
64
+ let selectedDetentIdentifier = sheet.selectedDetentIdentifier
65
+ else {
66
+ return .first(.null)
67
+ }
68
+
69
+ let rawDetentIdentifier = selectedDetentIdentifier.rawValue
70
+ let detentIdentifier: SheetDetentIdentifier?
71
+ switch selectedDetentIdentifier {
72
+ case .medium:
73
+ detentIdentifier = .medium
74
+ case .large:
75
+ detentIdentifier = .large
76
+ default:
77
+ detentIdentifier = nil
78
+ }
79
+
80
+ let customDetentHeight = Self.customDetentHeight(from: rawDetentIdentifier)
81
+ return .second(PresentedModalDetent(
82
+ detentIdentifier: detentIdentifier,
83
+ customDetentHeight: customDetentHeight,
84
+ rawDetentIdentifier: rawDetentIdentifier
85
+ ))
86
+ }
87
+ }
88
+
89
+ public func dismissPresentedModal(animated: Bool) throws -> Bool {
90
+ return try onMainThread {
91
+ guard let topController = topPresentedViewController(),
92
+ topController.presentingViewController != nil
93
+ else {
94
+ Self.debugLog("dismissPresentedModal no presented controller")
95
+ return false
96
+ }
97
+
98
+ Self.debugLog("dismissPresentedModal controller=\(Self.describe(topController)) animated=\(animated)")
99
+ topController.dismiss(animated: animated)
100
+ return true
101
+ }
102
+ }
103
+
104
+ private func applyConfig(_ config: PresentedModalConfig, to controller: UIViewController) -> Bool {
105
+ var didApply = false
106
+ if let isModalInPresentation = config.isModalInPresentation {
107
+ controller.isModalInPresentation = isModalInPresentation
108
+ didApply = true
109
+ }
110
+ if config.preferredContentWidth != nil || config.preferredContentHeight != nil {
111
+ var preferredContentSize = controller.preferredContentSize
112
+ if let width = config.preferredContentWidth {
113
+ preferredContentSize.width = CGFloat(width)
114
+ }
115
+ if let height = config.preferredContentHeight {
116
+ preferredContentSize.height = CGFloat(height)
117
+ }
118
+ controller.preferredContentSize = preferredContentSize
119
+ didApply = true
120
+ }
121
+ if let modalViewBackground = config.modalViewBackground {
122
+ applyModalViewBackground(modalViewBackground, to: controller)
123
+ didApply = true
124
+ }
125
+ if let cornerConfiguration = config.cornerConfiguration {
126
+ if #available(iOS 26.0, *) {
127
+ let didFullyApply = applyCornerConfiguration(cornerConfiguration, to: controller)
128
+ didApply = didApply || didFullyApply
129
+ } else {
130
+ didApply = true
131
+ }
132
+ }
133
+
134
+ if let sheetConfig = config.sheet {
135
+ if #available(iOS 15.0, *), let sheet = controller.sheetPresentationController {
136
+ applySheetConfig(sheetConfig, to: sheet)
137
+ if #available(iOS 26.0, *),
138
+ let cornerConfiguration = config.cornerConfiguration
139
+ {
140
+ sheet.preferredCornerRadius = derivedSheetCornerRadius(from: cornerConfiguration)
141
+ } else if sheetConfig.preferredCornerRadius == nil {
142
+ sheet.preferredCornerRadius = nil
143
+ }
144
+ didApply = true
145
+
146
+ // wantsFullScreen and child modal presentations can cause UIKit to
147
+ // force isModalInPresentation=true on this controller. Re-apply the
148
+ // caller's explicit intent after all sheet config is applied.
149
+ let resolvedIsModalInPresentation = config.isModalInPresentation ?? false
150
+ let beforeFix = controller.isModalInPresentation
151
+ if sheetConfig.wantsFullScreen == true {
152
+ controller.isModalInPresentation = resolvedIsModalInPresentation
153
+ }
154
+ let afterFix = controller.isModalInPresentation
155
+
156
+ #if DEBUG
157
+ let instanceId = Self.modalInstanceId(from: config).map(String.init) ?? "?"
158
+ let isModalInPres = controller.isModalInPresentation
159
+ Self.debugLog(
160
+ "wantsFullScreen fix id=\(instanceId) wantsFS=\(sheetConfig.wantsFullScreen as Any) before=\(beforeFix) after=\(afterFix) configValue=\(config.isModalInPresentation as Any)"
161
+ )
162
+ Self.debugLog(
163
+ "sheetState id=\(instanceId) isModalInPresentation=\(isModalInPres) detentCount=\(sheet.detents.count) prefersGrabber=\(sheet.prefersGrabberVisible) presentingVC=\(controller.presentingViewController.map { NSStringFromClass(type(of: $0)) } ?? "nil")"
164
+ )
165
+ #endif
166
+ } else {
167
+ return false
168
+ }
169
+ }
170
+
171
+ return didApply || config.sheet == nil
172
+ }
173
+
174
+ @available(iOS 15.0, *)
175
+ private func applySheetConfig(
176
+ _ config: SheetPresentationConfig,
177
+ to sheet: UISheetPresentationController
178
+ ) {
179
+ var resolvedDetents: [UISheetPresentationController.Detent] = []
180
+
181
+ if let detents = config.detents, !detents.isEmpty {
182
+ resolvedDetents.append(contentsOf: detents.map(detent(for:)))
183
+ }
184
+ if #available(iOS 16.0, *),
185
+ let customDetentHeights = config.customDetentHeights,
186
+ !customDetentHeights.isEmpty
187
+ {
188
+ for height in customDetentHeights {
189
+ let clampedHeight = max(0, height)
190
+ guard clampedHeight > 0 else { continue }
191
+ let customIdentifier = customDetentIdentifier(forHeight: clampedHeight)
192
+ let customDetent = UISheetPresentationController.Detent.custom(
193
+ identifier: customIdentifier
194
+ ) { _ in
195
+ return CGFloat(clampedHeight)
196
+ }
197
+ resolvedDetents.append(customDetent)
198
+ }
199
+ }
200
+ if !resolvedDetents.isEmpty {
201
+ sheet.detents = resolvedDetents
202
+ }
203
+
204
+ if #available(iOS 16.0, *),
205
+ let selectedCustomDetentHeight = config.selectedCustomDetentHeight,
206
+ selectedCustomDetentHeight > 0
207
+ {
208
+ sheet.selectedDetentIdentifier = customDetentIdentifier(forHeight: selectedCustomDetentHeight)
209
+ } else if let selectedDetentIdentifier = config.selectedDetentIdentifier {
210
+ sheet.selectedDetentIdentifier = detentIdentifier(for: selectedDetentIdentifier)
211
+ }
212
+
213
+ if #available(iOS 16.0, *),
214
+ let largestUndimmedCustomDetentHeight = config.largestUndimmedCustomDetentHeight,
215
+ largestUndimmedCustomDetentHeight > 0
216
+ {
217
+ sheet.largestUndimmedDetentIdentifier = customDetentIdentifier(
218
+ forHeight: largestUndimmedCustomDetentHeight
219
+ )
220
+ } else if let largestUndimmedDetentIdentifier = config.largestUndimmedDetentIdentifier {
221
+ sheet.largestUndimmedDetentIdentifier = detentIdentifier(for: largestUndimmedDetentIdentifier)
222
+ }
223
+ if let prefersGrabberVisible = config.prefersGrabberVisible {
224
+ sheet.prefersGrabberVisible = prefersGrabberVisible
225
+ }
226
+ if let preferredCornerRadius = config.preferredCornerRadius {
227
+ sheet.preferredCornerRadius = CGFloat(preferredCornerRadius)
228
+ }
229
+ if let prefersScrollingExpandsWhenScrolledToEdge =
230
+ config.prefersScrollingExpandsWhenScrolledToEdge
231
+ {
232
+ sheet.prefersScrollingExpandsWhenScrolledToEdge = prefersScrollingExpandsWhenScrolledToEdge
233
+ }
234
+ if let prefersEdgeAttachedInCompactHeight = config.prefersEdgeAttachedInCompactHeight {
235
+ sheet.prefersEdgeAttachedInCompactHeight = prefersEdgeAttachedInCompactHeight
236
+ }
237
+ if let widthFollowsPreferredContentSizeWhenEdgeAttached =
238
+ config.widthFollowsPreferredContentSizeWhenEdgeAttached
239
+ {
240
+ sheet.widthFollowsPreferredContentSizeWhenEdgeAttached =
241
+ widthFollowsPreferredContentSizeWhenEdgeAttached
242
+ }
243
+ if let wantsFullScreen = config.wantsFullScreen {
244
+ let dyn = Dynamic(sheet)
245
+ dyn.wantsFullScreen = wantsFullScreen
246
+ dyn.allowsInteractiveDismissWhenFullScreen = wantsFullScreen
247
+
248
+ if let prefersGrabberVisible = config.prefersGrabberVisible, prefersGrabberVisible {
249
+ // UIKit's internal _grabberAlpha fighting is unreliable in wantsFullScreen mode.
250
+ // Add our own overlay grabber pill directly to the drop shadow view instead.
251
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak sheet] in
252
+ guard let sheet = sheet else { return }
253
+ guard let dropShadowView = (sheet as NSObject).value(forKey: "dropShadowView") as? UIView else { return }
254
+
255
+ // Remove any previously attached overlay grabber before adding a new one.
256
+ Self.removeOverlayGrabber(from: dropShadowView)
257
+
258
+ let topInset = UIApplication.shared.connectedScenes
259
+ .compactMap { $0 as? UIWindowScene }
260
+ .flatMap { $0.windows }
261
+ .first(where: { $0.isKeyWindow })
262
+ .map { $0.safeAreaInsets.top } ?? 0
263
+
264
+ let pillWidth: CGFloat = 36
265
+ let pillHeight: CGFloat = 5
266
+ let pillX = (dropShadowView.bounds.width - pillWidth) / 2
267
+ let pillY = topInset + 8
268
+
269
+ let pill = UIView(frame: CGRect(x: pillX, y: pillY, width: pillWidth, height: pillHeight))
270
+ pill.backgroundColor = UIColor(white: 0.55, alpha: 0.45)
271
+ pill.layer.cornerRadius = pillHeight / 2
272
+ pill.autoresizingMask = [.flexibleLeftMargin, .flexibleRightMargin]
273
+ pill.tag = Self.overlayGrabberTag
274
+
275
+ dropShadowView.addSubview(pill)
276
+
277
+ #if DEBUG
278
+ Self.logPresentationStack(from: sheet)
279
+ Self.logGestureRecognizers(on: sheet)
280
+ #endif
281
+ }
282
+ }
283
+ }
284
+ }
285
+
286
+ #if DEBUG
287
+ private static func logPresentationStack(from sheet: UISheetPresentationController) {
288
+ let presented = sheet.presentedViewController
289
+ var vc: UIViewController? = presented
290
+ var depth = 0
291
+ var stack: [String] = []
292
+ while let current = vc {
293
+ let isModal = isReactNativeModalHostController(current)
294
+ let instanceId = isModal ? modalHostInstanceId(for: current).map(String.init) ?? "?" : "-"
295
+ let hasSheet = current.sheetPresentationController != nil
296
+ let presentedChild = current.presentedViewController.map { NSStringFromClass(type(of: $0)) } ?? "none"
297
+ stack.append(" [\(depth)] \(NSStringFromClass(type(of: current))) id=\(instanceId) hasSheet=\(hasSheet) presentedChild=\(presentedChild)")
298
+ vc = current.presentedViewController
299
+ depth += 1
300
+ }
301
+ debugLog("presentationStack:\n\(stack.joined(separator: "\n"))")
302
+ }
303
+
304
+ private static func logGestureRecognizers(on sheet: UISheetPresentationController) {
305
+ guard let containerView = sheet.containerView else { return }
306
+ var gestures: [String] = []
307
+ func walkGestures(view: UIView, depth: Int) {
308
+ let prefix = String(repeating: " ", count: depth)
309
+ let viewName = NSStringFromClass(type(of: view))
310
+ for gr in view.gestureRecognizers ?? [] {
311
+ let grName = NSStringFromClass(type(of: gr))
312
+ gestures.append("\(prefix)\(viewName) → \(grName) enabled=\(gr.isEnabled) state=\(gr.state.rawValue)")
313
+ }
314
+ for sub in view.subviews {
315
+ walkGestures(view: sub, depth: depth + 1)
316
+ }
317
+ }
318
+ walkGestures(view: containerView, depth: 0)
319
+ if !gestures.isEmpty {
320
+ debugLog("gestureRecognizers on parent sheet:\n\(gestures.joined(separator: "\n"))")
321
+ }
322
+ }
323
+ #endif
324
+
325
+ private static let overlayGrabberTag = 0x4E435347 // "NCSG"
326
+
327
+ private static func removeOverlayGrabber(from view: UIView) {
328
+ view.subviews
329
+ .filter { $0.tag == overlayGrabberTag }
330
+ .forEach { $0.removeFromSuperview() }
331
+ }
332
+
333
+ @available(iOS 15.0, *)
334
+ private func detent(for identifier: SheetDetentIdentifier)
335
+ -> UISheetPresentationController.Detent
336
+ {
337
+ switch identifier {
338
+ case .medium:
339
+ return .medium()
340
+ case .large:
341
+ return .large()
342
+ @unknown default:
343
+ return .large()
344
+ }
345
+ }
346
+
347
+ @available(iOS 15.0, *)
348
+ private func detentIdentifier(
349
+ for identifier: SheetDetentIdentifier
350
+ ) -> UISheetPresentationController.Detent.Identifier {
351
+ switch identifier {
352
+ case .medium:
353
+ return .medium
354
+ case .large:
355
+ return .large
356
+ @unknown default:
357
+ return .large
358
+ }
359
+ }
360
+
361
+ @available(iOS 16.0, *)
362
+ private func customDetentIdentifier(
363
+ forHeight height: Double
364
+ ) -> UISheetPresentationController.Detent.Identifier {
365
+ let normalizedHeight = max(0, height)
366
+ let rawValue = String(format: "ncs.customHeight.%.3f", normalizedHeight)
367
+ return UISheetPresentationController.Detent.Identifier(rawValue: rawValue)
368
+ }
369
+
370
+ private func applyModalViewBackground(
371
+ _ modalViewBackground: ModalViewBackground,
372
+ to controller: UIViewController
373
+ ) {
374
+ let color: UIColor
375
+ switch modalViewBackground {
376
+ case .clear:
377
+ color = .clear
378
+ case .systembackground:
379
+ color = .systemBackground
380
+ @unknown default:
381
+ color = .systemBackground
382
+ }
383
+
384
+ controller.view.backgroundColor = color
385
+ controller.view.isOpaque = color != .clear
386
+
387
+ // React Native mounts its modal content as a subview of the presented controller's root view.
388
+ // Keep those roots aligned with the requested background mode to avoid accidental opaque fills.
389
+ for subview in controller.view.subviews {
390
+ subview.backgroundColor = color
391
+ subview.isOpaque = color != .clear
392
+ }
393
+ }
394
+
395
+ @available(iOS 26.0, *)
396
+ private func applyCornerConfiguration(
397
+ _ configuration: ModalCornerConfiguration,
398
+ to controller: UIViewController
399
+ ) -> Bool {
400
+ let appliedConfiguration: UICornerConfiguration
401
+
402
+ switch configuration.type {
403
+ case .none:
404
+ appliedConfiguration = .corners(radius: .fixed(0))
405
+ case .fixed:
406
+ let radius = max(0, configuration.radius ?? 0)
407
+ appliedConfiguration = .corners(radius: .fixed(radius))
408
+ case .containerconcentric:
409
+ if let minimumRadius = configuration.minimumRadius {
410
+ appliedConfiguration = .corners(
411
+ radius: .containerConcentric(minimum: max(0, minimumRadius))
412
+ )
413
+ } else {
414
+ appliedConfiguration = .corners(radius: .containerConcentric())
415
+ }
416
+ case .capsule:
417
+ if let maximumRadius = configuration.maximumRadius {
418
+ appliedConfiguration = .capsule(maximumRadius: max(0, maximumRadius))
419
+ } else {
420
+ appliedConfiguration = .capsule()
421
+ }
422
+ @unknown default:
423
+ appliedConfiguration = .corners(radius: .fixed(0))
424
+ }
425
+
426
+ return applyCornerConfigurationNow(
427
+ appliedConfiguration,
428
+ cornerType: configuration.type,
429
+ to: controller
430
+ )
431
+ }
432
+
433
+ @available(iOS 26.0, *)
434
+ private func applyCornerConfigurationNow(
435
+ _ cornerConfiguration: UICornerConfiguration,
436
+ cornerType: ModalCornerConfigurationType,
437
+ to controller: UIViewController
438
+ ) -> Bool {
439
+ let contentView = controller.view!
440
+ let reactRootSubview = contentView.subviews.first
441
+
442
+ CATransaction.begin()
443
+ CATransaction.setDisableActions(true)
444
+ contentView.cornerConfiguration = cornerConfiguration
445
+ reactRootSubview?.cornerConfiguration = cornerConfiguration
446
+
447
+ let shouldClipContents = cornerType != .none
448
+ contentView.clipsToBounds = shouldClipContents
449
+ contentView.layer.masksToBounds = shouldClipContents
450
+ reactRootSubview?.clipsToBounds = shouldClipContents
451
+ reactRootSubview?.layer.masksToBounds = shouldClipContents
452
+ CATransaction.commit()
453
+
454
+ // React content can mount after present; return false to trigger JS retry until mounted.
455
+ return reactRootSubview != nil
456
+ }
457
+
458
+ private func derivedSheetCornerRadius(
459
+ from configuration: ModalCornerConfiguration
460
+ ) -> CGFloat? {
461
+ switch configuration.type {
462
+ case .none:
463
+ return 0
464
+ case .fixed:
465
+ return CGFloat(max(0, configuration.radius ?? 0))
466
+ case .containerconcentric, .capsule:
467
+ return nil
468
+ @unknown default:
469
+ return nil
470
+ }
471
+ }
472
+
473
+ private func onMainThread<T>(_ block: () throws -> T) throws -> T {
474
+ if Thread.isMainThread {
475
+ return try block()
476
+ }
477
+
478
+ var result: Result<T, Error>?
479
+ DispatchQueue.main.sync {
480
+ result = Result(catching: block)
481
+ }
482
+ return try result!.get()
483
+ }
484
+
485
+ private func topPresentedViewController() -> UIViewController? {
486
+ guard let rootViewController = activeRootViewController() else { return nil }
487
+ let topController = Self.topMostPresentedController(startingFrom: rootViewController)
488
+ return topController === rootViewController ? nil : topController
489
+ }
490
+
491
+ private func targetPresentedViewController(forModalInstanceId modalInstanceId: Int?)
492
+ -> UIViewController?
493
+ {
494
+ guard let modalInstanceId else {
495
+ return topPresentedViewController()
496
+ }
497
+
498
+ guard let rootViewController = activeRootViewController() else { return nil }
499
+
500
+ var controller: UIViewController? = rootViewController
501
+ while let current = controller {
502
+ if Self.isReactNativeModalHostController(current),
503
+ Self.modalHostInstanceId(for: current) == modalInstanceId
504
+ {
505
+ return current
506
+ }
507
+ controller = current.presentedViewController
508
+ }
509
+
510
+ return nil
511
+ }
512
+
513
+ private func activeRootViewController() -> UIViewController? {
514
+ let connectedScenes = UIApplication.shared.connectedScenes.compactMap {
515
+ $0 as? UIWindowScene
516
+ }
517
+
518
+ let activeScenes = connectedScenes.filter {
519
+ $0.activationState == .foregroundActive || $0.activationState == .foregroundInactive
520
+ }
521
+
522
+ for scene in activeScenes {
523
+ if let keyWindow = scene.windows.first(where: \.isKeyWindow),
524
+ let rootViewController = keyWindow.rootViewController
525
+ {
526
+ return rootViewController
527
+ }
528
+ if let rootViewController = scene.windows.first?.rootViewController {
529
+ return rootViewController
530
+ }
531
+ }
532
+
533
+ return nil
534
+ }
535
+
536
+ // TODO: super hacky find better solution 🤷‍♂️
537
+ @MainActor
538
+ fileprivate static func preconfigurePresentedControllerIfNeeded(_ controller: UIViewController) {
539
+ guard isReactNativeModalHostController(controller) else { return }
540
+ guard !cachedPresentedModalConfigs.isEmpty else {
541
+ debugLog("preconfigure skip empty queue controller=\(describe(controller))")
542
+ return
543
+ }
544
+ let queueSnapshot = cachedPresentedModalConfigs.compactMap { Self.modalInstanceId(from: $0) }.map(String.init)
545
+ debugLog(
546
+ "preconfigure queueBefore=[\(queueSnapshot.joined(separator: ","))] controller=\(describe(controller))"
547
+ )
548
+ // React child effects run before parent effects, so inner modals cache first.
549
+ // UIKit fires viewWillAppear outer-first. Using removeLast() (LIFO) corrects
550
+ // the mismatch so outer controllers get outer configs and inner get inner.
551
+ let config = cachedPresentedModalConfigs.removeLast()
552
+ let modalInstanceId = modalInstanceId(from: config)
553
+ if let modalInstanceId {
554
+ setModalHostInstanceId(modalInstanceId, on: controller)
555
+ }
556
+ debugLog(
557
+ "preconfigure consume controller=\(describe(controller)) modalInstanceId=\(modalInstanceId.map(String.init) ?? "nil") config=\(describe(config)) remainingQueueCount=\(cachedPresentedModalConfigs.count)"
558
+ )
559
+ _ = sharedInstance?.applyConfig(config, to: controller)
560
+ disableBackgroundModalHostInterceptionIfNeeded()
561
+ }
562
+
563
+ // TODO: super hacky find better solution 🤷‍♂️
564
+ private static func isReactNativeModalHostController(_ controller: UIViewController) -> Bool {
565
+ let className = NSStringFromClass(type(of: controller))
566
+ return className.contains("ModalHostViewController")
567
+ }
568
+
569
+ // TODO: super hacky find better solution 🤷‍♂️
570
+ private static func installModalHostHooksIfNeeded() {
571
+ guard !didInstallModalHostHooks else { return }
572
+ didInstallModalHostHooks = true
573
+ debugLog("installModalHostHooksIfNeeded")
574
+ swizzleUIViewControllerPresentIfNeeded()
575
+
576
+ let classNames = ["RCTFabricModalHostViewController", "RCTModalHostViewController"]
577
+ for className in classNames {
578
+ guard let cls = NSClassFromString(className) else { continue }
579
+ swizzleViewWillAppear(on: cls)
580
+ }
581
+ }
582
+
583
+ // TODO: super hacky find better solution 🤷‍♂️
584
+ private static func swizzleViewWillAppear(on cls: AnyClass) {
585
+ if let isSwizzled = objc_getAssociatedObject(
586
+ cls,
587
+ &Self.modalHostViewWillAppearSwizzleKey
588
+ ) as? Bool, isSwizzled {
589
+ debugLog("swizzleViewWillAppear already swizzled class=\(NSStringFromClass(cls))")
590
+ return
591
+ }
592
+
593
+ let originalSelector = #selector(UIViewController.viewWillAppear(_:))
594
+ let swizzledSelector = #selector(UIViewController.ncs_modalHost_viewWillAppear(_:))
595
+
596
+ guard
597
+ let originalMethod = class_getInstanceMethod(cls, originalSelector),
598
+ let swizzledMethod = class_getInstanceMethod(UIViewController.self, swizzledSelector)
599
+ else {
600
+ debugLog("swizzleViewWillAppear failed missing methods class=\(NSStringFromClass(cls))")
601
+ return
602
+ }
603
+
604
+ // Canonical safe swizzle pattern:
605
+ // 1) Try adding swizzled impl under original selector on this class.
606
+ // 2) If added, wire original impl under swizzled selector.
607
+ // 3) Otherwise exchange class-local method impls.
608
+ let didAddOriginalMethod = class_addMethod(
609
+ cls,
610
+ originalSelector,
611
+ method_getImplementation(swizzledMethod),
612
+ method_getTypeEncoding(swizzledMethod)
613
+ )
614
+
615
+ if didAddOriginalMethod {
616
+ class_replaceMethod(
617
+ cls,
618
+ swizzledSelector,
619
+ method_getImplementation(originalMethod),
620
+ method_getTypeEncoding(originalMethod)
621
+ )
622
+ } else if
623
+ let classOriginalMethod = class_getInstanceMethod(cls, originalSelector),
624
+ let classSwizzledMethod = class_getInstanceMethod(cls, swizzledSelector)
625
+ {
626
+ method_exchangeImplementations(classOriginalMethod, classSwizzledMethod)
627
+ } else {
628
+ debugLog("swizzleViewWillAppear failed class-local methods class=\(NSStringFromClass(cls))")
629
+ return
630
+ }
631
+
632
+ objc_setAssociatedObject(
633
+ cls,
634
+ &Self.modalHostViewWillAppearSwizzleKey,
635
+ true,
636
+ .OBJC_ASSOCIATION_RETAIN_NONATOMIC
637
+ )
638
+ debugLog("swizzleViewWillAppear success class=\(NSStringFromClass(cls))")
639
+ }
640
+
641
+ private static func swizzleUIViewControllerPresentIfNeeded() {
642
+ if let isSwizzled = objc_getAssociatedObject(
643
+ UIViewController.self,
644
+ &Self.viewControllerPresentSwizzleKey
645
+ ) as? Bool, isSwizzled {
646
+ debugLog("swizzleUIViewControllerPresentIfNeeded already swizzled")
647
+ return
648
+ }
649
+
650
+ let originalSelector = #selector(
651
+ UIViewController.present(_:animated:completion:)
652
+ )
653
+ let swizzledSelector = #selector(
654
+ UIViewController.ncs_modalHost_present(_:animated:completion:)
655
+ )
656
+
657
+ guard
658
+ let originalMethod = class_getInstanceMethod(UIViewController.self, originalSelector),
659
+ let swizzledMethod = class_getInstanceMethod(UIViewController.self, swizzledSelector)
660
+ else {
661
+ debugLog("swizzleUIViewControllerPresentIfNeeded failed missing methods")
662
+ return
663
+ }
664
+
665
+ method_exchangeImplementations(originalMethod, swizzledMethod)
666
+ objc_setAssociatedObject(
667
+ UIViewController.self,
668
+ &Self.viewControllerPresentSwizzleKey,
669
+ true,
670
+ .OBJC_ASSOCIATION_RETAIN_NONATOMIC
671
+ )
672
+ debugLog("swizzleUIViewControllerPresentIfNeeded success")
673
+ }
674
+
675
+ fileprivate static func preferredPresenter(
676
+ for presenter: UIViewController,
677
+ viewControllerToPresent: UIViewController
678
+ ) -> UIViewController {
679
+ guard isReactNativeModalHostController(viewControllerToPresent),
680
+ let currentlyPresented = presenter.presentedViewController
681
+ else {
682
+ return presenter
683
+ }
684
+
685
+ let top = topMostPresentedController(startingFrom: currentlyPresented)
686
+ guard isReactNativeModalHostController(top) else {
687
+ return presenter
688
+ }
689
+
690
+ return top
691
+ }
692
+
693
+ fileprivate static func topMostPresentedController(startingFrom root: UIViewController)
694
+ -> UIViewController
695
+ {
696
+ var controller = root
697
+ while let next = controller.presentedViewController {
698
+ controller = next
699
+ }
700
+ return controller
701
+ }
702
+
703
+ fileprivate static func modalInstanceId(from config: PresentedModalConfig) -> Int? {
704
+ guard let modalInstanceId = config.modalInstanceId else { return nil }
705
+ return Int(modalInstanceId)
706
+ }
707
+
708
+ fileprivate static func setModalHostInstanceId(_ id: Int, on controller: UIViewController) {
709
+ objc_setAssociatedObject(
710
+ controller,
711
+ &Self.modalHostInstanceIdKey,
712
+ NSNumber(value: id),
713
+ .OBJC_ASSOCIATION_RETAIN_NONATOMIC
714
+ )
715
+ }
716
+
717
+ fileprivate static func modalHostInstanceId(for controller: UIViewController) -> Int? {
718
+ guard
719
+ let number = objc_getAssociatedObject(
720
+ controller,
721
+ &Self.modalHostInstanceIdKey
722
+ ) as? NSNumber
723
+ else {
724
+ return nil
725
+ }
726
+ return number.intValue
727
+ }
728
+
729
+ fileprivate static func customDetentHeight(from rawDetentIdentifier: String) -> Double? {
730
+ let prefix = "ncs.customHeight."
731
+ guard rawDetentIdentifier.hasPrefix(prefix) else { return nil }
732
+ let rawValue = String(rawDetentIdentifier.dropFirst(prefix.count))
733
+ return Double(rawValue)
734
+ }
735
+
736
+ fileprivate static func disableBackgroundModalHostInterceptionIfNeeded() {
737
+ guard let rootViewController = sharedInstance?.activeRootViewController() else { return }
738
+ disableModalHostComponentInteraction(in: rootViewController.view)
739
+ }
740
+
741
+ fileprivate static func disableModalHostComponentInteraction(in rootView: UIView) {
742
+ var disabledCount = 0
743
+
744
+ func walk(_ view: UIView) {
745
+ let className = NSStringFromClass(type(of: view))
746
+ if className.contains("RCTModalHostViewComponentView")
747
+ || className.contains("RCTModalHostView")
748
+ {
749
+ if view.isUserInteractionEnabled {
750
+ view.isUserInteractionEnabled = false
751
+ disabledCount += 1
752
+ }
753
+ }
754
+
755
+ for subview in view.subviews {
756
+ walk(subview)
757
+ }
758
+ }
759
+
760
+ walk(rootView)
761
+ if disabledCount > 0 {
762
+ debugLog(
763
+ "disabled modal host interception views count=\(disabledCount)"
764
+ )
765
+ }
766
+ }
767
+
768
+ fileprivate static func describe(_ controller: UIViewController) -> String {
769
+ let className = NSStringFromClass(type(of: controller))
770
+ let pointer = Unmanaged.passUnretained(controller).toOpaque()
771
+ return "\(className)@\(pointer)"
772
+ }
773
+
774
+ fileprivate static func describe(_ config: PresentedModalConfig) -> String {
775
+ let modalInstanceId = modalInstanceId(from: config).map(String.init) ?? "nil"
776
+ let isModalInPresentation = config.isModalInPresentation.map(String.init) ?? "nil"
777
+ let bg = config.modalViewBackground.map { "\($0)" } ?? "nil"
778
+ let corner = config.cornerConfiguration.map { "\($0.type)" } ?? "nil"
779
+ let selectedCustom = config.sheet?.selectedCustomDetentHeight.map {
780
+ String(format: "%.3f", $0)
781
+ } ?? "nil"
782
+ let largestCustom = config.sheet?.largestUndimmedCustomDetentHeight.map {
783
+ String(format: "%.3f", $0)
784
+ } ?? "nil"
785
+ let customHeights = (config.sheet?.customDetentHeights ?? []).map {
786
+ String(format: "%.3f", $0)
787
+ }.joined(separator: ",")
788
+
789
+ return "modalInstanceId=\(modalInstanceId) sheetCustom=[\(customHeights)] selectedCustom=\(selectedCustom) largestCustom=\(largestCustom) bg=\(bg) corner=\(corner) modalInPresentation=\(isModalInPresentation)"
790
+ }
791
+
792
+ fileprivate static func debugLog(_ message: String) {
793
+ #if DEBUG
794
+ NSLog("[SheetModalController] %@", message)
795
+ #endif
796
+ }
797
+ }
798
+
799
+ private extension UIViewController {
800
+ @objc
801
+ func ncs_modalHost_present(
802
+ _ viewControllerToPresent: UIViewController,
803
+ animated flag: Bool,
804
+ completion: (() -> Void)? = nil
805
+ ) {
806
+ let preferredPresenter = SheetModalController.preferredPresenter(
807
+ for: self,
808
+ viewControllerToPresent: viewControllerToPresent
809
+ )
810
+
811
+ if preferredPresenter !== self {
812
+ SheetModalController.debugLog(
813
+ "redirect present from=\(SheetModalController.describe(self)) to=\(SheetModalController.describe(preferredPresenter)) target=\(SheetModalController.describe(viewControllerToPresent))"
814
+ )
815
+ preferredPresenter.ncs_modalHost_present(
816
+ viewControllerToPresent,
817
+ animated: flag,
818
+ completion: completion
819
+ )
820
+ return
821
+ }
822
+
823
+ ncs_modalHost_present(viewControllerToPresent, animated: flag, completion: completion)
824
+ }
825
+
826
+ @objc
827
+ func ncs_modalHost_viewWillAppear(_ animated: Bool) {
828
+ SheetModalController.debugLog(
829
+ "modalHost viewWillAppear controller=\(SheetModalController.describe(self)) animated=\(animated)"
830
+ )
831
+ if Thread.isMainThread {
832
+ SheetModalController.preconfigurePresentedControllerIfNeeded(self)
833
+ } else {
834
+ DispatchQueue.main.sync {
835
+ SheetModalController.preconfigurePresentedControllerIfNeeded(self)
836
+ }
837
+ }
838
+ ncs_modalHost_viewWillAppear(animated)
839
+ }
840
+ }
841
+
842
+ // MARK: - Debug helpers
843
+
844
+ #if DEBUG
845
+ extension SheetModalController {
846
+ static func dumpSheetProperties(_ sheet: UISheetPresentationController) {
847
+ let obj = sheet as NSObject
848
+ var cls: AnyClass? = type(of: sheet)
849
+
850
+ print("──── UISheetPresentationController ivars ────")
851
+ while let current = cls {
852
+ var ivarCount: UInt32 = 0
853
+ if let ivars = class_copyIvarList(current, &ivarCount) {
854
+ for i in 0..<Int(ivarCount) {
855
+ guard let ivarName = ivar_getName(ivars[i]) else { continue }
856
+ let rawKey = String(cString: ivarName)
857
+ let kvcKey = String(rawKey.drop(while: { $0 == "_" }))
858
+ guard !kvcKey.isEmpty else {
859
+ print(" \(rawKey) = ⚠️ inaccessible (empty key)")
860
+ continue
861
+ }
862
+ let getter = NSSelectorFromString(kvcKey)
863
+ let boolGetter = NSSelectorFromString(
864
+ "is" + kvcKey.prefix(1).uppercased() + kvcKey.dropFirst()
865
+ )
866
+ if obj.responds(to: getter) || obj.responds(to: boolGetter) {
867
+ let value = obj.value(forKey: kvcKey)
868
+ print(" \(rawKey) = \(value ?? "nil")")
869
+ } else {
870
+ print(" \(rawKey) = ⚠️ inaccessible")
871
+ }
872
+ }
873
+ free(ivars)
874
+ }
875
+ cls = class_getSuperclass(current)
876
+ if current == NSObject.self { break }
877
+ }
878
+
879
+ print("──── Methods matching grabber/fullScreen ────")
880
+ cls = type(of: sheet)
881
+ while let current = cls {
882
+ var methodCount: UInt32 = 0
883
+ if let methods = class_copyMethodList(current, &methodCount) {
884
+ for i in 0..<Int(methodCount) {
885
+ let selName = NSStringFromSelector(method_getName(methods[i]))
886
+ if selName.localizedCaseInsensitiveContains("grabber") ||
887
+ selName.localizedCaseInsensitiveContains("fullScreen") ||
888
+ selName.localizedCaseInsensitiveContains("fullscreen")
889
+ {
890
+ print(" \(selName)")
891
+ }
892
+ }
893
+ free(methods)
894
+ }
895
+ cls = class_getSuperclass(current)
896
+ if current == NSObject.self { break }
897
+ }
898
+ print("─────────────────────────────────────────────")
899
+ }
900
+ }
901
+ #endif
902
+
903
+ // MARK: - KVC access for private UISheetPresentationController properties
904
+ @dynamicMemberLookup
905
+ private final class Dynamic {
906
+ private let base: NSObject
907
+ init(_ base: NSObject) { self.base = base }
908
+ subscript(dynamicMember member: String) -> Any? {
909
+ get { base.value(forKey: member) }
910
+ set { base.setValue(newValue, forKey: member) }
911
+ }
912
+ }