import Foundation import UIKit /// @class RNSSplitViewScreenController /// @brief A UIViewController subclass that manages a SplitView column in a UISplitViewController. /// /// Associated with a RNSSplitViewScreenComponentView, it handles layout synchronization with the /// Shadow Tree, emits React lifecycle events, and interacts with the SplitViewHost hierarchy. @objc public class RNSSplitViewScreenController: UIViewController { let splitViewScreenComponentView: RNSSplitViewScreenComponentView private var shadowStateProxy: RNSSplitViewScreenShadowStateProxy { return splitViewScreenComponentView.shadowStateProxy() } private var reactEventEmitter: RNSSplitViewScreenComponentEventEmitter { return splitViewScreenComponentView.reactEventEmitter() } private var viewSizeTransitionState: ViewSizeTransitionState? = nil @objc public required init(splitViewScreenComponentView: RNSSplitViewScreenComponentView) { self.splitViewScreenComponentView = splitViewScreenComponentView super.init(nibName: nil, bundle: nil) } required init?(coder aDecoder: NSCoder) { return nil } /// /// @brief Searching for the SplitViewHost controller /// /// It checks whether the parent controller is our host controller. /// If we're outside the structure, e. g. for inspector represented as a modal, /// we're searching for that controller using a reference that Screen keeps for Host component view. /// /// @return If found - a RNSSplitViewHostController instance, otherwise nil. /// func findSplitViewHostController() -> RNSSplitViewHostController? { if let splitViewHostController = self.splitViewController as? RNSSplitViewHostController { return splitViewHostController } if let splitViewHost = self.splitViewScreenComponentView.splitViewHost { return splitViewHost.splitViewHostController } return nil } /// /// @brief Determines if this controller is nested inside a SplitViewHost hierarchy. /// /// Used to differentiate between screens embedded in the native host and modal presentations. /// /// @return true if inside RNSSplitViewHostController, false otherwise. /// @objc public func isInSplitViewHostSubtree() -> Bool { return self.splitViewController is RNSSplitViewHostController } /// /// @brief Determines whether an SplitView animated transition is currently running /// /// Used to differentiate favor frames from the presentation layer over view's frame . /// /// @return true if the transition is running, false otherwise. /// @objc public func isViewSizeTransitionInProgress() -> Bool { return viewSizeTransitionState != nil } // MARK: Signals @objc public func setNeedsLifecycleStateUpdate() { findSplitViewHostController()?.setNeedsUpdateOfChildViewControllers() } // MARK: Layout /// /// @brief This method is overridden to extract the value to which we're transitioning /// and attach the DisplayLink to track frame updates on the presentation layer. /// public override func viewWillTransition( to size: CGSize, with coordinator: any UIViewControllerTransitionCoordinator ) { super.viewWillTransition(to: size, with: coordinator) viewSizeTransitionState = ViewSizeTransitionState() coordinator.animate( alongsideTransition: { [weak self] context in guard let self = self else { return } guard let viewSizeTransitionState = self.viewSizeTransitionState else { return } if viewSizeTransitionState.displayLink == nil { viewSizeTransitionState.setupDisplayLink( forTarget: self, selector: #selector(trackTransitionProgress)) } }, completion: { [weak self] context in guard let self = self else { return } self.cleanupViewSizeTransitionState() // After the animation completion, ensure that ShadowTree state // is calculated relatively to the ancestor's frame by requesting // the state update. self.updateShadowTreeState() }) } private func cleanupViewSizeTransitionState() { viewSizeTransitionState?.invalidate() viewSizeTransitionState = nil } /// /// @brief This method is responsible for tracking animation frames and requests layout /// which will synchronize ShadowNode size with the animation frame size. /// @objc private func trackTransitionProgress() { if let currentFrame = view.layer.presentation()?.frame { viewSizeTransitionState?.lastViewPresentationFrame = currentFrame updateShadowTreeState() } } @objc public override func viewDidLayoutSubviews() { super.viewDidLayoutSubviews() updateShadowTreeState() } /// /// @brief Handles frame layout changes and updates Shadow Tree accordingly. /// /// Requests for the ShadowNode updates through the shadow state proxy. /// Differentiates cases when we're in the Host hierarchy to calculate frame relatively /// to the Host view from the modal case where we're passing absolute layout metrics to the ShadowNode. /// /// Prefers to apply dynamic updates from the presentation layer if the transition is in progress. /// private func updateShadowTreeState() { // For modals, which are presented outside the SplitViewHost subtree (and RN hierarchy), // we're attaching our touch handler and we don't need to apply any offset corrections, // because it's positioned relatively to our RNSSplitViewScreenComponentView if !isInSplitViewHostSubtree() { shadowStateProxy.updateShadowState(ofComponent: splitViewScreenComponentView) return } let ancestorView = findSplitViewHostController()?.view assert( ancestorView != nil, "[RNScreens] Expected to find RNSSplitViewHost component for RNSSplitViewScreen component" ) // If the resize animation is currently running, we prefer to apply dynamic updates, // based on the results from the presentation layer // which is read from `trackTransitionProgress` method. if let lastViewPresentationFrame = viewSizeTransitionState?.lastViewPresentationFrame, !lastViewPresentationFrame.isNull { shadowStateProxy.updateShadowState( ofComponent: splitViewScreenComponentView, withFrame: lastViewPresentationFrame, inContextOfAncestorView: ancestorView!) return } // There might be the case, when transition is about to start and in the meantime, // sth else is triggering frame update relatively to the parent. As we know // that dynamic updates from the presentation layer are coming, we're blocking this // to prevent interrupting with the frames that are less important for us. // This works fine, because after the animation completion, we're sending the last update // which is compatible with the frame which would be calculated relatively to the ancestor here. if !isViewSizeTransitionInProgress() { shadowStateProxy.updateShadowState( ofComponent: splitViewScreenComponentView, inContextOfAncestorView: ancestorView) } } /// /// @brief Request ShadowNode state update when the SplitView screen frame origin has changed. /// /// If there's a transition in progress, this function is ignored as we prefer to apply updates /// that are dynamically coming from the presentation layer, rather than reading the frame, because /// view's frame is set to the target value at the begining of the transition. /// /// @param splitViewController The UISplitViewController whose layout positioning changed, represented by RNSSplitViewHostController. /// func columnPositioningDidChangeIn(splitViewController: UISplitViewController) { // During the transition, we're listening for the animation // frame updates on the presentation layer and we're // treating these updates as the source of truth if !isViewSizeTransitionInProgress() { shadowStateProxy.updateShadowState( ofComponent: splitViewScreenComponentView, inContextOfAncestorView: splitViewController.view ) } } // MARK: Events public override func viewWillAppear(_ animated: Bool) { reactEventEmitter.emitOnWillAppear() } public override func viewDidAppear(_ animated: Bool) { reactEventEmitter.emitOnDidAppear() } public override func viewWillDisappear(_ animated: Bool) { reactEventEmitter.emitOnWillDisappear() } public override func viewDidDisappear(_ animated: Bool) { reactEventEmitter.emitOnDidDisappear() } } private class ViewSizeTransitionState { public var displayLink: CADisplayLink? public var lastViewPresentationFrame: CGRect = CGRect.null public func setupDisplayLink(forTarget target: Any, selector sel: Selector) { if displayLink != nil { displayLink?.invalidate() } displayLink = CADisplayLink(target: target, selector: sel) displayLink!.add(to: .main, forMode: .common) } public func invalidate() { displayLink?.invalidate() displayLink = nil lastViewPresentationFrame = CGRect.null } }