Tusker/Tusker/Screens/Utilities/SplitNavigationController.s...

303 lines
13 KiB
Swift

//
// 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()
}
}
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
func popToRootViewController(animated: Bool) {
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()
}
}
}
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
owner.viewControllers.first?.view ?? 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()
}
}