// // Copyright 2022 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only // import SignalServiceKit /// Any view controller which wants to be able cancel back button /// presses and back gestures should implement this protocol. public protocol OWSNavigationChildController: AnyObject { /// If non-nil, will use the provided child (should be a child view controller) for /// all other protocol methods. var childForOWSNavigationConfiguration: OWSNavigationChildController? { get } /// Will be called if the back button was pressed or if a back gesture /// was performed but not if the view is popped programmatically. /// Default false. var shouldCancelNavigationBack: Bool { get } /// The style to apply to the nav bar on view appearance in the navigation stack. /// Defaults to `blur`. var preferredNavigationBarStyle: OWSNavigationBarStyle { get } /// A background color to use for the navbar in certain styles. /// Defaults to nil (default color for style) var navbarBackgroundColorOverride: UIColor? { get } /// A tint color to use for the navbar in certain styles. /// Defaults to nil (default color for style) var navbarTintColorOverride: UIColor? { get } /// Whether the navigation bar should show or hide when this view controller appears. /// Defaults to false. var prefersNavigationBarHidden: Bool { get } } extension OWSNavigationChildController { public var childForOWSNavigationConfiguration: OWSNavigationChildController? { nil } public var shouldCancelNavigationBack: Bool { false } public var preferredNavigationBarStyle: OWSNavigationBarStyle { .blur } public var navbarBackgroundColorOverride: UIColor? { nil } public var navbarTintColorOverride: UIColor? { nil } public var prefersNavigationBarHidden: Bool { false } } /// This navigation controller subclass should be used anywhere we might /// want to cancel back button presses or back gestures due to, for example, /// unsaved changes. open class OWSNavigationController: UINavigationController { private var owsNavigationBar: OWSNavigationBar { return navigationBar as! OWSNavigationBar } private weak var externalDelegate: UINavigationControllerDelegate? public override var delegate: UINavigationControllerDelegate? { get { return externalDelegate } set { if newValue === self { owsFailDebug("Self is already the delegate! Override methods instead.") return } externalDelegate = newValue } } public init() { super.init(navigationBarClass: OWSNavigationBar.self, toolbarClass: nil) super.delegate = self NotificationCenter.default.addObserver( self, selector: #selector(themeDidChange), name: .themeDidChange, object: nil ) } public override convenience init(rootViewController: UIViewController) { self.init() self.pushViewController(rootViewController, animated: false) } @available(*, unavailable) public required init?(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } open override var supportedInterfaceOrientations: UIInterfaceOrientationMask { if let delegateOrientations = self.delegate?.navigationControllerSupportedInterfaceOrientations?(self) { return delegateOrientations } else if let visibleViewController = self.visibleViewController { return visibleViewController.supportedInterfaceOrientations } else { return UIDevice.current.defaultSupportedOrientations } } open override func viewDidLoad() { super.viewDidLoad() interactivePopGestureRecognizer?.delegate = self } open override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) updateNavbarAppearance(animated: animated) } // MARK: - Theme and Style @objc private func themeDidChange() { updateNavbarAppearance() } public override var preferredStatusBarStyle: UIStatusBarStyle { if let forcedStyle = owsNavigationBar.forcedStatusBarStyle { return forcedStyle } if !CurrentAppContext().isMainApp { return super.preferredStatusBarStyle } else if let presentedViewController = self.presentedViewController { return presentedViewController.preferredStatusBarStyle } else { return Theme.isDarkThemeEnabled ? .lightContent : .darkContent } } /// Apply any changes to navbar appearance from the top view controller in the stack. /// Changes will be automatically applied when a view controller is pushed or popped; /// this method is just for use if state changes while the view is on screen. public func updateNavbarAppearance(animated: Bool = UIView.areAnimationsEnabled) { if let topViewController = topViewController { updateNavbarAppearance(for: topViewController, fromViewControllerTransition: false, animated: animated) } } private func updateNavbarAppearance( for viewController: UIViewController, fromViewControllerTransition: Bool, animated: Bool ) { // If currently presenting or dismissing, animating these changes looks off. // In these cases, force the changes to apply un-animated. let animated = animated && !(self.isBeingPresented || self.isBeingDismissed) let navChildController = viewController.getFinalNavigationChildController() let shouldHideNavbar = navChildController?.prefersNavigationBarHidden ?? false if !shouldHideNavbar { // Only update visible attributes if we aren't hiding; if its hidden anyway // they won't matter and seeing them blink then hide is weird. owsNavigationBar.navbarBackgroundColorOverride = navChildController?.navbarBackgroundColorOverride owsNavigationBar.navbarTintColorOverride = navChildController?.navbarTintColorOverride owsNavigationBar.setStyle(navChildController?.preferredNavigationBarStyle ?? .blur, animated: animated) } // NOTE: UIKit sets isNavigationBarHidden immediately at the start of // setNavigationBarHidden, without waiting for the animation to complete. // If UIKit didn't do that, we'd have a race condition where we hide it, // then unhide before the animation finishes, but get stale state. // UIKit saves us this headache. // Only update when necessary to preserve performance and safe area changes. if shouldHideNavbar != isNavigationBarHidden { // Don't do our custom shenanigans if we are changing the hidden state // as a result of view controler transitions. if fromViewControllerTransition { super.setNavigationBarHidden(shouldHideNavbar, animated: animated) } else { self.setNavigationBarHidden(shouldHideNavbar, animated: animated) } } } override open func setNavigationBarHidden(_ hidden: Bool, animated: Bool) { guard hidden != self.isNavigationBarHidden else { return } guard animated else { super.setNavigationBarHidden(hidden, animated: false) return } if !hidden { // When showing, immediately show it first so the sizing of child views works, // then apply transition animations. super.setNavigationBarHidden(hidden, animated: false) } UIView.transition( with: self.view, duration: Self.hideShowBarDuration, options: .transitionCrossDissolve, animations: { super.setNavigationBarHidden(hidden, animated: false) } ) } } // MARK: - UIGestureRecognizerDelegate extension OWSNavigationController: UIGestureRecognizerDelegate { public func position(for bar: UIBarPositioning) -> UIBarPosition { return .topAttached } public func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { owsAssertDebug(gestureRecognizer === self.interactivePopGestureRecognizer) guard viewControllers.count > 1 else { return false } if let child = topViewController?.getFinalNavigationChildController() { return !child.shouldCancelNavigationBack } else { return topViewController != viewControllers.first } } } // MARK: - UINavigationBarDelegate extension OWSNavigationController: UINavigationBarDelegate { // All OWSNavigationController serve as the UINavigationBarDelegate for their navbar. // We override shouldPopItem: in order to cancel some back button presses - for example, // if a view has unsaved changes. public func navigationBar(_ navigationBar: UINavigationBar, shouldPop item: UINavigationItem) -> Bool { owsAssertDebug(interactivePopGestureRecognizer?.delegate === self) // wasBackButtonClicked is true if the back button was pressed but not // if a back gesture was performed or if the view is popped programmatically. let wasBackButtonClicked = topViewController?.navigationItem == item if wasBackButtonClicked, let child = topViewController?.getFinalNavigationChildController() { return !child.shouldCancelNavigationBack } return true } } // MARK: - UINavigationControllerDelegate extension OWSNavigationController: UINavigationControllerDelegate { public func navigationController( _ navigationController: UINavigationController, willShow viewController: UIViewController, animated: Bool ) { // The `viewController` parameter is non-Optional. It is annotated as such // in Apple's header. However, on iOS 16, they pass `nil`, and that causes // our code to blow up. Detect when they've given us nil in a non-Optional // parameter and avoid calling the method that causes things to blow up. if let viewController = viewController as AnyObject as? UIViewController { updateNavbarAppearance(for: viewController, fromViewControllerTransition: true, animated: animated) } externalDelegate?.navigationController?(navigationController, willShow: viewController, animated: animated) } public func navigationController( _ navigationController: UINavigationController, didShow viewController: UIViewController, animated: Bool ) { externalDelegate?.navigationController?(navigationController, didShow: viewController, animated: animated) } public func navigationController( _ navigationController: UINavigationController, animationControllerFor operation: UINavigationController.Operation, from fromVC: UIViewController, to toVC: UIViewController ) -> UIViewControllerAnimatedTransitioning? { return externalDelegate?.navigationController?( navigationController, animationControllerFor: operation, from: fromVC, to: toVC ) } public func navigationController( _ navigationController: UINavigationController, interactionControllerFor animationController: UIViewControllerAnimatedTransitioning ) -> UIViewControllerInteractiveTransitioning? { return externalDelegate?.navigationController?( navigationController, interactionControllerFor: animationController ) } public func navigationControllerPreferredInterfaceOrientationForPresentation( _ navigationController: UINavigationController ) -> UIInterfaceOrientation { return externalDelegate?.navigationControllerPreferredInterfaceOrientationForPresentation?( navigationController ) ?? .portrait } public func navigationControllerSupportedInterfaceOrientations( _ navigationController: UINavigationController ) -> UIInterfaceOrientationMask { return externalDelegate?.navigationControllerSupportedInterfaceOrientations?( navigationController ) ?? supportedInterfaceOrientations } } // MARK: - OWSNavigationChildController children extension UIViewController { func getFinalNavigationChildController() -> OWSNavigationChildController? { guard let child = self as? OWSNavigationChildController else { return nil } return child.getFinalChild() } } extension OWSNavigationChildController { func getFinalChild() -> OWSNavigationChildController { if let child = childForOWSNavigationConfiguration { return child.getFinalChild() } return self } }