Created
May 9, 2019 15:50
-
-
Save bryanjclark/2bff6f83d85a45dd1d403a51e20bff43 to your computer and use it in GitHub Desktop.
Locket Photo's color-theme management code
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// Locket's color-management stuff | |
// First up, here's the protocol for a "color theme". | |
// I only have two implemented; they're listed below. | |
public enum SemanticColor: CaseIterable { | |
case backgroundColor | |
case backgroundColorSecondary | |
case backgroundColorCell | |
case backgroundColorCellHighlighted | |
case tintColor | |
case tintColorPressed | |
case backgroundColorTintPressed | |
case tableCellSeparator | |
case shuffleButtonBackground | |
case shuffleButtonBackgroundPressed | |
case mapButtonBackground | |
case mapButtonBackgroundPressed | |
case textPrimary | |
case textSecondary | |
case emptyStateImage | |
case placeholderImage | |
// eg the loading-cloud on PhotoCell. | |
case placeholderImageForeground | |
case sliderFill | |
} | |
public protocol ColorThemeType { | |
func color(for semanticColor: SemanticColor) -> UIColor | |
var colors: [SemanticColor: UIColor] { get } | |
var barStyle: UIBarStyle { get } | |
var statusBarStyle: UIStatusBarStyle { get } | |
var themeID: ColorThemeIdentifier { get } | |
} | |
public extension ColorThemeType { | |
subscript(semanticColor: SemanticColor) -> UIColor? { | |
return colors[semanticColor] | |
} | |
} | |
// Used by views / view controllers that consume color themes. | |
public protocol ColorThemeConsuming: class { | |
func apply(theme: ColorThemeType) | |
} | |
public extension ColorThemeConsuming { | |
func reapplyCurrentColorTheme() { | |
self.apply(theme: ColorThemeManager.shared.currentColorTheme.value) | |
} | |
var currentColorTheme: ColorThemeType { | |
return ColorThemeManager.shared.currentColorTheme.value | |
} | |
} | |
public enum ColorThemeIdentifier: String, CaseIterable { | |
case light // default | |
case dark | |
public var theme: ColorThemeType { | |
switch self { | |
case .light: | |
return LightColorTheme.shared | |
case .dark: | |
return DarkColorTheme.shared | |
} | |
} | |
} | |
public struct LightColorTheme: ColorThemeType { | |
public let themeID: ColorThemeIdentifier = .light | |
public var colors: [SemanticColor : UIColor] | |
public func color(for semanticColor: SemanticColor) -> UIColor { | |
switch semanticColor { | |
case .backgroundColor: | |
return AppTheme.Colors.backgroundColor | |
case .backgroundColorSecondary: | |
return UIColor(hex: 0xE8EBED) // UIKit table background | |
case .backgroundColorCellHighlighted: | |
return UIColor(hex: 0xD9D9D9) // from UIKit | |
case .backgroundColorCell: | |
return AppTheme.Colors.backgroundColor | |
case .backgroundColorTintPressed: | |
return AppTheme.Colors.backgroundColorTintPressed | |
case .emptyStateImage: | |
return UIColor(hex: 0xEFEFF4) // matches backgroundColorCell | |
case .mapButtonBackground: | |
return AppTheme.Colors.mapButtonBackground | |
case .mapButtonBackgroundPressed: | |
return AppTheme.Colors.mapButtonBackgroundPressed | |
case .textPrimary: | |
return AppTheme.Colors.textPrimary | |
case .textSecondary: | |
return AppTheme.Colors.textSecondary | |
case .tintColor: | |
return AppTheme.Colors.tintColor | |
case .tintColorPressed: | |
return AppTheme.Colors.tintColorPressed | |
case .shuffleButtonBackground: | |
return AppTheme.Colors.gridHeaderButtonBackground | |
case .shuffleButtonBackgroundPressed: | |
return AppTheme.Colors.gridHeaderButtonBackgroundPressed | |
case .placeholderImage: | |
return UIColor(hex: 0xE8EBED) | |
case .placeholderImageForeground: | |
return UIColor.white | |
case .tableCellSeparator: | |
return AppTheme.Colors.lightGray | |
case .sliderFill: | |
return UIColor(hex: 0xC7C7CC) // UIKit | |
} | |
} | |
public let barStyle: UIBarStyle = .default | |
public let statusBarStyle = UIStatusBarStyle.default | |
public static let shared = LightColorTheme() | |
private init() { | |
// HACK omg I bet I can do this better, but let's just try this out for now. | |
self.colors = [:] | |
SemanticColor.allCases.forEach { self.colors[$0] = self.color(for: $0) } | |
} | |
} | |
public struct DarkColorTheme: ColorThemeType { | |
public let themeID: ColorThemeIdentifier = .dark | |
public static let shared = DarkColorTheme() | |
public func color(for semanticColor: SemanticColor) -> UIColor { | |
switch semanticColor { | |
case .backgroundColor: | |
return UIColor(hex: 0x0A0A0A) | |
case .backgroundColorSecondary: | |
return color(for: .backgroundColor) | |
case .backgroundColorCellHighlighted: | |
return UIColor(hex: 0x1A1A1A) | |
case .backgroundColorCell: | |
return UIColor(hex: 0x262626) | |
case .backgroundColorTintPressed: | |
return color(for: .backgroundColorCellHighlighted) | |
case .emptyStateImage: | |
return UIColor(hex: 0x3B3B3B) | |
case .mapButtonBackground: | |
return color(for: .backgroundColorCell) | |
case .mapButtonBackgroundPressed: | |
return color(for: .backgroundColorCellHighlighted) | |
case .textPrimary: | |
return UIColor.white | |
case .textSecondary: | |
return UIColor(hex: 0xB1B6B9) | |
case .tintColor: | |
return UIColor(hex: 0xED3B41) | |
case .tintColorPressed: | |
return UIColor(hex: 0xD62B31) | |
case .shuffleButtonBackground: | |
return UIColor(hex: 0x171515) | |
case .shuffleButtonBackgroundPressed: | |
return UIColor(hex: 0x0D0909) | |
case .placeholderImage: | |
return AppTheme.Colors.lightGray | |
case .placeholderImageForeground: | |
return color(for: .textSecondary) | |
case .tableCellSeparator: | |
return UIColor(hex: 0x383838) | |
case .sliderFill: | |
return UIColor(hex: 0x707070) | |
} | |
} | |
public let barStyle = UIBarStyle.black | |
public let statusBarStyle = UIStatusBarStyle.lightContent | |
public var colors: [SemanticColor : UIColor] | |
private init() { | |
// HACK omg I bet I can do this better, but let's just try this out for now. | |
self.colors = [:] | |
SemanticColor.allCases.forEach { self.colors[$0] = self.color(for: $0) } | |
} | |
} | |
// Second, here's the ColorThemeManager, that toggles between themes. | |
// (Sorry for the ReactiveCocoa, if that's confusing!) | |
public class ColorThemeManager { | |
public static let shared = ColorThemeManager() | |
public var currentColorThemeID: ColorThemeIdentifier { | |
didSet { | |
// If the theme isn't dark, and they're not subscribed, well then, uh, don't. | |
if currentColorThemeID == .dark && !SubscriptionManager.shared.isSubscribed.value { | |
print("Ignoring theme change because they're not subscribed.") | |
self.currentColorThemeID = .light | |
return | |
} | |
UserDefaults.standard.set( | |
currentColorThemeID.rawValue, | |
forKey: ColorThemeManager.userDefaultsThemeKey | |
) | |
guard oldValue != currentColorThemeID else { return } | |
print("Toggled current color theme to \(currentColorThemeID.rawValue)") | |
self.prepareForTransitionAnimation() | |
self.currentColorThemeMutable.value = currentColorThemeID.theme | |
self.completeTransitionAnimation() | |
} | |
} | |
public var currentColorTheme: Property<ColorThemeType> { | |
return currentColorThemeMutable.immutableView | |
} | |
private let currentColorThemeMutable: MutableProperty<ColorThemeType> | |
public func register(consumer: ColorThemeConsuming) { | |
if let nsObject = consumer as? NSObject { | |
self.currentColorTheme.producer.take(during: nsObject.reactive.lifetime).startWithValues { [weak nsObject] theme in | |
(nsObject as? ColorThemeConsuming)?.apply(theme: theme) | |
} | |
} else { | |
self.currentColorTheme.producer | |
.startWithValues { [weak consumer] (theme) in | |
consumer?.apply(theme: theme) | |
} | |
} | |
} | |
private static let userDefaultsThemeKey = "userDefaultsColorThemeKey" | |
private init() { | |
if | |
let colorThemeIdentifierString = UserDefaults.standard | |
.string(forKey: ColorThemeManager.userDefaultsThemeKey), | |
let colorThemeID = ColorThemeIdentifier(rawValue: colorThemeIdentifierString), | |
SubscriptionManager.shared.isSubscribed.value | |
{ | |
self.currentColorThemeID = colorThemeID | |
} else { | |
self.currentColorThemeID = .light | |
} | |
self.currentColorThemeMutable = MutableProperty(self.currentColorThemeID.theme) | |
self.screenBrightnessChanged() | |
NotificationCenter.default.reactive.notifications(forName: UIScreen.brightnessDidChangeNotification).observeValues { [weak self] _ in | |
self?.screenBrightnessChanged() | |
} | |
// Verify that all colors are enabled for all possible cases | |
ColorThemeIdentifier.allCases.forEach { themeID in | |
let theme = themeID.theme | |
SemanticColor.allCases.forEach { color in | |
assert(theme[color] != nil) | |
} | |
} | |
SubscriptionManager.shared.isSubscribed.producer.uniqueValues().startWithValues { [weak self] isSubscribed in | |
if !isSubscribed { | |
self?.currentColorThemeID = .light | |
} | |
} | |
} | |
deinit { | |
NotificationCenter.default.removeObserver(self) | |
} | |
public func screenBrightnessChanged() { | |
guard SubscriptionManager.shared.isSubscribed.value else { return } | |
let brightness = UIScreen.main.brightness | |
if UserDefaultsBoolean.automaticDarkMode.enabled { | |
let cutoff = LocketTweaks.assign(LocketTweaks.DarkMode.automaticCutOff) | |
self.currentColorThemeID = (brightness < cutoff) ? .dark : .light | |
} | |
} | |
public weak var window: UIWindow? = nil | |
private var snapshot: UIView? = nil | |
private func prepareForTransitionAnimation() { | |
if | |
let window = self.window, | |
let snapshot = window.snapshotView(afterScreenUpdates: false) | |
{ | |
snapshot.frame = window.bounds | |
window.addSubview(snapshot) | |
self.snapshot = snapshot | |
} | |
} | |
private func completeTransitionAnimation() { | |
UIView.animate(withDuration: 0.3, delay: 0, options: .beginFromCurrentState, animations: { [weak self] in | |
self?.snapshot?.alpha = 0 | |
self?.window?.rootViewController?.setNeedsStatusBarAppearanceUpdate() | |
}) { [weak self] _ in | |
self?.snapshot?.removeFromSuperview() | |
self?.snapshot = nil | |
} | |
} | |
} | |
// Third, here's how I use it in my views / view controllers / table cells / etc: | |
internal class BasicSettingsCell: UITableViewCell, ColorThemeConsuming { | |
static let identifier = "BasicSettingsCell" | |
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { | |
super.init(style: .value1, reuseIdentifier: reuseIdentifier) | |
self.textLabel?.textColor = AppTheme.Colors.textPrimary | |
self.detailTextLabel?.textColor = AppTheme.Colors.textSecondary | |
self.textLabel?.font = AppTheme.Fonts.tableCellTitleRegular | |
self.detailTextLabel?.font = AppTheme.Fonts.tableCellTitleRegular | |
} | |
required init?(coder aDecoder: NSCoder) { | |
fatalError("init(coder:) has not been implemented") | |
} | |
override func prepareForReuse() { | |
self.title = nil | |
self.subtitle = nil | |
} | |
var title: String? { | |
didSet { | |
self.textLabel?.text = title | |
} | |
} | |
var subtitle: String? { | |
didSet { | |
self.detailTextLabel?.text = subtitle | |
} | |
} | |
func apply(theme: ColorThemeType) { | |
self.backgroundColor = theme[.backgroundColorCell] | |
self.selectedBackgroundView = UIView(backgroundColor: theme[.backgroundColorCellHighlighted]!) | |
self.textLabel?.textColor = theme[.textPrimary] | |
self.detailTextLabel?.textColor = theme[.textSecondary] | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment