ssap_app/node_modules/react-native-screens/ios/gamma/split-view/RNSSplitViewHostController....

476 lines
17 KiB
Swift

import Foundation
import UIKit
/// @class RNSSplitViewHostController
/// @brief A controller associated with the RN native component representing SplitView host.
///
/// Manages a collection of RNSSplitViewScreenComponentView instances,
/// synchronizes appearance settings with props, observes component lifecycle, and emits events.
@objc
public class RNSSplitViewHostController: UISplitViewController, ReactMountingTransactionObserving,
RNSOrientationProvidingSwift
{
private var needsChildViewControllersUpdate = false
private var splitViewAppearanceCoordinator: RNSSplitViewAppearanceCoordinator
private var splitViewAppearanceApplicator: RNSSplitViewAppearanceApplicator
private var reactEventEmitter: RNSSplitViewHostComponentEventEmitter {
return splitViewHostComponentView.reactEventEmitter()
}
private let splitViewHostComponentView: RNSSplitViewHostComponentView
/// This variable is keeping the value of how many columns were set in the initial render. It's used for validation, because SplitView 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
///
/// @brief Initializes the SplitView host controller with provided style.
///
/// The style for the SplitView component can be passed only in the initialization method and cannot be changed dynamically.
///
/// @param splitViewHostComponentView The view managed by this controller.
/// @param numberOfColumns Expected number of visible columns.
///
@objc public init(
splitViewHostComponentView: RNSSplitViewHostComponentView,
numberOfColumns: Int
) {
self.splitViewHostComponentView = splitViewHostComponentView
self.splitViewAppearanceCoordinator = RNSSplitViewAppearanceCoordinator()
self.splitViewAppearanceApplicator = RNSSplitViewAppearanceApplicator()
self.fixedColumnsCount = numberOfColumns
super.init(style: RNSSplitViewHostController.styleByNumberOfColumns(numberOfColumns))
delegate = self
}
required init?(coder aDecoder: NSCoder) {
return nil
}
// MARK: Signals
@objc
public func setNeedsUpdateOfChildViewControllers() {
needsChildViewControllersUpdate = true
}
@objc
public func setNeedsAppearanceUpdate() {
splitViewAppearanceCoordinator.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.
splitViewAppearanceCoordinator.needs(.secondaryScreenNavBarUpdate)
}
@objc
public func setNeedsDisplayModeUpdate() {
splitViewAppearanceCoordinator.needs(.displayModeUpdate)
}
@objc
public func setNeedsOrientationUpdate() {
splitViewAppearanceCoordinator.needs(.orientationUpdate)
}
// MARK: Updating
@objc
public func updateChildViewControllersIfNeeded() {
if needsChildViewControllersUpdate {
updateChildViewControllers()
}
}
///
/// @brief Creates and attaches the SplitView child controllers based on the current React subviews.
///
/// It validates constraints for SplitView 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 SplitView constraints are met, it attaches SplitViewScreen representatives to SplitViewHost component.
///
@objc
public func updateChildViewControllers() {
precondition(
needsChildViewControllersUpdate,
"[RNScreens] Child view controller must be invalidated when update is forced!")
let currentColumns = filterSubviews(
ofType: RNSSplitViewScreenColumnType.column, in: splitViewReactSubviews)
let currentInspectors = filterSubviews(
ofType: RNSSplitViewScreenColumnType.inspector, in: splitViewReactSubviews)
validateColumns(currentColumns)
validateInspectors(currentInspectors)
let currentViewControllers = currentColumns.map {
RNSSplitViewNavigationController(rootViewController: $0.controller)
}
viewControllers = currentViewControllers
#if compiler(>=6.2)
maybeSetupInspector(currentInspectors)
#endif
for controller in currentViewControllers {
controller.viewFrameOriginChangeObserver = self
}
needsChildViewControllersUpdate = false
}
func updateSplitViewAppearanceIfNeeded() {
splitViewAppearanceApplicator.updateAppearanceIfNeeded(
self.splitViewHostComponentView, self, self.splitViewAppearanceCoordinator)
}
///
/// @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 RNSSplitViewScreenColumnType (e.g., .column, .inspector).
///
/// @param type The target RNSSplitViewScreenColumnType to filter for.
/// @param subviews The array of RNSSplitViewScreenComponentView elements to filter.
/// @return A filtered array of RNSSplitViewScreenComponentView objects with the specified column type.
///
func filterSubviews(
ofType type: RNSSplitViewScreenColumnType, in subviews: [RNSSplitViewScreenComponentView]
) -> [RNSSplitViewScreenComponentView] {
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
}
// 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()
updateSplitViewAppearanceIfNeeded()
validateSplitViewHierarchy()
}
// MARK: RNSSplitViewHostOrientationProviding
@objc
public func evaluateOrientation() -> RNSOrientationSwift {
return convertToSwiftEnum(splitViewHostComponentView.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: RNSSplitViewScreenColumnType.column, in: splitViewReactSubviews)
let inspectors = filterSubviews(
ofType: RNSSplitViewScreenColumnType.inspector, in: splitViewReactSubviews)
validateColumns(columns)
validateInspectors(inspectors)
}
///
/// @brief Ensures that number of columns is valid and hasn't changed dynamically.
///
func validateColumns(_ columns: [RNSSplitViewScreenComponentView]) {
assert(
columns.count >= minNumberOfColumns
&& columns.count <= maxNumberOfColumns,
"[RNScreens] SplitView can only have from \(minNumberOfColumns) to \(maxNumberOfColumns) columns"
)
assert(
columns.count == fixedColumnsCount,
"[RNScreens] SplitView number of columns shouldn't change dynamically")
}
///
/// @brief Ensures that at most one inspector is present.
///
func validateInspectors(_ inspectors: [RNSSplitViewScreenComponentView]) {
assert(
inspectors.count <= maxNumberOfInspectors,
"[RNScreens] SplitView can only have \(maxNumberOfInspectors) inspector")
}
}
extension RNSSplitViewHostController {
///
/// @brief Gets the children RNSSplitViewScreenController instances.
///
/// Accesses SplitView controllers associated with columns. It asserts that each view controller is a navigation controller and its topViewController is of type RNSSplitViewScreenController.
///
/// @return An array of RNSSplitViewScreenController corresponding to current split view columns.
///
var splitViewScreenControllers: [RNSSplitViewScreenController] {
return viewControllers.lazy.map { viewController in
assert(
viewController is RNSSplitViewNavigationController,
"[RNScreens] Expected RNSSplitViewNavigationController but got \(type(of: viewController))")
let splitViewNavigationController = viewController as! RNSSplitViewNavigationController
let splitViewNavigationControllerTopViewController = splitViewNavigationController
.topViewController
assert(
splitViewNavigationControllerTopViewController is RNSSplitViewScreenController,
"[RNScreens] Expected RNSSplitViewScreenController but got \(type(of: splitViewNavigationControllerTopViewController))"
)
return splitViewNavigationControllerTopViewController as! RNSSplitViewScreenController
}
}
///
/// @brief Gets all React subviews of type RNSSplitViewScreenComponentView.
///
/// Accesses all the subviews from the reactSubviews collection. It asserts that each one is a RNSSplitViewScreenComponentView.
///
/// @return An array of RNSSplitViewScreenComponentView subviews which are children of the host component view.
///
var splitViewReactSubviews: [RNSSplitViewScreenComponentView] {
return self.splitViewHostComponentView.reactSubviews().lazy.map { subview in
assert(
subview is RNSSplitViewScreenComponentView,
"[RNScreens] Expected RNSSplitViewScreenComponentView but got \(type(of: subview))")
return subview as! RNSSplitViewScreenComponentView
}
}
}
extension RNSSplitViewHostController: RNSSplitViewNavigationControllerViewFrameObserver {
///
/// @brief Notifies that an origin of parent RNSSplitViewNavigationController frame has changed.
///
/// It iterates over children controllers and notifies them for the layout update.
///
/// @param splitViewNavCtrl The navigation controller whose frame origin changed.
///
func splitViewNavCtrlViewDidChangeFrameOrigin(
_ splitViewNavCtrl: RNSSplitViewNavigationController
) {
for controller in self.splitViewScreenControllers {
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 RNSSplitViewHostController {
///
/// @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 RNSSplitViewScreenComponentView subviews.
///
func maybeSetupInspector(_ inspectors: [RNSSplitViewScreenComponentView]) {
if #available(iOS 26.0, *) {
let inspector = inspectors.first
if inspector != nil {
let inspectorViewController = RNSSplitViewNavigationController(
rootViewController: inspector!.controller)
setViewController(inspectorViewController, for: .inspector)
}
}
}
///
/// @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 #available(iOS 26.0, *) {
show(.inspector)
}
}
///
/// @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 #available(iOS 26.0, *) {
hide(.inspector)
}
}
}
#endif
extension RNSSplitViewHostController: UISplitViewControllerDelegate {
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 #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
@objc
public func splitViewController(
_ svc: UISplitViewController, willChangeTo displayMode: UISplitViewController.DisplayMode
) {
if self.displayMode != displayMode {
reactEventEmitter.emitOnDisplayModeWillChange(from: self.displayMode, to: displayMode)
}
}
}