260 lines
9.5 KiB
Swift
260 lines
9.5 KiB
Swift
//
|
|
// EnhancedNavigationViewController.swift
|
|
// Tusker
|
|
//
|
|
// Created by Shadowfacts on 2/19/20.
|
|
// Copyright © 2020 Shadowfacts. All rights reserved.
|
|
//
|
|
|
|
import UIKit
|
|
|
|
class EnhancedNavigationViewController: UINavigationController {
|
|
|
|
var useBrowserStyleNavigation = false
|
|
|
|
var poppedViewControllers = [UIViewController]()
|
|
var skipResetPoppedOnNextPush = false
|
|
|
|
private var interactivePushTransition: InteractivePushTransition!
|
|
|
|
override var viewControllers: [UIViewController] {
|
|
didSet {
|
|
poppedViewControllers = []
|
|
if #available(iOS 16.0, *) {
|
|
// TODO: this for loop might not be necessary
|
|
for vc in viewControllers {
|
|
configureNavItem(vc.navigationItem)
|
|
}
|
|
updateTopNavItemState()
|
|
}
|
|
}
|
|
}
|
|
|
|
override func viewDidLoad() {
|
|
super.viewDidLoad()
|
|
|
|
self.interactivePushTransition = InteractivePushTransition(navigationController: self)
|
|
|
|
if #available(iOS 16.0, *),
|
|
let topViewController {
|
|
configureNavItem(topViewController.navigationItem)
|
|
updateTopNavItemState()
|
|
}
|
|
}
|
|
|
|
override func popViewController(animated: Bool) -> UIViewController? {
|
|
let popped = performAfterAnimating(block: {
|
|
super.popViewController(animated: animated)
|
|
}, after: {
|
|
if #available(iOS 16.0, *) {
|
|
self.updateTopNavItemState()
|
|
}
|
|
}, animated: animated)
|
|
if let popped {
|
|
poppedViewControllers.insert(popped, at: 0)
|
|
}
|
|
return popped
|
|
}
|
|
|
|
override func popToRootViewController(animated: Bool) -> [UIViewController]? {
|
|
let popped = performAfterAnimating(block: {
|
|
super.popToRootViewController(animated: animated)
|
|
}, after: {
|
|
if #available(iOS 16.0, *) {
|
|
self.updateTopNavItemState()
|
|
}
|
|
}, animated: animated)
|
|
if let popped {
|
|
poppedViewControllers = popped
|
|
}
|
|
return popped
|
|
}
|
|
|
|
override func popToViewController(_ viewController: UIViewController, animated: Bool) -> [UIViewController]? {
|
|
let popped = performAfterAnimating(block: {
|
|
super.popToViewController(viewController, animated: animated)
|
|
}, after: {
|
|
if #available(iOS 16.0, *) {
|
|
self.updateTopNavItemState()
|
|
}
|
|
}, animated: animated)
|
|
if let popped {
|
|
poppedViewControllers.insert(contentsOf: popped, at: 0)
|
|
}
|
|
return popped
|
|
}
|
|
|
|
override func pushViewController(_ viewController: UIViewController, animated: Bool) {
|
|
if skipResetPoppedOnNextPush {
|
|
skipResetPoppedOnNextPush = false
|
|
} else {
|
|
self.poppedViewControllers = []
|
|
}
|
|
|
|
if #available(iOS 16.0, *) {
|
|
configureNavItem(viewController.navigationItem)
|
|
}
|
|
|
|
super.pushViewController(viewController, animated: animated)
|
|
|
|
if #available(iOS 16.0, *) {
|
|
updateTopNavItemState()
|
|
}
|
|
}
|
|
|
|
func pushPoppedViewController() {
|
|
guard !poppedViewControllers.isEmpty else {
|
|
return
|
|
}
|
|
skipResetPoppedOnNextPush = true
|
|
pushViewController(poppedViewControllers.removeFirst(), animated: true)
|
|
}
|
|
|
|
func pushToPoppedViewController(_ target: UIViewController) {
|
|
guard poppedViewControllers.contains(target) else {
|
|
return
|
|
}
|
|
var toInsert: [UIViewController] = []
|
|
while true {
|
|
let vc = poppedViewControllers.removeFirst()
|
|
if vc == target {
|
|
break
|
|
} else {
|
|
toInsert.append(vc)
|
|
}
|
|
}
|
|
// match the system behavior when popping multiple by animated-ly pushing the final destination one,
|
|
// and then intersiting the intermediary ones before it, as if they'd all been pushed together
|
|
performAfterAnimating(block: {
|
|
pushViewController(target, animated: true)
|
|
}, after: {
|
|
self.viewControllers.insert(contentsOf: toInsert, at: self.viewControllers.count - 1)
|
|
if #available(iOS 16.0, *) {
|
|
self.updateTopNavItemState()
|
|
}
|
|
}, animated: true)
|
|
}
|
|
|
|
func onWillShow() {
|
|
self.transitionCoordinator?.notifyWhenInteractionChanges({ (context) in
|
|
if context.isCancelled {
|
|
if self.interactivePushTransition.interactive {
|
|
// when an interactive push gesture is cancelled, make sure to adding the VC that was being pushed back onto the popped stack so it doesn't disappear
|
|
self.poppedViewControllers.insert(self.interactivePushTransition.pushingViewController!, at: 0)
|
|
} else {
|
|
// when an interactive pop gesture is cancelled (i.e. the user lifts their finger before it triggers),
|
|
// the popViewController(animated:) method has already been called so the VC has already been added to the popped stack
|
|
// so we make sure to remove it, otherwise there could be duplicate VCs on the navigation stasck
|
|
self.poppedViewControllers.remove(at: 0)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
@available(iOS 16.0, *)
|
|
private func configureNavItem(_ navItem: UINavigationItem) {
|
|
guard useBrowserStyleNavigation,
|
|
UIDevice.current.userInterfaceIdiom != .phone else {
|
|
return
|
|
}
|
|
navItem.style = .browser
|
|
navItem.hidesBackButton = true
|
|
if let titleView = navItem.titleView,
|
|
titleView.tag != ViewTags.navEmptyTitleView {
|
|
// blergh, i don't like changing this out from under some other view controller
|
|
// we use an empty view because otherwise the title label displays in addition to the new title view bar button item
|
|
navItem.titleView = UIView()
|
|
navItem.titleView?.tag = ViewTags.navEmptyTitleView
|
|
// TODO: centerItemGroups don't animate out during nav transitions, the just (dis)appear abruptly
|
|
navItem.centerItemGroups = [
|
|
.fixedGroup(items: [UIBarButtonItem(customView: titleView)])
|
|
]
|
|
}
|
|
let back = UIBarButtonItem(image: UIImage(systemName: "chevron.backward"), style: .plain, target: self, action: #selector(goBack))
|
|
back.tag = ViewTags.navBackBarButton
|
|
back.menu = UIMenu(children: [
|
|
UIDeferredMenuElement({ [unowned self] completion in
|
|
completion(self.viewControllers.dropLast(1).reversed().map { vc in
|
|
UIAction(title: vc.navigationItem.title ?? "Back") { [weak self] _ in
|
|
_ = self?.popToViewController(vc, animated: true)
|
|
}
|
|
})
|
|
})
|
|
])
|
|
let forward = UIBarButtonItem(image: UIImage(systemName: "chevron.forward"), style: .plain, target: self, action: #selector(goForward))
|
|
forward.tag = ViewTags.navForwardBarButton
|
|
forward.menu = UIMenu(children: [
|
|
UIDeferredMenuElement.uncached({ [unowned self] completion in
|
|
completion(poppedViewControllers.map { vc in
|
|
UIAction(title: vc.navigationItem.title ?? "Forward") { [weak self] _ in
|
|
self?.pushToPoppedViewController(vc)
|
|
}
|
|
})
|
|
})
|
|
])
|
|
navItem.leadingItemGroups = [
|
|
.fixedGroup(items: [
|
|
back,
|
|
forward,
|
|
])
|
|
]
|
|
}
|
|
|
|
@available(iOS 16.0, *)
|
|
private func updateTopNavItemState() {
|
|
guard useBrowserStyleNavigation,
|
|
UIDevice.current.userInterfaceIdiom != .phone,
|
|
let vc = topViewController,
|
|
let group = vc.navigationItem.leadingItemGroups.first,
|
|
group.barButtonItems.count == 2,
|
|
group.barButtonItems[0].tag == ViewTags.navBackBarButton,
|
|
group.barButtonItems[1].tag == ViewTags.navForwardBarButton else {
|
|
return
|
|
}
|
|
group.barButtonItems[0].isEnabled = viewControllers.count > 1
|
|
group.barButtonItems[1].isEnabled = !poppedViewControllers.isEmpty
|
|
}
|
|
|
|
private func performAfterAnimating<R>(block: () -> R, after: @escaping () -> Void, animated: Bool) -> R {
|
|
if animated {
|
|
CATransaction.begin()
|
|
let result = block()
|
|
CATransaction.setCompletionBlock {
|
|
after()
|
|
}
|
|
CATransaction.commit()
|
|
return result
|
|
} else {
|
|
let result = block()
|
|
after()
|
|
return result
|
|
}
|
|
}
|
|
|
|
@objc private func goBack() {
|
|
_ = popViewController(animated: true)
|
|
}
|
|
|
|
@objc private func goForward() {
|
|
pushPoppedViewController()
|
|
}
|
|
|
|
}
|
|
|
|
extension EnhancedNavigationViewController: BackgroundableViewController {
|
|
func sceneDidEnterBackground() {
|
|
if let topVC = topViewController as? BackgroundableViewController {
|
|
topVC.sceneDidEnterBackground()
|
|
}
|
|
}
|
|
}
|
|
|
|
extension EnhancedNavigationViewController: StatusBarTappableViewController {
|
|
func handleStatusBarTapped(xPosition: CGFloat) -> StatusBarTapActionResult {
|
|
if let topVC = topViewController as? StatusBarTappableViewController {
|
|
return topVC.handleStatusBarTapped(xPosition: xPosition)
|
|
}
|
|
return .continue
|
|
}
|
|
}
|