Signal-iOS/SignalUI/ViewControllers/OWSNavigationController.swift
2023-06-05 16:18:17 -07:00

364 lines
14 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import Foundation
import UIKit
/// 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 }
/// 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 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: OWSNavigationControllerBase {
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
)
}
// On iOS 12, init(navigationBarClass:toolbarClass:) calls
// init(nibName:bundle:). In the latest iOS SDK, these are both marked as
// designated initializers, so that shouldn't be allowed. In Objective-C,
// this resolves to the superclass implementation and behaves properly, but
// in Swift, it results in a crash. A no-op implementation avoids the crash
// and results in the same behavior as in Objective-C.
//
// Subclass are required to implement this initializer if they implement
// any other initializer. However, the initializer should *always* be an
// empty shim that calls `super`. The compiler will force you to initialize
// all ivars before calling `super` -- dont do that. Instead, make ivars
// `var` or optional so they dont need to be modified in this initializer.
public required override init(nibName nibNameOrNil: String?, bundle nibBundleOrNil: Bundle?) {
if #available(iOS 13, *) {
owsFailDebug("This initializer should never be explicitly executed.")
}
super.init(nibName: nibNameOrNil, bundle: nibBundleOrNil)
}
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 if #available(iOS 13, *) {
return Theme.isDarkThemeEnabled ? .lightContent : .darkContent
} else {
return Theme.isDarkThemeEnabled ? .lightContent : 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 = false) {
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.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
var result = true
if wasBackButtonClicked {
if let child = topViewController?.getFinalNavigationChildController() {
result = !child.shouldCancelNavigationBack
}
}
// If we're not going to cancel the pop/back, we need to call the super
// implementation since it has important side effects.
if result {
// NOTE: result might end up false if the super implementation cancels the
// the pop/back.
super.ows_navigationBar(navigationBar, shouldPop: item)
result = true
}
return result
}
}
// MARK: - UINavigationControllerDelegate
extension OWSNavigationController: UINavigationControllerDelegate {
public func navigationController(
_ navigationController: UINavigationController,
willShow viewController: UIViewController,
animated: Bool
) {
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
}
}