Понадобилось значит нам в приложение внедрить темную тему. Вроде все просто, но для тестирования решили сделать ее мануальной, т.е. будет ставится юзером внутри приложения, а не от системы телефона.
Как все мы знаем, темная тема была добавлена в iOS 13, до этой версии она не поддерживается.
Переключение темы в приложении сделано на скрине настроек, обычным Switch. Но что бы не вводить в заблуждение юзеров до iOS 13, этот свитч прятался простой проверкой версии iOS
if #available(iOS 13, *) {
// show switch
}
Все цвета были заданы в ассетах, все работало, тема переключалась.
Через время вдруг поступил запрос: можно ли дефолтные цвета в приложении заменить на кастомные цвета с сервера, т.е. на сайте можно самому изменить цвета и применить их к приложению.
Apple и тут выручила, есть инициализатор для UIColor, который позволяет задавать цвета кодом, а не через ассеты
UIColor { trait -> UIColor in
if trait.userInterfaceStyle == .dark {
return UIColor... // для темной темы
} else {
return UIColor... // для светлой темы
}
}
Для отслеживания того, что у юзера включена темная тема в настройках приложения, создан флаг в UserDefaults > isDarkModeOn: Bool
При изменеии этого флага, тема меняется глобально для всего приложения
if #available(iOS 13.0, *) {
if let appDelegate = UIApplication.shared.delegate as? AppDelegate {
appDelegate.window?.overrideUserInterfaceStyle = UserDefaults.isDarkModeOn ? .dark : .light
}
}
Но тут встал вопрос, каким образом перезаписать сами цвета на кастомные и что бы они задействовались в приложении. Первый вариант был самый простой. Если нам с сервера пришли кастомные цвета, мы их для каждого дефолтного заменяем. После чего что бы они работали, приложение нужно рестартануть. Но это не красиво, заставляет делать юзера лишние действия. Поэтому продолжился поиск как это все делать на лету.
На помощь приходит Objective-C со своим associated objects.
Что бы на лету изменить семантический цвет у элемента, его нужно хранить в каком-то наименовании, что бы было понятно, на что его менять. Поясню: если у элемента задан фон просто как цвет, это нам не скажет чт это за цвет и на какой его менять в списке цветов из сервера. Поэтому все цвета были сформированы через Enum как background, borderBase, primary и т.д.
Это дает нам уже понятие какой цвет хранится у элемента. Но что бы не переписывать все элементы на кастомные, т.к. нам нужно всем элементам доваить хранимое свойство для цвета, все это было сделано через расширение и ассоциированные объекты.
Вот вспомогательные методы для associated objects.
func associatedObject<ValueType: Any>(_ base: AnyObject, key: UnsafePointer<UInt8>, initialiser: () -> ValueType) -> ValueType {
if let associated = objc_getAssociatedObject(base, key) as? ValueType { return associated }
let associated = initialiser()
objc_setAssociatedObject(base, key, associated, .OBJC_ASSOCIATION_RETAIN)
return associated
}
func associateObject<ValueType: Any>(_ base: AnyObject, key: UnsafePointer<UInt8>, value: ValueType) {
objc_setAssociatedObject(base, key, value, .OBJC_ASSOCIATION_RETAIN)
}
Далее, расширение для изменения и обновления фонового цвета
private var bgColorKey: UInt8 = 0
extension UIView: ViewStyling {
var bgColor: kColor {
get {
return associatedObject(self, key: &bgColorKey) { return .clear }
}
set {
associateObject(self, key: &bgColorKey, value: newValue)
NotificationCenter.default.addObserver(self, selector: #selector(updateColorTheme), name: .init("updateColorTheme"), object: nil)
updateColorTheme()
}
}
@objc private func updateColorTheme() {
backgroundColor = bgColor.color
}
}
Сами дефолтные цвета заданы следующим образом
public enum kColor: String {
case clear = ""
// light mode | dark mode
case borderLight = "f3f3f6|0C0C09"
case background = "ffffff|000000"
var name: String {
get { return String(describing: self) }
}
var color: UIColor {
switch self {
case .clear:
return .clear
default:
let components = self.rawValue.split(separator: "|")
var lightColor = String(components[0])
var darkColor = String(components[1])
if UserDefaults.useCustomTheme, let customColor = customColors[name] {
if let l = customColor["light"] { lightColor = l }
if let d = customColor["dark"] { darkColor = d }
}
if #available(iOS 13, *) {
return UIColor { trait -> UIColor in
if trait.userInterfaceStyle == .dark {
return UIColor(hex: darkColor)
} else {
return UIColor(hex: lightColor)
}
}
} else {
return UIColor(hex: UserDefaults.isDarkModeOn ? darkColor : lightColor)
}
}
}
}
Как видно, цвета задаются сразу для двух тем. Не самое лечшее решение. Возможно в будущем переделается. Вычисляемое свойсто color как раз подготавливает нам нужные цвета. Там где условие на проверку версии iOS, это как раз выход для версий ниже 13. Так же тут есть второй флаг useCustomTheme, который говорит нам о том, что мы будем использовать дефолтные цвета, либо кастомные.
Для симуляции кастомных цветов с сервера, для тестов создан простой массив
let customColors = [
"borderLight": ["light": "ff0000", "dark": "ff00ff"],
"background": ["light": "0000ff", "dark": "00ffff"],
]
Важно: названия ключей должны совпадать с наименованием кейсов в нашем енуме.
Далее сама логика переключения цветов в настройках. Два Switch’a, два метода.
func switchAppMode() {
UserDefaults.isDarkModeOn.toggle()
if #available(iOS 13.0, *) {
if let appDelegate = UIApplication.shared.delegate as? AppDelegate {
appDelegate.window?.overrideUserInterfaceStyle = Account().darkMode ? .dark : .light
}
} else {
NotificationCenter.default.post(name: .init("updateColorTheme"), object: nil)
}
}
func switchColors() {
UserDefaults.useCustomTheme.toggle()
NotificationCenter.default.post(name: .init("updateColorTheme"), object: nil)
}
Само использование для элементов
view.bgColor = kColor.background
или view.bgColor = .background
tableView.bgColor = .background
button.bgColor = .backgorund
С изменением цвета текста похожая ситуация, только нужно расширить соответствующие классы UILabel, UIButtton и т.д., соответственно у них будет почти все тоже самое что было для UIVIew, только параметр будет называться по другому, нотификация будет вызывать другой метод, но имя нотификации прежнее. Сам же метод будет устанавливать цвет текста.
Сложнее с attributed string. Это пока в процессе.
Буду рад выслушать ваши замечания, предложения.
Если вдруг кому нужно, для текста на примере UILabel
private var textColorKey: UInt8 = 1
extension UILabel {
var mTextColor: kColor {
get {
return associatedObject(self, key: &textColorKey) { return .clear }
}
set {
associateObject(self, key: &textColorKey, value: newValue)
NotificationCenter.default.addObserver(self, selector: #selector(updateTextTheme), name: .init("updateColorTheme"), object: nil)
updateTextTheme()
}
}
@objc private func updateTextTheme() {
textColor = mTextColor.color
}
}
Если заморочиться, это все можно сделать в одном расширении для UIView, единственное там придется проверять self на соответствие текстовым элементам и в зависимости от элемента, правильно менять ему цвет текста, т.к. для кнопок цвет меняется setTitleColor().
Итого: в итоге мы получим целых 4 различные темы, между которыми можно переключаться.