// // 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(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 } }