Tusker/Tusker/Screens/Utilities/EnhancedNavigationViewContr...

270 lines
9.8 KiB
Swift

//
// EnhancedNavigationViewController.swift
// Tusker
//
// Created by Shadowfacts on 2/19/20.
// Copyright © 2020 Shadowfacts. All rights reserved.
//
import UIKit
class EnhancedNavigationViewController: UINavigationController {
let useBrowserStyleNavigation = Preferences.shared.hasFeatureFlag(.iPadBrowserNavigation)
var poppedViewControllers = [UIViewController]()
var skipResetPoppedOnNextPush = false
#if !os(visionOS)
private var interactivePushTransition: InteractivePushTransition!
#endif
override var viewControllers: [UIViewController] {
didSet {
poppedViewControllers = []
if #available(iOS 16.0, *),
useBrowserStyleNavigation {
// TODO: this for loop might not be necessary
for vc in viewControllers {
configureNavItem(vc.navigationItem)
}
updateTopNavItemState()
}
}
}
override func viewDidLoad() {
super.viewDidLoad()
#if !os(visionOS)
self.interactivePushTransition = InteractivePushTransition(navigationController: self)
#endif
if #available(iOS 16.0, *),
useBrowserStyleNavigation,
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 inserting 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)
}
#if !os(visionOS)
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 if self.interactivePopGestureRecognizer?.state == .ended {
// 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
if !self.poppedViewControllers.isEmpty {
self.poppedViewControllers.remove(at: 0)
}
}
}
})
}
#endif
@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
}
}