Signal-iOS/SignalUI/Appearance/Theme.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

465 lines
16 KiB
Swift

//
// Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import SignalServiceKit
public extension Notification.Name {
static let themeDidChange = Notification.Name("ThemeDidChangeNotification")
}
@objc
public extension NSNotification {
static var ThemeDidChange: NSString { Notification.Name.themeDidChange.rawValue as NSString }
}
final public class Theme: NSObject {
public enum Mode: UInt {
case system, light, dark
}
private static var shared = Theme()
private override init() {
super.init()
AppReadiness.runNowOrWhenAppDidBecomeReadySync {
// IOS-782: +[Theme shared] re-enterant initialization
// AppReadiness will invoke the block synchronously if the app is already ready.
// This doesn't work here, because we'll end up reenterantly calling +shared
// if the app is in dark mode and the first call to +[Theme shared] happens
// after the app is ready.
//
// It looks like that pattern is only hit in the share extension, but we're better off
// asyncing always to ensure the dependency chain is broken. We're okay waiting, since
// there's no guarantee that this block in synchronously executed anyway.
DispatchQueue.main.async {
self.notifyIfThemeModeIsNotDefault()
}
}
}
private static var keyValueStore: SDSKeyValueStore {
return SDSKeyValueStore(collection: "ThemeCollection")
}
private struct KVSKeys {
static var currentMode = "ThemeKeyCurrentMode"
static var legacyThemeEnabled = "ThemeKeyThemeEnabled"
}
public class func setupSignalAppearance() {
UINavigationBar.appearance().barTintColor = Theme.navbarBackgroundColor
UINavigationBar.appearance().tintColor = Theme.primaryIconColor
UIToolbar.appearance().barTintColor = Theme.navbarBackgroundColor
UIToolbar.appearance().tintColor = Theme.primaryIconColor
// We do _not_ specify BarButton.appearance().tintColor because it is sufficient to specify
// UINavigationBar.appearance().tintColor. Furthermore, specifying the BarButtonItem's
// appearance makes it more difficult to override the navbar theme, e.g. how we _always_
// use dark theme in the media send flow and gallery views. If we were specifying
// barButton.appearance().tintColor we would then have to manually override each
// BarButtonItem's tint, rather than just the navbars.
//
// UIBarButtonItem.appearance.tintColor = Theme.primaryIconColor;
// Using UIText{View,Field}.appearance().keyboardAppearance crashes due to a bug in UIKit,
// so we don't do it.
UITableViewCell.appearance().tintColor = Theme.primaryIconColor
UIToolbar.appearance().tintColor = .ows_accentBlue
// If we set NSShadowAttributeName, the NSForegroundColorAttributeName value is ignored.
UINavigationBar.appearance().titleTextAttributes = [
NSAttributedString.Key.foregroundColor: Theme.navbarTitleColor
]
let cursorColor = isDarkThemeEnabled ? UIColor.white : UIColor.ows_accentBlue
UITextView.appearance(whenContainedInInstancesOf: [OWSNavigationController.self]).tintColor = cursorColor
UITextField.appearance(whenContainedInInstancesOf: [OWSNavigationController.self]).tintColor = cursorColor
}
// MARK: - Theme Mode
public static var isDarkThemeEnabled: Bool { shared.isDarkThemeEnabled }
public class func getOrFetchCurrentMode() -> Mode {
return shared.getOrFetchCurrentMode()
}
public class func setCurrentMode(_ mode: Mode) {
shared.setCurrentMode(mode)
}
public class func performWithModeAsCurrent(_ mode: Mode, _ operation: () -> Void) {
shared.performWithModeAsCurrent(mode, operation)
}
private func performWithModeAsCurrent(_ mode: Mode, _ operation: () -> Void) {
let previousMode = cachedCurrentMode
defer { cachedCurrentMode = previousMode }
cachedCurrentMode = mode
operation()
}
private var cachedIsDarkThemeEnabled: Bool?
private var cachedCurrentMode: Mode?
private var isDarkThemeEnabled: Bool {
#if TESTABLE_BUILD
if let isDarkThemeEnabledForTests {
return isDarkThemeEnabledForTests
}
#endif
// Don't cache this value until it reflects the data store.
guard AppReadiness.isAppReady else {
return isSystemDarkThemeEnabled()
}
// Always respect the system theme in extensions.
guard CurrentAppContext().isMainApp else {
return isSystemDarkThemeEnabled()
}
if let cachedIsDarkThemeEnabled {
return cachedIsDarkThemeEnabled
}
let isDarkThemeEnabled: Bool = {
switch getOrFetchCurrentMode() {
case .system: return isSystemDarkThemeEnabled()
case .dark: return true
case .light: return false
}
}()
cachedIsDarkThemeEnabled = isDarkThemeEnabled
return isDarkThemeEnabled
}
private func isSystemDarkThemeEnabled() -> Bool {
return UITraitCollection.current.userInterfaceStyle == .dark
}
private func getOrFetchCurrentMode() -> Mode {
if let cachedCurrentMode {
return cachedCurrentMode
}
guard AppReadiness.isAppReady else {
return defaultMode
}
var currentMode: Mode = .system
databaseStorage.read { transaction in
let hasDefinedMode = Theme.keyValueStore.hasValue(forKey: KVSKeys.currentMode, transaction: transaction)
if hasDefinedMode {
let rawMode = Theme.keyValueStore.getUInt(
KVSKeys.currentMode,
defaultValue: Theme.Mode.system.rawValue,
transaction: transaction
)
if let definedMode = Mode(rawValue: rawMode) {
currentMode = definedMode
}
} else {
// If the theme has not yet been defined, check if the user ever manually changed
// themes in a legacy app version. If so, preserve their selection. Otherwise,
// default to matching the system theme.
if Theme.keyValueStore.hasValue(forKey: KVSKeys.legacyThemeEnabled, transaction: transaction) {
let isLegacyModeDark = Theme.keyValueStore.getBool(
KVSKeys.legacyThemeEnabled,
defaultValue: false,
transaction: transaction
)
currentMode = isLegacyModeDark ? .dark : .light
}
}
}
cachedCurrentMode = currentMode
return currentMode
}
private func setCurrentMode(_ mode: Mode) {
AssertIsOnMainThread()
let previousMode = cachedCurrentMode
switch mode {
case .light:
cachedIsDarkThemeEnabled = false
case .dark:
cachedIsDarkThemeEnabled = true
case .system:
cachedIsDarkThemeEnabled = isSystemDarkThemeEnabled()
}
cachedCurrentMode = mode
// It's safe to do an async write because all accesses check self.cachedCurrentThemeNumber first.
databaseStorage.asyncWrite { transaction in
Theme.keyValueStore.setUInt(mode.rawValue, key: KVSKeys.currentMode, transaction: transaction)
}
if previousMode != mode {
themeDidChange()
}
}
private var defaultMode: Mode {
return .system
}
private func notifyIfThemeModeIsNotDefault() {
if isDarkThemeEnabled || defaultMode != getOrFetchCurrentMode() {
themeDidChange()
}
}
// MARK: -
class func systemThemeChanged() {
shared.systemThemeChanged()
}
private func systemThemeChanged() {
// Do nothing, since we haven't setup the theme yet.
guard cachedIsDarkThemeEnabled != nil else { return }
// Theme can only be changed externally when in system mode.
guard getOrFetchCurrentMode() == .system else { return }
// We may get multiple updates for the same change.
let isSystemDarkThemeEnabled = isSystemDarkThemeEnabled()
guard cachedIsDarkThemeEnabled != isSystemDarkThemeEnabled else { return }
cachedIsDarkThemeEnabled = isSystemDarkThemeEnabled
themeDidChange()
}
private func themeDidChange() {
Theme.setupSignalAppearance()
NotificationCenter.default.post(name: .themeDidChange, object: self)
}
// MARK: - UI Colors
@objc
public class var backgroundColor: UIColor {
isDarkThemeEnabled ? darkThemeBackgroundColor : .white
}
public class var secondaryBackgroundColor: UIColor {
isDarkThemeEnabled ? darkThemeSecondaryBackgroundColor : lightThemeSecondaryBackgroundColor
}
public class var lightThemeSecondaryBackgroundColor: UIColor {
.ows_gray02
}
public class var darkThemeSecondaryBackgroundColor: UIColor {
.ows_gray80
}
public class var washColor: UIColor {
isDarkThemeEnabled ? darkThemeWashColor : .ows_gray05
}
public class var primaryTextColor: UIColor {
isDarkThemeEnabled ? darkThemePrimaryColor : lightThemePrimaryColor
}
public class var primaryIconColor: UIColor {
isDarkThemeEnabled ? darkThemeNavbarIconColor : .ows_gray75
}
public class var secondaryTextAndIconColor: UIColor {
isDarkThemeEnabled ? darkThemeSecondaryTextAndIconColor : lightThemeSecondaryTextAndIconColor
}
public class var ternaryTextColor: UIColor { .ows_gray45 }
public class var placeholderColor: UIColor { .ows_gray45 }
public class var snippetColor: UIColor {
isDarkThemeEnabled ? darkThemeSnippetColor : lightThemeSnippetColor
}
public class var hairlineColor: UIColor {
isDarkThemeEnabled ? .ows_gray75 : .ows_gray15
}
public class var outlineColor: UIColor {
isDarkThemeEnabled ? .ows_gray75 : .ows_gray15
}
public class var backdropColor: UIColor { .ows_blackAlpha40 }
public class var navbarBackgroundColor: UIColor {
isDarkThemeEnabled ? darkThemeNavbarBackgroundColor : .white
}
public class var navbarTitleColor: UIColor { primaryTextColor }
public class var toolbarBackgroundColor: UIColor { navbarBackgroundColor }
// For accessibility:
//
// * Flat areas (e.g. button backgrounds) should use UIColor.ows_accentBlue.
// * Fine detail (e.g., text, non-filled icons) should use Theme.accentBlueColor.
// It is brighter in dark mode, improving legibility.
public class var accentBlueColor: UIColor {
isDarkThemeEnabled ? .ows_accentBlueDark : .ows_accentBlue
}
public class var conversationButtonBackgroundColor: UIColor {
isDarkThemeEnabled ? .ows_gray80 : .ows_gray02
}
public class var conversationButtonTextColor: UIColor {
isDarkThemeEnabled ? .ows_gray05 : .ows_accentBlue
}
@objc
public class var launchScreenBackgroundColor: UIColor {
return isDarkThemeEnabled ? .ows_signalBlueDark : .ows_signalBlue
}
// MARK: - Table View
public class var cellSelectedColor: UIColor {
isDarkThemeEnabled ? UIColor(white: 0.2, alpha: 1) : UIColor(white: 0.92, alpha: 1)
}
public class var cellSeparatorColor: UIColor { hairlineColor }
public class var tableCell2BackgroundColor: UIColor {
isDarkThemeEnabled ? darkThemeTableCell2BackgroundColor : .white
}
public class var tableCell2PresentedBackgroundColor: UIColor {
isDarkThemeEnabled ? darkThemeTableCell2PresentedBackgroundColor : .white
}
public class var tableCell2SelectedBackgroundColor: UIColor {
isDarkThemeEnabled ? darkThemeTableCell2SelectedBackgroundColor : .ows_gray15
}
public class var tableCell2SelectedBackgroundColor2: UIColor {
isDarkThemeEnabled ? darkThemeTableCell2SelectedBackgroundColor2 : .ows_gray15
}
public class var tableCell2MultiSelectedBackgroundColor: UIColor {
isDarkThemeEnabled ? darkThemeTableCell2MultiSelectedBackgroundColor : .ows_gray05
}
public class var tableCell2PresentedSelectedBackgroundColor: UIColor {
isDarkThemeEnabled ? darkThemeTableCell2PresentedSelectedBackgroundColor : .ows_gray15
}
public class var tableView2BackgroundColor: UIColor {
isDarkThemeEnabled ? darkThemeTableView2BackgroundColor : .ows_gray10
}
public class var tableView2PresentedBackgroundColor: UIColor {
isDarkThemeEnabled ? darkThemeTableView2PresentedBackgroundColor : .ows_gray10
}
public class var tableView2SeparatorColor: UIColor {
isDarkThemeEnabled ? darkThemeTableView2SeparatorColor : .ows_gray20
}
public class var tableView2PresentedSeparatorColor: UIColor {
isDarkThemeEnabled ? darkThemeTableView2PresentedSeparatorColor : .ows_gray20
}
// MARK: - Light Theme Colors
public class var lightThemePrimaryColor: UIColor { .ows_gray90 }
public class var lightThemeSecondaryTextAndIconColor: UIColor { .ows_gray60 }
public class var lightThemeSnippetColor: UIColor { .ows_gray45 }
// MARK: - Dark Theme Colors
public class var darkThemeBackgroundColor: UIColor { .black }
public class var darkThemePrimaryColor: UIColor { .ows_gray02 }
public class var darkThemeSecondaryTextAndIconColor: UIColor { .ows_gray25 }
public class var darkThemeSnippetColor: UIColor { .ows_gray25 }
public class var darkThemeWashColor: UIColor { .ows_gray75 }
public class var darkThemeNavbarBackgroundColor: UIColor { .black }
public class var darkThemeNavbarIconColor: UIColor { .ows_gray15 }
public class var darkThemeTableCell2BackgroundColor: UIColor { .ows_gray90 }
public class var darkThemeTableCell2PresentedBackgroundColor: UIColor { .ows_gray80 }
public class var darkThemeTableCell2SelectedBackgroundColor: UIColor { .ows_gray80 }
public class var darkThemeTableCell2SelectedBackgroundColor2: UIColor { .ows_gray65 }
public class var darkThemeTableCell2MultiSelectedBackgroundColor: UIColor { .ows_gray75 }
public class var darkThemeTableCell2PresentedSelectedBackgroundColor: UIColor { .ows_gray75 }
public class var darkThemeTableView2BackgroundColor: UIColor { .black }
public class var darkThemeTableView2PresentedBackgroundColor: UIColor { .ows_gray90 }
public class var darkThemeTableView2SeparatorColor: UIColor { .ows_gray75 }
public class var darkThemeTableView2PresentedSeparatorColor: UIColor { .ows_gray65 }
// MARK: - Blur Effect
public class var barBlurEffect: UIBlurEffect {
isDarkThemeEnabled ? darkThemeBarBlurEffect : UIBlurEffect(style: .light)
}
public class var darkThemeBarBlurEffect: UIBlurEffect { UIBlurEffect(style: .dark) }
// MARK: - Keyboard
public class var keyboardAppearance: UIKeyboardAppearance {
isDarkThemeEnabled ? darkThemeKeyboardAppearance : .default
}
public class var darkThemeKeyboardAppearance: UIKeyboardAppearance { .dark }
public class var keyboardBackgroundColor: UIColor {
isDarkThemeEnabled ? .ows_gray90 : .ows_gray02
}
// MARK: - Search Bar
public class var barStyle: UIBarStyle {
isDarkThemeEnabled ? .black : .default
}
public class var searchFieldBackgroundColor: UIColor { washColor }
public class var searchFieldElevatedBackgroundColor: UIColor {
isDarkThemeEnabled ? .ows_gray75 : .ows_gray12
}
#if TESTABLE_BUILD
private var isDarkThemeEnabledForTests: Bool?
public class func setIsDarkThemeEnabledForTests(_ enabled: Bool) {
shared.isDarkThemeEnabledForTests = enabled
}
#endif
}