Мануальный Dark Mode для всех версий iOS

uikit
swift
ios

#1

Понадобилось значит нам в приложение внедрить темную тему. Вроде все просто, но для тестирования решили сделать ее мануальной, т.е. будет ставится юзером внутри приложения, а не от системы телефона.
Как все мы знаем, темная тема была добавлена в 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 различные темы, между которыми можно переключаться.


#2

Что тут скажешь: интересное решение. :slight_smile:

Альтернатива самая простая через файрбез удаленные конфигурации грузить, отслеживая ос13 и соотнося настройки темы в ней с установленной конфигурацией. Хотя там у бейза тоже надо смотреть поддержку осей, так что так себе совет )))


#3

У нас свой сервер и все настройки от туда.


#4

В продолжение темы. Расширение было переписано и все свойства были сделаны для UIView. Это избавило от некоторого боейлерплейта и кода стало меньше. Так же вся логика в одном месте и не раскидана по отдельным расширениям для каждого класса. Можно легко расширять метод обновления.

Update: добавлен статический метод для уведомления о смене цветов

private var fsColorThemeKey: UInt8 = 0
extension UIView {
    
    typealias FSColor = kColor
    typealias FSColorTheme = (background: FSColor?, tint: FSColor?, barTint: FSColor?, text: FSColor?)

    static func notifyColorThemeUpdate() {
        NotificationCenter.default.post(name: .init("updateColorTheme"), object: nil)
    }
    
    func setColorTheme(background: FSColor? = nil, tint: FSColor? = nil, barTint: FSColor? = nil, text: FSColor? = nil) {
        let theme: FSColorTheme = (background: background, tint: tint, barTint: barTint, text: text)
        associateObject(self, key: &fsColorThemeKey, value: theme)
        NotificationCenter.default.addObserver(self, selector: #selector(updateColorTheme), name: .init("updateColorTheme"), object: nil)
        updateColorTheme()
    }
    
    private func getColorTheme() -> FSColorTheme? {
        associatedObject(self, key: &fsColorThemeKey) { nil }
    }
    
    @objc private func updateColorTheme() {
        guard let theme = getColorTheme() else { return }
        
        if let bg = theme.background { backgroundColor = bg.color }
        if let tint = theme.tint { tintColor = tint.color }
        
        if let navBar = self as? UINavigationBar, let barTint = theme.barTint {
            navBar.barTintColor = barTint.color
        }
        
        if let button = self as? UIButton, let text = theme.text {
            button.setTitleColor(text.color, for: .normal)
        }
        
        if let textElement = self as? UILabel, let text = theme.text {
            textElement.textColor = text.color
        }
        
        if let textElement = self as? UITextView, let text = theme.text {
            textElement.textColor = text.color
        }
        
        if let textElement = self as? UITextField, let text = theme.text {
            textElement.textColor = text.color
        }
    }
}

Применение так же простое

tableView.setColorTheme(background: .background)
navBar.setColorTheme(background: .background, tint: .background, barTint: .background)

Так же добавилась поддержка для изменения цвета текста

uiLabel.setColorTheme(text: .background)
uiButton.setColorTheme(text: .background)

Для смены цветов теперь достаточно вызвать статический метод
UIView.notifyColorThemeUpdate()
вместо
NotificationCenter.default.post(name: .init("updateColorTheme"), object: nil)

P.S. для attributedString я все еще думаю над реализацией, как появится, добавлю код.


#5

Небольшая презентация работы.
Не обращайте внимание на то, что не все меняет цвет. Просто для теста не везде его менял.


#6

В итоге полчилось у меня сделать изменение текста для attributedString. Но скорее всего некоторые могли думать что это не сложно, если не знать всю суть.
У нас в проекте много мест где текст задается как attributedString и в это же время имеет несколько стилей по цвету и размеру, т.е. состоит из подстрок.
Пример: вот такая вот строка с разными стилями
Не смог разными цветами выделить текс. В итоге нам нужно для каждой подстроки изменить ее стиль на соответствующий, а не на всю строку целиком.
Я до этого писал метод для прохождения по всем атрибутам такой строки и как оказалось, он подошел и для данной темы. Правда писался он для изменения размера текста. На скрине выше видно ползунок, который меняет размер текста по всему приложению, так же на лету.

Приведу пример для UILabel. У меня были созданы свои элементы, которые наследуются от базовых, что бы у них создать свои свойства для хранения простых строк и с атрибутами.

public class StyledLabel: UILabel {
    
    var textAttrs: [NSAttributedString.Key: Any] = [:]
    
    required init(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)!
        commonInit()
    }
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        commonInit()
    }
    
    deinit {
        NotificationCenter.default.removeObserver(self)
    }
    
    func commonInit() {
        NotificationCenter.default.addObserver(self, selector: #selector(updateFontStyle), name: .init("updateTextTheme"), object: nil)
    }
    
    public override var text: String? {
        didSet {
            customAttrText = NSAttributedString(string: text ?? "", attributes: textAttrs)
        }
    }
    
    var customAttrText: NSAttributedString? {
        didSet {
            updateFontStyle()
        }
    }
    
    @objc private func updateFontStyle() {
        if let attrString = customAttrText, !attrString.string.isEmpty {
            let newText = NSMutableAttributedString(attributedString: attrString)
            let range = NSMakeRange(0, attrString.length)
            
            attrString.enumerateAttributes(in: range, options: []) { attributes, range, _ in
                var attr = attributes
                if let color = attr[.foregroundColor] as? kColor {
                    attr[.foregroundColor] = color.color
                }
                
                newText.setAttributes(attr, range: range)
            }
            
            attributedText = newText
        }
    }
}

Использование будет таким

styledLabel.customAttrText = NSAttributedString(string: "some text", attributes: [.foregroundColor: kColor.primaryText])

или посложнее

var mutableAttrText = NSMutableAttributedString(string: "first part", attributes: [.foregroundColor: kColor.primaryText])
mutableAttrText.append(NSAttributedString(string: "second part", attributes: [.foregroundColor: kColor.secondaryText]))
styledLabel.customAttrText = mutableAttrText

И для обновления текста в статический метод UIView.notifyColorThemeUpdate() нужно добавить еще один вызов уведомления

static func notifyColorThemeUpdate() {
    NotificationCenter.default.post(name: .init("updateColorTheme"), object: nil)
    NotificationCenter.default.post(name: .init("updateTextTheme"), object: nil)
}

P.S. заранее извиняюсь, если класс StyledLabel выдаст какие либо ошибки, код сокращал на ходу, т.к. в проекте он гораздо больше и завязан на системе стилей.