Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
signalapp
GitHub Repository: signalapp/Signal-iOS
Path: blob/main/SignalUI/ViewControllers/OWSNavigationController.swift
1 views
//
// 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?

    override public var delegate: UINavigationControllerDelegate? {
        get {
            return externalDelegate
        }
        set {
            if newValue === self {
                owsFailDebug("Self is already the delegate! Override methods instead.")
                return
            }
            externalDelegate = newValue
        }
    }

    public init() {
        if #available(iOS 26, *) {
            super.init(nibName: nil, bundle: nil)
        } else {
            super.init(navigationBarClass: OWSNavigationBar.self, toolbarClass: nil)
        }

        super.delegate = self

        NotificationCenter.default.addObserver(
            self,
            selector: #selector(themeDidChange),
            name: .themeDidChange,
            object: nil,
        )
    }

    override public 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")
    }

    override open 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
        }
    }

    override open func viewDidLoad() {
        super.viewDidLoad()

        interactivePopGestureRecognizer?.delegate = self
        if #available(iOS 26.0, *) {
            interactiveContentPopGestureRecognizer?.delegate = self
        }
    }

    override open func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)

        updateNavbarAppearance(animated: animated)
    }

    // MARK: - Theme and Style

    @objc
    private func themeDidChange() {
        updateNavbarAppearance()
    }

    override public 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 super.preferredStatusBarStyle
        }
    }

    /// 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 {
            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, let owsNavigationBar {
            // 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 {
        if #available(iOS 26.0, *) {
            owsAssertDebug(gestureRecognizer === self.interactivePopGestureRecognizer || gestureRecognizer === self.interactiveContentPopGestureRecognizer)
        } else {
            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)
        if #available(iOS 26.0, *) {
            owsAssertDebug(interactiveContentPopGestureRecognizer?.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
    }
}