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.
- package/LICENSE +21 -0
- package/NitroConcentricSheet.podspec +31 -0
- package/README.md +118 -0
- package/ios/Bridge.h +8 -0
- package/ios/SheetModalController.swift +912 -0
- package/lib/NativeSheetModal.d.ts +10 -0
- package/lib/NativeSheetModal.js +243 -0
- package/lib/index.d.ts +3 -0
- package/lib/index.js +1 -0
- package/lib/specs/SheetModalController.nitro.d.ts +46 -0
- package/lib/specs/SheetModalController.nitro.js +1 -0
- package/nitro.json +23 -0
- package/nitrogen/generated/.gitattributes +1 -0
- package/nitrogen/generated/ios/NitroConcentricSheet+autolinking.rb +60 -0
- package/nitrogen/generated/ios/NitroConcentricSheet-Swift-Cxx-Bridge.cpp +33 -0
- package/nitrogen/generated/ios/NitroConcentricSheet-Swift-Cxx-Bridge.hpp +269 -0
- package/nitrogen/generated/ios/NitroConcentricSheet-Swift-Cxx-Umbrella.hpp +69 -0
- package/nitrogen/generated/ios/NitroConcentricSheetAutolinking.mm +33 -0
- package/nitrogen/generated/ios/NitroConcentricSheetAutolinking.swift +26 -0
- package/nitrogen/generated/ios/c++/HybridSheetModalControllerSpecSwift.cpp +11 -0
- package/nitrogen/generated/ios/c++/HybridSheetModalControllerSpecSwift.hpp +130 -0
- package/nitrogen/generated/ios/swift/HybridSheetModalControllerSpec.swift +58 -0
- package/nitrogen/generated/ios/swift/HybridSheetModalControllerSpec_cxx.swift +181 -0
- package/nitrogen/generated/ios/swift/ModalCornerConfiguration.swift +83 -0
- package/nitrogen/generated/ios/swift/ModalCornerConfigurationType.swift +48 -0
- package/nitrogen/generated/ios/swift/ModalViewBackground.swift +40 -0
- package/nitrogen/generated/ios/swift/PresentedModalConfig.swift +129 -0
- package/nitrogen/generated/ios/swift/PresentedModalDetent.swift +71 -0
- package/nitrogen/generated/ios/swift/SheetDetentIdentifier.swift +40 -0
- package/nitrogen/generated/ios/swift/SheetPresentationConfig.swift +238 -0
- package/nitrogen/generated/ios/swift/Variant_NullType_PresentedModalDetent.swift +18 -0
- package/nitrogen/generated/shared/c++/HybridSheetModalControllerSpec.cpp +24 -0
- package/nitrogen/generated/shared/c++/HybridSheetModalControllerSpec.hpp +71 -0
- package/nitrogen/generated/shared/c++/ModalCornerConfiguration.hpp +97 -0
- package/nitrogen/generated/shared/c++/ModalCornerConfigurationType.hpp +84 -0
- package/nitrogen/generated/shared/c++/ModalViewBackground.hpp +76 -0
- package/nitrogen/generated/shared/c++/PresentedModalConfig.hpp +115 -0
- package/nitrogen/generated/shared/c++/PresentedModalDetent.hpp +94 -0
- package/nitrogen/generated/shared/c++/SheetDetentIdentifier.hpp +76 -0
- package/nitrogen/generated/shared/c++/SheetPresentationConfig.hpp +130 -0
- package/package.json +103 -0
- package/react-native.config.js +13 -0
- package/src/NativeSheetModal.tsx +343 -0
- package/src/index.ts +16 -0
- 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
|
+
}
|