356 lines
14 KiB
Swift
356 lines
14 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)
|
|
SplitNavigationController.clearSelectedRow(sender: sender)
|
|
} 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")
|
|
}
|
|
|
|
static func clearSelectedRow(sender: Any?) {
|
|
// 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) }
|
|
}
|
|
}
|
|
}
|
|
|
|
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 {
|
|
for vc in oldValue where vc.parent !== self {
|
|
removeSecondarySplitCloseButton(for: vc)
|
|
}
|
|
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
|
|
}
|
|
}
|
|
|
|
override func pushViewController(_ viewController: UIViewController, animated: Bool) {
|
|
if viewControllers.isEmpty {
|
|
configureSecondarySplitCloseButton(for: viewController)
|
|
}
|
|
|
|
super.pushViewController(viewController, animated: animated)
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
private func removeSecondarySplitCloseButton(for viewController: UIViewController) {
|
|
if viewController.navigationItem.leftBarButtonItem?.tag == ViewTags.splitNavCloseSecondaryButton {
|
|
viewController.navigationItem.leftBarButtonItem = nil
|
|
}
|
|
}
|
|
|
|
@objc private func closeSecondary() {
|
|
closeSecondaryImpl()
|
|
}
|
|
|
|
}
|
|
|
|
@MainActor
|
|
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
|
|
}
|
|
}
|
|
}
|