-
-
Notifications
You must be signed in to change notification settings - Fork 643
Expand file tree
/
Copy pathRNSSplitHostController.swift
More file actions
575 lines (499 loc) · 19.9 KB
/
RNSSplitHostController.swift
File metadata and controls
575 lines (499 loc) · 19.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
import Foundation
import UIKit
/// @class RNSSplitHostController
/// @brief A controller associated with the RN native component representing Split host.
///
/// Manages a collection of RNSSplitScreenComponentView instances,
/// synchronizes appearance settings with props, observes component lifecycle, and emits events.
@objc
public class RNSSplitHostController: UISplitViewController, ReactMountingTransactionObserving,
RNSOrientationProvidingSwift
{
private var needsChildViewControllersUpdate = false
private var isLayoutDirectionUpdatePending = false
private var splitAppearanceCoordinator: RNSSplitAppearanceCoordinator
private var splitAppearanceApplicator: RNSSplitAppearanceApplicator
private var reactEventEmitter: RNSSplitHostComponentEventEmitter {
return splitHostComponentView.reactEventEmitter()
}
private let splitHostComponentView: RNSSplitHostComponentView
/// This variable is keeping the value of how many columns were set in the initial render. It's used for validation, because Split doesn't support changing number of columns dynamically.
private let fixedColumnsCount: Int
private let minNumberOfColumns: Int = 2
private let maxNumberOfColumns: Int = 3
private let maxNumberOfInspectors: Int = 1
/// Tracks currently visible columns of the UISplitViewController.
///
/// This set is kept in sync via `UISplitViewControllerDelegate` methods (`willShow` / `willHide`)
/// to reflect which columns are currently rendered in the UI.
/// It ensures that only visible columns are considered (e.g. for accessing topViewController),
/// avoiding crashes when certain columns are collapsed or hidden.
private var visibleColumns: Set<UISplitViewController.Column> = []
///
/// @brief Initializes the Split host controller with provided style.
///
/// The style for the Split component can be passed only in the initialization method and cannot be changed dynamically.
///
/// @param splitHostComponentView The view managed by this controller.
/// @param numberOfColumns Expected number of visible columns.
///
@objc public init(
splitHostComponentView: RNSSplitHostComponentView,
numberOfColumns: Int
) {
self.splitHostComponentView = splitHostComponentView
self.splitAppearanceCoordinator = RNSSplitAppearanceCoordinator()
self.splitAppearanceApplicator = RNSSplitAppearanceApplicator()
self.fixedColumnsCount = numberOfColumns
super.init(style: RNSSplitHostController.styleByNumberOfColumns(numberOfColumns))
delegate = self
}
required init?(coder aDecoder: NSCoder) {
return nil
}
// MARK: Signals
@objc
public func setNeedsUpdateOfChildViewControllers() {
needsChildViewControllersUpdate = true
}
@objc
public func setNeedsAppearanceUpdate() {
splitAppearanceCoordinator.needs(.generalUpdate)
}
@objc
public func setNeedsSecondaryScreenNavBarUpdate() {
// We noticed a bug on the pure-native component, which is blocking dynamic updates for showsSecondaryOnlyButton.
// Toggling this flag doesn't refresh the component and is updated after triggerig some other interaction, like changing layout.
// We noticed that we can forcefully refresh navigation bar from UINavigationController level by toggling setNavigationBarHidden.
// After some testing, it looks well and I haven't noticed any flicker - missing button is appearing naturally.
// Please note that this is a hack rather than a solution so feel free to remove this code in case of any problems and treat the bug with toggling button as a platform's issue.
splitAppearanceCoordinator.needs(.secondaryScreenNavBarUpdate)
}
@objc
public func setNeedsDisplayModeUpdate() {
splitAppearanceCoordinator.needs(.displayModeUpdate)
}
@objc
public func setNeedsOrientationUpdate() {
splitAppearanceCoordinator.needs(.orientationUpdate)
}
@objc
public func setNeedsLayoutDirectionUpdateBelowIOS17() {
if self.parent != nil {
splitAppearanceCoordinator.needs(.layoutDirectionUpdateBelowIOS17)
} else {
isLayoutDirectionUpdatePending = true
}
}
// MARK: Updating
@objc
public func updateChildViewControllersIfNeeded() {
if needsChildViewControllersUpdate {
updateChildViewControllers()
}
}
///
/// @brief Creates and attaches the Split child controllers based on the current React subviews.
///
/// It validates constraints for Split hierarchy and it will crash after recognizing an invalid state,
/// e. g. dynamically changed number of columns or number of columns that isn't between defined bounds.
/// If Split constraints are met, it attaches SplitScreen representatives to SplitHost component.
///
@objc
public func updateChildViewControllers() {
precondition(
needsChildViewControllersUpdate,
"[RNScreens] Child view controller must be invalidated when update is forced!")
let currentColumns = filterSubviews(
ofType: RNSSplitScreenColumnType.column, in: splitReactSubviews)
let currentInspectors = filterSubviews(
ofType: RNSSplitScreenColumnType.inspector, in: splitReactSubviews)
validateColumns(currentColumns)
validateInspectors(currentInspectors)
let currentViewControllers = currentColumns.map {
RNSSplitNavigationController(rootViewController: $0.controller)
}
viewControllers = currentViewControllers
#if compiler(>=6.2)
maybeSetupInspector(currentInspectors)
#endif
for controller in currentViewControllers {
controller.viewFrameOriginChangeObserver = self
}
needsChildViewControllersUpdate = false
}
func updateSplitAppearanceIfNeeded() {
splitAppearanceApplicator.updateAppearanceIfNeeded(
self.splitHostComponentView, self, self.splitAppearanceCoordinator)
}
///
/// @brief Triggering appearance updates on secondary column's UINavigationBar component
///
/// It validates that the secondary VC is valid UINavigationController and it updates the navbar
/// state by toggling it's visibility, what should be performed in a single batch of updates.
///
public func refreshSecondaryNavBar() {
let secondaryViewController = viewController(for: .secondary)
assert(
secondaryViewController != nil,
"[RNScreens] Failed to refresh secondary nav bar. Secondary view controller is nil.")
assert(
secondaryViewController is UINavigationController,
"[RNScreens] Expected UINavigationController but got \(type(of: secondaryViewController))")
let navigationController = secondaryViewController as! UINavigationController
/// The assumption is that it should come in a single batch and it won't cause any delays in rendering the content.
navigationController.setNavigationBarHidden(true, animated: false)
navigationController.setNavigationBarHidden(false, animated: false)
}
// MARK: Helpers
///
/// @brief Gets the appropriate style for a specified number of columns.
///
/// This utility maps a given number of columns to the corresponding UISplitViewController.Style.
///
/// @param numberOfColumns The number of columns for the SplitView.
/// @return A UISplitViewController.Style corresponding to the provided column count.
///
static func styleByNumberOfColumns(_ numberOfColumns: Int) -> UISplitViewController.Style {
switch numberOfColumns {
case 2:
return .doubleColumn
case 3:
return .tripleColumn
default:
return .unspecified
}
}
///
/// @brief Filters the given subviews array by a specific column type.
///
/// Iterates over the provided subviews array and returns only the elements that match
/// the specified RNSSplitScreenColumnType (e.g., .column, .inspector).
///
/// @param type The target RNSSplitScreenColumnType to filter for.
/// @param subviews The array of RNSSplitScreenComponentView elements to filter.
/// @return A filtered array of RNSSplitScreenComponentView objects with the specified column type.
///
func filterSubviews(
ofType type: RNSSplitScreenColumnType, in subviews: [RNSSplitScreenComponentView]
) -> [RNSSplitScreenComponentView] {
return subviews.filter { $0.columnType == type }
}
// MARK: Public setters
///
/// @brief Shows or hides the inspector screen.
/// @remarks Inspector column is only available for iOS 26 or higher.
///
/// @param showInspector Determines whether the inspector column should be visible.
///
@objc
public func toggleSplitViewInspector(_ showInspector: Bool) {
#if compiler(>=6.2)
if showInspector {
maybeShowInspector()
} else {
maybeHideInspector()
}
#endif
}
///
/// @brief Programmatically shows a specific column identified by its string name.
///
/// Maps the string column name to the corresponding `UISplitViewController.Column` and calls `show(_:)`.
///
/// @param columnName A string representing the column to show: `"primary"`, `"supplementary"`, or `"secondary"`.
///
@objc
public func showColumnNamed(_ columnName: String) {
guard let column = splitViewColumnFromString(columnName) else {
assertionFailure("[RNScreens] Unknown column name: \(columnName)")
return
}
show(column)
}
///
/// @brief Maps a string column name to its corresponding `UISplitViewController.Column` value.
///
/// @param name The column name string: `"primary"`, `"supplementary"`, or `"secondary"`.
/// @return The corresponding `UISplitViewController.Column`, or `nil` if the name is not recognized.
///
private func splitViewColumnFromString(_ name: String) -> UISplitViewController.Column? {
switch name {
case "primary":
return .primary
case "supplementary":
return .supplementary
case "secondary":
return .secondary
default:
return nil
}
}
// MARK: ReactMountingTransactionObserving
///
/// @brief Called before mounting transaction.
///
@objc
public func reactMountingTransactionWillMount() {
// noop
}
///
/// @brief Called after mounting transaction.
///
/// Updates children and the appearance, checks if the hierarchy is valid after applying updates.
///
@objc
public func reactMountingTransactionDidMount() {
updateChildViewControllersIfNeeded()
updateSplitAppearanceIfNeeded()
validateSplitViewHierarchy()
}
// MARK: RNSSplitHostOrientationProviding
@objc
public func evaluateOrientation() -> RNSOrientationSwift {
return convertToSwiftEnum(splitHostComponentView.orientation)
}
func convertToSwiftEnum(_ orientation: RNSOrientation) -> RNSOrientationSwift {
switch orientation {
case RNSOrientation.inherit:
return .inherit
case RNSOrientation.all:
return .all
case RNSOrientation.allButUpsideDown:
return .allButUpsideDown
case RNSOrientation.portrait:
return .portrait
case RNSOrientation.portraitUp:
return .portraitUp
case RNSOrientation.portraitDown:
return .portraitDown
case RNSOrientation.landscape:
return .landscape
case RNSOrientation.landscapeLeft:
return .landscapeLeft
case RNSOrientation.landscapeRight:
return .landscapeRight
@unknown default:
return .inherit
}
}
// MARK: Validators
///
/// @brief Validates that child structure meets required constraints defined for columns and the inspector.
///
func validateSplitViewHierarchy() {
let columns = filterSubviews(
ofType: RNSSplitScreenColumnType.column, in: splitReactSubviews)
let inspectors = filterSubviews(
ofType: RNSSplitScreenColumnType.inspector, in: splitReactSubviews)
validateColumns(columns)
validateInspectors(inspectors)
}
///
/// @brief Ensures that number of columns is valid and hasn't changed dynamically.
///
func validateColumns(_ columns: [RNSSplitScreenComponentView]) {
assert(
columns.count >= minNumberOfColumns
&& columns.count <= maxNumberOfColumns,
"[RNScreens] Split can only have from \(minNumberOfColumns) to \(maxNumberOfColumns) columns"
)
assert(
columns.count == fixedColumnsCount,
"[RNScreens] Split number of columns shouldn't change dynamically")
}
///
/// @brief Ensures that at most one inspector is present.
///
func validateInspectors(_ inspectors: [RNSSplitScreenComponentView]) {
assert(
inspectors.count <= maxNumberOfInspectors,
"[RNScreens] Split can only have \(maxNumberOfInspectors) inspector")
}
}
extension RNSSplitHostController {
///
/// @brief Gets the children RNSSplitScreenController instances.
///
/// Accesses Split controllers associated with presented columns. It asserts that each view controller is a navigation controller and its topViewController is of type RNSSplitScreenController.
///
/// @return An array of RNSSplitScreenController corresponding to current split view columns.
///
var splitScreenControllers: [RNSSplitScreenController] {
return visibleColumns.compactMap { column in
let viewController = self.viewController(for: column)
assert(viewController != nil, "[RNScreens] viewController for column \(column) is nil.")
let splitNavigationController = viewController as? RNSSplitNavigationController
assert(
splitNavigationController != nil,
"[RNScreens] Expected RNSSplitNavigationController but got \(type(of: viewController))")
let maybeSplitScreenController = splitNavigationController?.topViewController
assert(
maybeSplitScreenController != nil,
"[RNScreens] RNSSplitScreenController is nil for column \(column)")
assert(
maybeSplitScreenController is RNSSplitScreenController,
"[RNScreens] Expected RNSSplitScreenController but got \(type(of: maybeSplitScreenController))"
)
return maybeSplitScreenController as? RNSSplitScreenController
}
}
///
/// @brief Gets all React subviews of type RNSSplitScreenComponentView.
///
/// Accesses all the subviews from the reactSubviews collection. It asserts that each one is a RNSSplitScreenComponentView.
///
/// @return An array of RNSSplitScreenComponentView subviews which are children of the host component view.
///
var splitReactSubviews: [RNSSplitScreenComponentView] {
return self.splitHostComponentView.reactSubviews().lazy.map { subview in
assert(
subview is RNSSplitScreenComponentView,
"[RNScreens] Expected RNSSplitScreenComponentView but got \(type(of: subview))")
return subview as! RNSSplitScreenComponentView
}
}
}
extension RNSSplitHostController: RNSSplitNavigationControllerViewFrameObserver {
///
/// @brief Notifies that an origin of parent RNSSplitNavigationController frame has changed.
///
/// It iterates over children controllers and notifies them for the layout update.
///
/// @param splitNavCtrl The navigation controller whose frame origin changed.
///
func splitNavCtrlViewDidChangeFrameOrigin(
_ splitNavCtrl: RNSSplitNavigationController
) {
for controller in self.splitScreenControllers {
controller.columnPositioningDidChangeIn(splitViewController: self)
}
}
}
/// This extension is a workaround for missing UISplitViewController symbols introduced in iOS 26,
/// allowing the project to compile and run on iOS 18 or earlier versions.
#if compiler(>=6.2)
extension RNSSplitHostController {
///
/// @brief Sets up the inspector column if available.
/// @remarks Inspector columns is available only on iOS 26 or higher.
///
/// Attaches a view controller for the inspector column.
///
/// @param inspectors An array of inspector-type RNSSplitScreenComponentView subviews.
///
func maybeSetupInspector(_ inspectors: [RNSSplitScreenComponentView]) {
#if !os(tvOS)
if #available(iOS 26.0, *) {
let inspector = inspectors.first
if inspector != nil {
let inspectorViewController = RNSSplitNavigationController(
rootViewController: inspector!.controller)
setViewController(inspectorViewController, for: .inspector)
}
}
#endif
}
///
/// @brief Shows the inspector column when available.
/// @remarks Inspector columns is available only on iOS 26 or higher.
///
/// Uses the UISplitViewController's new API introduced in iOS 26 to show the inspector column.
///
func maybeShowInspector() {
#if !os(tvOS)
if #available(iOS 26.0, *) {
show(.inspector)
}
#endif
}
///
/// @brief Hides the inspector column when available.
/// @remarks Inspector columns is available only on iOS 26 or higher.
///
/// Uses the UISplitViewController's new API introduced in iOS 26 to hide the inspector column.
///
func maybeHideInspector() {
#if !os(tvOS)
if #available(iOS 26.0, *) {
hide(.inspector)
}
#endif
}
}
#endif
extension RNSSplitHostController: UISplitViewControllerDelegate {
public func splitViewController(
_ svc: UISplitViewController, willShow column: UISplitViewController.Column
) {
visibleColumns.insert(column)
}
public func splitViewController(
_ svc: UISplitViewController, willHide column: UISplitViewController.Column
) {
visibleColumns.remove(column)
}
public func splitViewControllerDidCollapse(_ svc: UISplitViewController) {
reactEventEmitter.emitOnCollapse()
}
public func splitViewControllerDidExpand(_ svc: UISplitViewController) {
reactEventEmitter.emitOnExpand()
}
#if compiler(>=6.2)
///
/// @brief Called after a column in the split view controller has been hidden from the interface.
///
/// Currently emits onHideInspector event for the inspector if applicable.
///
/// @param svc The split view controller that just hid the column.
/// @param column The column that was hidden.
///
public func splitViewController(
_ svc: UISplitViewController, didHide column: UISplitViewController.Column
) {
#if !os(tvOS)
if #available(iOS 26.0, *) {
// TODO: we may consider removing this logic, because it could be handled by onViewDidDisappear on the column level
// On the other hand, maybe dedicated event related to the inspector would be a better approach.
// For now I am leaving it, but feel free to drop this method if there's any reason that `onDidDisappear` works better.
if column != .inspector {
return
}
// `didHide` for modal is called on finger down for dismiss, what is problematic, because we can cancel dismissing modal.
// In this scenario, the modal inspector might receive an invalid state and will deviate from the JS value.
// Therefore, for event emissions, we need to ensure that the view was detached from the view hierarchy, by checking its window.
if let inspectorViewController = viewController(for: .inspector) {
if inspectorViewController.view.window == nil {
reactEventEmitter.emitOnHideInspector()
}
}
}
#endif
}
#endif
@objc
public func splitViewController(
_ svc: UISplitViewController, willChangeTo displayMode: UISplitViewController.DisplayMode
) {
if self.displayMode != displayMode {
reactEventEmitter.emitOnDisplayModeWillChange(from: self.displayMode, to: displayMode)
}
}
public func splitViewController(
_ svc: UISplitViewController,
topColumnForCollapsingToProposedTopColumn proposedTopColumn: UISplitViewController.Column
) -> UISplitViewController.Column {
if splitHostComponentView.hasCustomTopColumnForCollapsing {
return splitHostComponentView.topColumnForCollapsingColumn
}
return proposedTopColumn
}
public override func didMove(toParent parent: UIViewController?) {
super.didMove(toParent: parent)
if parent != nil && isLayoutDirectionUpdatePending {
isLayoutDirectionUpdatePending = false
splitAppearanceApplicator.updateLayoutDirectionBelowIOS17(
self.splitHostComponentView, self)
}
}
}