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
465 lines
16 KiB
Swift
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
|
|
}
|