Signal-iOS/SignalUI/ViewControllers/OWSNavigationController.swift
Adam Sharp 5f1820748e Don't ignore theme changes when switching to system interface style
When switching to the "system" theme mode, if the effective appearance matched
the previous overridden theme mode, the theme change would be ignored. By
comparing the mode instead of just the effective appearance, we properly react
to system appearance changes.

Additionally, rework the transition animation for changing themes to match the
animation of system appearance changes (i.e., a 0.5-second cross-dissolve).

Fixes: https://signalmessenger.atlassian.net/browse/IOS-4431
2024-05-13 16:16:53 -05:00

336 lines
12 KiB
Swift

//
// 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
) {
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
}
}