// // SplitNavigationController.swift // Tusker // // Created by Shadowfacts on 7/1/22. // Copyright © 2022 Shadowfacts. All rights reserved. // import UIKit class SplitNavigationController: UIViewController { private let rootNav = SplitRootNavigationController() private let secondaryNav = SplitSecondaryNavigationController() private let separatorView = UIView() private var constraints: [NSLayoutConstraint] = [] var viewControllers: [UIViewController] { get { return rootNav.viewControllers + secondaryNav.viewControllers } set { if newValue.isEmpty { rootNav.viewControllers = [] secondaryNav.viewControllers = [] } else if canShowSecondaryNav { var newValue = newValue rootNav.viewControllers = [newValue.removeFirst()] secondaryNav.viewControllers = newValue } else { rootNav.viewControllers = newValue secondaryNav.viewControllers = [] } updateSecondaryNavVisibility() } } /// This property is only valid after the view has been laid out. private var canShowSecondaryNav: Bool { // minimum of 360pt for each column // this allows split navigation on all ipads in portrait w/ sidebar hidden and in landscape (regardless of sidebar) (viewIfLoaded?.bounds.width ?? 0) >= 720 } init(rootViewController: UIViewController? = nil) { super.init(nibName: nil, bundle: nil) rootNav.showImpl = { [unowned self] vc, sender in if self.canShowSecondaryNav { self.setSecondaryViewControllers([vc], animated: true) // the split nav shouldn't really be reaching down into the inner VCs like this, // but I can't think of a cleaner way if let tableVC = sender as? UITableViewController, let selectedIndexPath = tableVC.tableView.indexPathForSelectedRow { tableVC.tableView.deselectRow(at: selectedIndexPath, animated: true) } else if let sender = sender as? UIViewController, let collectionView = (sender as? CollectionViewController)?.collectionView ?? sender.view as? UICollectionView { // the collection view's animation speed is weirdly fast, so we do it slower UIView.animate(withDuration: 0.5, delay: 0) { collectionView.indexPathsForSelectedItems?.forEach { collectionView.deselectItem(at: $0, animated: false) } } } } else { self.rootNav.pushViewController(vc, animated: true) } } secondaryNav.owner = self secondaryNav.closeSecondaryImpl = { [unowned self] in self.popToRootViewController(animated: true) } if let rootViewController { rootNav.viewControllers = [rootViewController] } // add the child VCs here, rather than in viewDidLoad, because this VC is added to the UISplitViewController, // it needs a UINavigationController to be this VC's first child, otherwise it will embed this VC inside // yet another UINavigationController, which can then cause a crash when we try to embed a nav controller inside // of ourself (because nested nav controllers are forbidden) // and because of that, the view needs to be added here, in between the addChild/didMove(toParent:) calls // and so the view needs to be loaded immediately loadViewIfNeeded() addChild(rootNav) rootNav.view.translatesAutoresizingMaskIntoConstraints = false view.addSubview(rootNav.view) rootNav.didMove(toParent: self) addChild(secondaryNav) secondaryNav.view.translatesAutoresizingMaskIntoConstraints = false view.addSubview(secondaryNav.view) secondaryNav.didMove(toParent: self) separatorView.backgroundColor = .separator separatorView.translatesAutoresizingMaskIntoConstraints = false view.addSubview(separatorView) NSLayoutConstraint.activate([ rootNav.view.topAnchor.constraint(equalTo: view.topAnchor), rootNav.view.bottomAnchor.constraint(equalTo: view.bottomAnchor), rootNav.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), separatorView.topAnchor.constraint(equalTo: view.topAnchor), separatorView.bottomAnchor.constraint(equalTo: view.bottomAnchor), separatorView.leadingAnchor.constraint(equalTo: rootNav.view.trailingAnchor), separatorView.widthAnchor.constraint(equalToConstant: 0.5), secondaryNav.view.topAnchor.constraint(equalTo: view.topAnchor), secondaryNav.view.bottomAnchor.constraint(equalTo: view.bottomAnchor), secondaryNav.view.leadingAnchor.constraint(equalTo: separatorView.trailingAnchor), ]) updateSecondaryNavVisibility() } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func viewDidLoad() { super.viewDidLoad() // NOTE: as explained by the large comment above, viewDidLoad is called during initialization, and so things may not be fully setup when it is } override func show(_ vc: UIViewController, sender: Any?) { if !canShowSecondaryNav { rootNav.pushViewController(vc, animated: true) } else if rootNav.viewControllers.isEmpty { rootNav.pushViewController(vc, animated: false) } else { secondaryNav.pushViewController(vc, animated: true) } updateSecondaryNavVisibility() } override func viewWillLayoutSubviews() { super.viewWillLayoutSubviews() if !isLayingOutForAnimation { updateSecondaryNavVisibility() } } func pushViewController(_ vc: UIViewController, animated: Bool) { if !canShowSecondaryNav { rootNav.pushViewController(vc, animated: animated) } else if rootNav.viewControllers.isEmpty { rootNav.pushViewController(vc, animated: false) } else { secondaryNav.pushViewController(vc, animated: animated) } updateSecondaryNavVisibility() } private func updateSecondaryNavVisibility() { guard isViewLoaded else { return } if canShowSecondaryNav { if rootNav.viewControllers.count > 1 { var vcs = rootNav.viewControllers let root = vcs.removeFirst() rootNav.viewControllers = [root] // this shouldn't be necessary since the vcs are removed from their parent vc by setting rootNav.viewControllers // but it doesn't remove the views from their superview (until the next runloop iteration?) // so we need to do that ourselves before we can set them on the secondary nav (otherwise it raises an exception) vcs.forEach { $0.removeViewAndController() } secondaryNav.viewControllers = vcs } } else { if !secondaryNav.viewControllers.isEmpty { let firstSecondary = secondaryNav.viewControllers.first! // remove the left bar button item so that the builtin Back item shows if firstSecondary.navigationItem.leftBarButtonItem?.tag == ViewTags.splitNavCloseSecondaryButton { firstSecondary.navigationItem.leftBarButtonItem = nil } rootNav.viewControllers.append(contentsOf: secondaryNav.viewControllers) secondaryNav.viewControllers = [] } } setSecondaryVisible(canShowSecondaryNav && !secondaryNav.viewControllers.isEmpty) } private func setSecondaryVisible(_ visible: Bool) { guard isViewLoaded else { return } NSLayoutConstraint.deactivate(constraints) if visible { constraints = [ rootNav.view.trailingAnchor.constraint(equalTo: view.centerXAnchor), secondaryNav.view.trailingAnchor.constraint(equalTo: view.trailingAnchor), ] } else { constraints = [ rootNav.view.trailingAnchor.constraint(equalTo: view.trailingAnchor), secondaryNav.view.widthAnchor.constraint(equalTo: view.widthAnchor, multiplier: 0.5), ] } NSLayoutConstraint.activate(constraints) } private func setSecondaryViewControllers(_ vcs: [UIViewController], animated: Bool) { if animated { if vcs.isEmpty { popToRootViewController(animated: true) } else { let wasVisible = !secondaryNav.viewControllers.isEmpty secondaryNav.viewControllers = vcs secondaryNav.view.frame = CGRect(x: view.bounds.width, y: 0, width: view.bounds.width / 2, height: view.bounds.height) secondaryNav.view.layoutIfNeeded() if !wasVisible { let animator = UIViewPropertyAnimator(duration: 0.35, curve: .easeInOut) { self.updateSecondaryNavVisibility() self.view.layoutIfNeeded() } animator.startAnimation() } } } else { secondaryNav.viewControllers = vcs updateSecondaryNavVisibility() } } private var isLayingOutForAnimation = false @discardableResult func popToRootViewController(animated: Bool) -> [UIViewController]? { let vcs = secondaryNav.viewControllers if animated { // we don't update secondaryNav.viewControllers until after the animation is completed // otherwise the secondary nav's contents disappear immediately, rather than sliding off-screen let animator = UIViewPropertyAnimator(duration: 0.35, curve: .easeInOut) { self.isLayingOutForAnimation = true self.setSecondaryVisible(false) self.view.layoutIfNeeded() } animator.addCompletion { _ in self.secondaryNav.viewControllers = [] self.isLayingOutForAnimation = false // self.updateSecondaryNavVisibility() } animator.startAnimation() } else { self.secondaryNav.viewControllers = [] self.updateSecondaryNavVisibility() } return vcs } } extension SplitNavigationController: StatusBarTappableViewController { func handleStatusBarTapped(xPosition: CGFloat) -> StatusBarTapActionResult { let vcs = viewControllers if !canShowSecondaryNav || vcs.count < 2 { return (vcs.last! as? StatusBarTappableViewController)?.handleStatusBarTapped(xPosition: xPosition) ?? .continue } else { let positionInRoot = rootNav.view.convert(CGPoint(x: xPosition, y: 0), from: view) let positionInSecondary = secondaryNav.view.convert(CGPoint(x: xPosition, y: 0), from: view) if rootNav.view.bounds.contains(positionInRoot) { return (rootNav.topViewController as? StatusBarTappableViewController)?.handleStatusBarTapped(xPosition: positionInRoot.x) ?? .continue } else if secondaryNav.view.bounds.contains(positionInSecondary) { return (secondaryNav.topViewController as? StatusBarTappableViewController)?.handleStatusBarTapped(xPosition: positionInRoot.x) ?? .continue } } return .continue } } private class SplitRootNavigationController: UINavigationController { fileprivate var showImpl: ((UIViewController, Any?) -> Void)! override func show(_ vc: UIViewController, sender: Any?) { showImpl(vc, sender) } } private class SplitSecondaryNavigationController: EnhancedNavigationViewController { fileprivate unowned var owner: SplitNavigationController! fileprivate var closeSecondaryImpl: (() -> Void)! override var viewControllers: [UIViewController] { didSet { if let first = viewControllers.first { configureSecondarySplitCloseButton(for: first) } } } override var next: UIResponder? { // ordinarily, the next responder in the chain would be the SplitNavigationController's view // but that would bypass the VC in the root nav, so we reroute the repsonder chain to include it // first seems to be nil when using the view debugger for some reason, so in that case, defer to super if let root = owner.viewControllers.first { return root.innermostResponder() ?? super.next } else { return super.next } } private func configureSecondarySplitCloseButton(for viewController: UIViewController) { guard viewController.navigationItem.leftBarButtonItem?.tag != ViewTags.splitNavCloseSecondaryButton else { return } let item = UIBarButtonItem(title: "Close", style: .done, target: self, action: #selector(closeSecondary)) item.tag = ViewTags.splitNavCloseSecondaryButton viewController.navigationItem.leftBarButtonItem = item } @objc private func closeSecondary() { closeSecondaryImpl() } } protocol NestedResponderProvider { var innerResponder: UIResponder? { get } } extension UIResponder { func innermostResponder() -> UIResponder? { if let nestedProvider = self as? NestedResponderProvider { return nestedProvider.innerResponder?.innermostResponder() ?? self } else { return self } } }