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
414 lines
15 KiB
Swift
414 lines
15 KiB
Swift
//
|
|
// Copyright 2018 Signal Messenger, LLC
|
|
// SPDX-License-Identifier: AGPL-3.0-only
|
|
//
|
|
|
|
import SignalServiceKit
|
|
|
|
public enum OWSNavigationBarStyle: Int {
|
|
case solid, alwaysDark, blur
|
|
|
|
var forcedStatusBarStyle: UIStatusBarStyle? {
|
|
switch self {
|
|
case .solid:
|
|
return nil
|
|
case .alwaysDark:
|
|
return .lightContent
|
|
case .blur:
|
|
return nil
|
|
}
|
|
}
|
|
}
|
|
|
|
public class OWSNavigationBar: UINavigationBar {
|
|
|
|
public var fullWidth: CGFloat {
|
|
return superview?.frame.width ?? .zero
|
|
}
|
|
|
|
public required init?(coder aDecoder: NSCoder) {
|
|
fatalError("init(coder:) has not been implemented")
|
|
}
|
|
|
|
/// This is the alpha we apply to UIVisualEffectView colors to achieve the blur effect we want.
|
|
public static let backgroundBlurMutingFactor: CGFloat = 0.5
|
|
|
|
/// This is the reference to the UIVisualEffectView we create ourselves on iOS 14 and earlier.
|
|
/// We own the layout and properties of this view.
|
|
private var manualBlurEffectView: UIVisualEffectView?
|
|
|
|
/// This is a reference to the UIVisualEffectView UIKit creates from UINavigationBarAppearance.
|
|
/// We find it by walking through the view hierarchy. We do not lay this view out, but we
|
|
/// need to modify its properties to get the blur effect we want.
|
|
private var appearanceBlurEffectView: UIVisualEffectView? {
|
|
didSet {
|
|
appearanceBlurEffectViewSublayerObservation?.invalidate()
|
|
appearanceBlurEffectViewSublayerObservation = nil
|
|
appearanceBlurEffectViewBackgroundColorObservation?.invalidate()
|
|
appearanceBlurEffectViewBackgroundColorObservation = nil
|
|
}
|
|
}
|
|
/// These are KVO observations we keep on the nodes in the view heirarchy graph so
|
|
/// we can keep our `appearanceBlurEffectView` reference up to date.
|
|
private var backgroundSublayerObservation: NSKeyValueObservation?
|
|
private var appearanceBlurEffectViewSublayerObservation: NSKeyValueObservation?
|
|
private var appearanceBlurEffectViewBackgroundColorObservation: NSKeyValueObservation?
|
|
|
|
override init(frame: CGRect) {
|
|
super.init(frame: frame)
|
|
|
|
updateAppearance(animated: false)
|
|
}
|
|
|
|
public override func didAddSubview(_ subview: UIView) {
|
|
super.didAddSubview(subview)
|
|
|
|
if #available(iOS 15, *) {
|
|
if String(describing: type(of: subview)) == "_UIBarBackground" {
|
|
// The background gets created before the UIVisualEffectView that gets added
|
|
// as a subview of it, not of the root. This gives us a hook to know
|
|
// when the UIVisualEffectView gets added.
|
|
backgroundSublayerObservation?.invalidate()
|
|
backgroundSublayerObservation = subview.layer.observe(\.sublayers) { [weak self] _, _ in
|
|
self?.setAppearanceBlurViewIfExists()
|
|
}
|
|
}
|
|
|
|
setAppearanceBlurViewIfExists()
|
|
}
|
|
}
|
|
|
|
public override var barPosition: UIBarPosition {
|
|
return .topAttached
|
|
}
|
|
|
|
public var forcedStatusBarStyle: UIStatusBarStyle? {
|
|
return style?.forcedStatusBarStyle
|
|
}
|
|
|
|
// MARK: Background Color
|
|
|
|
internal var navbarBackgroundColorOverride: UIColor?
|
|
internal var navbarTintColorOverride: UIColor?
|
|
|
|
private var navbarBackgroundColor: UIColor {
|
|
return navbarBackgroundColorOverride ?? Theme.navbarBackgroundColor
|
|
}
|
|
|
|
private var navbarTintColor: UIColor {
|
|
return navbarTintColorOverride ?? Theme.primaryTextColor
|
|
}
|
|
|
|
// MARK: Appearance
|
|
|
|
private var style: OWSNavigationBarStyle?
|
|
private var appearance: OWSNavigationBarAppearance?
|
|
|
|
internal func setStyle(_ style: OWSNavigationBarStyle, animated: Bool) {
|
|
self.style = style
|
|
updateAppearance(animated: animated)
|
|
}
|
|
|
|
private func updateAppearance(animated: Bool) {
|
|
AssertIsOnMainThread()
|
|
|
|
let appearance = OWSNavigationBarAppearance.appearance(
|
|
for: style ?? .blur,
|
|
navbarBackgroundColor: navbarBackgroundColor,
|
|
tintColor: navbarTintColor
|
|
)
|
|
|
|
guard appearance != self.appearance else {
|
|
return
|
|
}
|
|
|
|
if animated {
|
|
UIView.transition(with: self, duration: 0 /* inherit */, options: .transitionCrossDissolve) {
|
|
appearance.apply(to: self)
|
|
self.appearance = appearance
|
|
}
|
|
} else {
|
|
appearance.apply(to: self)
|
|
self.appearance = appearance
|
|
}
|
|
}
|
|
|
|
// MARK: - iOS >15 blur hacks
|
|
|
|
@available(iOS 15, *)
|
|
fileprivate func setAppearanceBlurViewIfExists() {
|
|
var subviews: [UIView] = [self]
|
|
while let subview = subviews.popLast() {
|
|
if let blurView = subview as? UIVisualEffectView {
|
|
self.appearanceBlurEffectView = blurView
|
|
self.updateAppearanceBlurView()
|
|
break
|
|
}
|
|
subviews.append(contentsOf: subview.subviews)
|
|
}
|
|
}
|
|
|
|
@available(iOS 15, *)
|
|
fileprivate func updateAppearanceBlurView() {
|
|
guard let appearanceBlurEffectView else {
|
|
appearanceBlurEffectView = nil
|
|
return
|
|
}
|
|
guard let tintingView = appearanceBlurEffectView.tintingView else {
|
|
appearanceBlurEffectViewBackgroundColorObservation?.invalidate()
|
|
appearanceBlurEffectViewBackgroundColorObservation = nil
|
|
if appearanceBlurEffectViewSublayerObservation == nil {
|
|
// The blur view gets created without its subviews at first, but its those
|
|
// subviews we need to adjust. This gives us a hook to know when they get added.
|
|
appearanceBlurEffectViewSublayerObservation?.invalidate()
|
|
appearanceBlurEffectViewSublayerObservation = appearanceBlurEffectView.layer.observe(\.sublayers) { [weak self] _, _ in
|
|
self?.updateAppearanceBlurView()
|
|
}
|
|
}
|
|
return
|
|
}
|
|
appearanceBlurEffectViewBackgroundColorObservation?.invalidate()
|
|
appearanceBlurEffectViewBackgroundColorObservation = nil
|
|
let desiredBgColor = navbarBackgroundColor.withAlphaComponent(Self.backgroundBlurMutingFactor)
|
|
appearanceBlurEffectViewSublayerObservation?.invalidate()
|
|
appearanceBlurEffectViewSublayerObservation = nil
|
|
appearanceBlurEffectView.matchBackgroundColor(desiredBgColor)
|
|
appearanceBlurEffectViewBackgroundColorObservation = tintingView.observe(\.backgroundColor, changeHandler: { [weak self] view, _ in
|
|
if view.backgroundColor != desiredBgColor {
|
|
self?.updateAppearanceBlurView()
|
|
}
|
|
})
|
|
}
|
|
|
|
// MARK: - iOS <=14 blur hacks
|
|
|
|
fileprivate func updateBlurView(_ effect: UIVisualEffect?) {
|
|
if #available(iOS 15, *) {
|
|
owsFailDebug("Shouldn't use a UIVisualEffectView on iOS 15+, use appearance instead")
|
|
return
|
|
}
|
|
guard let effect else {
|
|
manualBlurEffectView?.isHidden = true
|
|
return
|
|
}
|
|
let blurEffectView: UIVisualEffectView = {
|
|
if let existingBlurEffectView = self.manualBlurEffectView {
|
|
existingBlurEffectView.removeFromSuperview()
|
|
self.manualBlurEffectView = nil
|
|
}
|
|
|
|
let blurEffectView = UIVisualEffectView()
|
|
blurEffectView.isUserInteractionEnabled = false
|
|
|
|
self.manualBlurEffectView = blurEffectView
|
|
self.insertSubview(blurEffectView, at: 0)
|
|
|
|
// navbar frame doesn't account for statusBar, so, same as the built-in navbar background, we need to exceed
|
|
// the navbar bounds to have the blur extend up and behind the status bar.
|
|
let statusBarHeight = CurrentAppContext().statusBarHeight
|
|
blurEffectView.autoPinEdgesToSuperviewEdges(with: UIEdgeInsets(top: -statusBarHeight, left: 0, bottom: 0, right: 0))
|
|
|
|
return blurEffectView
|
|
}()
|
|
|
|
blurEffectView.effect = effect
|
|
|
|
// remove hairline below bar.
|
|
self.shadowImage = UIImage()
|
|
|
|
// On iOS11, despite inserting the blur at 0, other views are later inserted into the navbar behind the blur,
|
|
// so we have to set a zindex to avoid obscuring navbar title/buttons.
|
|
blurEffectView.layer.zPosition = -1
|
|
|
|
let bgColor = navbarBackgroundColor.withAlphaComponent(OWSNavigationBar.backgroundBlurMutingFactor)
|
|
if !blurEffectView.matchBackgroundColor(bgColor) {
|
|
// If we can't find the tinting subview (e.g. a new iOS version changed the behavior)
|
|
// We'll make the navbar more translucent by setting a background color.
|
|
let color = navbarBackgroundColor.withAlphaComponent(OWSNavigationBar.backgroundBlurMutingFactor)
|
|
let backgroundImage = UIImage.image(color: color)
|
|
self.setBackgroundImage(backgroundImage, for: .default)
|
|
}
|
|
}
|
|
}
|
|
|
|
internal struct OWSNavigationBarAppearance: Equatable {
|
|
|
|
var tintColor: UIColor?
|
|
var barStyle: UIBarStyle = .default
|
|
|
|
var isTranslucent: Bool = false
|
|
var clipsToBounds: Bool = false
|
|
|
|
enum BackgroundStyle: Equatable {
|
|
case blur(UIBlurEffect)
|
|
case tint(UIColor)
|
|
case image(UIColor)
|
|
}
|
|
|
|
var backgroundStyle: BackgroundStyle = .blur(UIBlurEffect(style: .regular))
|
|
|
|
var hasShadowImage: Bool = false
|
|
|
|
var titleTextColor: UIColor?
|
|
|
|
static func appearance(
|
|
for style: OWSNavigationBarStyle,
|
|
navbarBackgroundColor: UIColor,
|
|
tintColor: UIColor
|
|
) -> Self {
|
|
var appearance = OWSNavigationBarAppearance()
|
|
appearance.barStyle = Theme.barStyle
|
|
appearance.tintColor = tintColor
|
|
if UIAccessibility.isReduceTransparencyEnabled {
|
|
appearance.backgroundStyle = .image(navbarBackgroundColor)
|
|
} else {
|
|
appearance.backgroundStyle = .blur(Theme.barBlurEffect)
|
|
}
|
|
appearance.titleTextColor = Theme.primaryTextColor
|
|
appearance.clipsToBounds = false
|
|
appearance.hasShadowImage = false
|
|
appearance.isTranslucent = false
|
|
|
|
let applyTranslucency = {
|
|
appearance.isTranslucent = !UIAccessibility.isReduceTransparencyEnabled
|
|
}
|
|
|
|
let applyDarkThemeOverride = {
|
|
appearance.barStyle = .black
|
|
appearance.backgroundStyle = .tint(Theme.darkThemeBackgroundColor)
|
|
appearance.tintColor = Theme.darkThemePrimaryColor
|
|
}
|
|
|
|
let applySolidBarOverride = {
|
|
appearance.backgroundStyle = .image(navbarBackgroundColor)
|
|
appearance.hasShadowImage = true
|
|
}
|
|
|
|
switch style {
|
|
case .alwaysDark:
|
|
applyDarkThemeOverride()
|
|
applyTranslucency()
|
|
case .blur:
|
|
applyTranslucency()
|
|
case .solid:
|
|
applySolidBarOverride()
|
|
applyTranslucency()
|
|
}
|
|
|
|
return appearance
|
|
}
|
|
|
|
func apply(to navigationBar: OWSNavigationBar) {
|
|
if #available(iOS 15, *) {
|
|
applyForiOS15(to: navigationBar)
|
|
} else {
|
|
applyForiOS13(to: navigationBar)
|
|
}
|
|
|
|
// Apply the common properties
|
|
navigationBar.isTranslucent = isTranslucent
|
|
navigationBar.clipsToBounds = clipsToBounds
|
|
navigationBar.barStyle = barStyle
|
|
navigationBar.tintColor = tintColor
|
|
navigationBar.barTintColor = backgroundColor
|
|
}
|
|
|
|
// We use appearance APIs on iOS 15+. They only exist in iOS 13+,
|
|
// but are buggy on iOS 13 and 14, so we only use them on iOS 15+.
|
|
@available(iOS 15, *)
|
|
private func applyForiOS15(to navigationBar: OWSNavigationBar) {
|
|
let appearance = UINavigationBarAppearance()
|
|
appearance.backgroundEffect = blurEffect
|
|
appearance.backgroundColor = backgroundColor
|
|
appearance.backgroundImage = backgroundImage
|
|
appearance.titleTextAttributes = titleTextAttributes
|
|
appearance.shadowImage = shadowImage
|
|
// We have to override the color default, we never use it.
|
|
appearance.shadowColor = nil
|
|
|
|
navigationBar.standardAppearance = appearance
|
|
navigationBar.scrollEdgeAppearance = appearance
|
|
navigationBar.compactAppearance = appearance
|
|
navigationBar.compactScrollEdgeAppearance = appearance
|
|
|
|
// navigationBar.setAppearanceBlurViewIfExists()
|
|
navigationBar.updateAppearanceBlurView()
|
|
}
|
|
|
|
// On iOS 14 and earlier we don't use appearance APIs and instead
|
|
// set our own UIVisualEffectView.
|
|
private func applyForiOS13(to navigationBar: OWSNavigationBar) {
|
|
navigationBar.backgroundColor = backgroundColor
|
|
navigationBar.setBackgroundImage(backgroundImage, for: .default)
|
|
navigationBar.titleTextAttributes = titleTextAttributes
|
|
navigationBar.shadowImage = shadowImage
|
|
navigationBar.updateBlurView(blurEffect)
|
|
}
|
|
|
|
private var backgroundImage: UIImage? {
|
|
switch backgroundStyle {
|
|
case .blur:
|
|
return UIImage.image(color: .clear)
|
|
case .tint:
|
|
return nil
|
|
case .image(let color):
|
|
return UIImage.image(color: color)
|
|
}
|
|
}
|
|
|
|
private var blurEffect: UIBlurEffect? {
|
|
switch backgroundStyle {
|
|
case .tint, .image:
|
|
return nil
|
|
case .blur(let effect):
|
|
return effect
|
|
}
|
|
}
|
|
|
|
private var backgroundColor: UIColor? {
|
|
switch backgroundStyle {
|
|
case .blur, .image:
|
|
return nil
|
|
case .tint(let color):
|
|
return color
|
|
}
|
|
}
|
|
|
|
private var shadowImage: UIImage? {
|
|
return hasShadowImage ? UIImage() : nil
|
|
}
|
|
|
|
private var titleTextAttributes: [NSAttributedString.Key: Any] {
|
|
var attributes = [NSAttributedString.Key: Any]()
|
|
if let titleTextColor = titleTextColor {
|
|
attributes[.foregroundColor] = titleTextColor
|
|
}
|
|
return attributes
|
|
}
|
|
}
|
|
|
|
fileprivate extension UIVisualEffectView {
|
|
|
|
var tintingView: UIView? {
|
|
return subviews.first(where: {
|
|
String(describing: type(of: $0)) == "_UIVisualEffectSubview"
|
|
})
|
|
}
|
|
|
|
/// Alter the visual effect view's tint to match a background color
|
|
/// so the navbar, when over a solid color background matching navbarBackgroundColor,
|
|
/// exactly matches the background color. This is brittle, but there is no way to get
|
|
/// this behavior from UIVisualEffectView otherwise.
|
|
/// Return true if this was successful, and false otherwise.
|
|
@discardableResult
|
|
func matchBackgroundColor(_ color: UIColor) -> Bool {
|
|
if let tintingView = tintingView {
|
|
tintingView.backgroundColor = color
|
|
return true
|
|
} else {
|
|
owsFailDebug("Unexpectedly missing visual effect subview")
|
|
return false
|
|
}
|
|
}
|
|
}
|