iOS实现夜间模式

本文出自: http://mokai.me/theme.html

本文实现思路主要参考了这里,大概就是为日间模式与夜间模式各提供一份资源文件,资源文件中包含颜色值与图标名,切换主题加载相应主题的资源并刷新页面的控件即可,这和实现国际化有点类似。


这是本文附带的Demo,Github地址

Demo

定义资源文件

首先定义资源文件,我们使用JSON做为配置的格式,大概如下:

{
    "colors": {
        "tint": "#404146",
        "background": "#FFFFFF",
        "text": "#404146",
        "placeholder": "#AAAAAA",
        "separator": "#C8C7CC",
        "shadow_layer": "#00000026",
        "tabBar_background": "#FFFFFF",
        "tabBar_normal": "#8A8A8F",
        "tabBar_selected": "#404146",
        "navigationBar_background": "#FFFFFF",
        "cell_background": "#FFFFFF",
        "cell_selected_background": "#B8B8B8",
        "switch_tint": "#3F72AF"
    },
    "images": {
        "article_loading": "article_loading"
    }
}
  • colors 定义颜色值

  • images 定义图片

    大多数情况下,我们可以把纯色图标的Render AS 设置为 Template Image 来满足不同颜色的渲染,对于不是纯色图标才使用多张图片来定义。

控件样式

首先通用的样式,比如主题色、字体色、背景色等,页面上NavigationBar、UILabel、UIButton等控件基本都固定使用了这些样式,那么这部分我们就可以自动更新。

而需要自定义的 属性样式,我们通过扩展一系列key配置好属性样式名就行了,比如backgroundColorKeytextColorKey,而之后自动更新样式的过程就可以优先判断这些值是否不为空,否则就使用上面的通用样式。

extension UILabel {
    
    /// 自动更新文本色的配置key
    @IBInspectable var textColorKey: String? {
        get {
            return objc_getAssociatedObject(self, &ThemeUILabelTextColorKey) as? String
        }
        set {
            objc_setAssociatedObject(self, &ThemeUILabelTextColorKey, newValue, .OBJC_ASSOCIATION_COPY_NONATOMIC)
        }
    }
    
}

主题管理类

负责切换主题,获取相应主题的资源,并自动更新控件通用样式或者自定义的属性样式

  • 切换主题
/// 当前主题
fileprivate(set) var style: ThemeStyle {
    get {
        if let currentStyleString = df.string(forKey: ThemeCurrentStyle),
            let currentStyle = ThemeStyle(rawValue: currentStyleString)  {
            return currentStyle
        }
        return .default
    }
    set {
        df.set(newValue.rawValue, forKey: ThemeCurrentStyle)
        df.synchronize()
        //加载主题资源
        setup() 
        //通知现有页面更新
        NotificationCenter.default.post(name: .ThemeStyleChange, object: nil)
    }
}

/// 切换主题
func switchStyle() {
    style = style == .default ? .night : .default
}

  • 获取主题资源
let style = self.style //当前样式
        
//从应用Bundle中拿相应主题名.theme文件
let path = Bundle.main.path(forResource: style.rawValue, ofType: "theme")!
let url = URL(fileURLWithPath: path)
let string = try! String(contentsOf: url)
let json = JSON(parseJSON: string)

self.colors = [:] 
self.images = [:]

//颜色
let colorsJSON = json["colors"].dictionaryValue
colorsJSON.forEach { (key, value) in
    self.colors[key] = UIColor(value.stringValue)
}

//图片
let imagesJSON = json["images"].dictionaryValue
imagesJSON.forEach { (key, value) in
    self.images[key] = value.stringValue
}
  • 自动更新样式
/// 自动更新到当前主题下的通用样式
///
/// - Parameter view: View
func updateThemeSubviews(with view: UIView) {
    guard view.autoUpdateTheme else { //不需要自动切换样式
        //更新subviews
        //UIButton中有UILabel,所以不需要更新subviews
        guard !(view is UIButton) else {
            return
        }
        view.subviews.forEach { (subView) in
            updateThemeSubviews(with: subView)
        }
        return
    }
    //各种视图更新
    if let tableView = view as? UITableView {
        //取消当前选择行
        if let selectedRow = tableView.indexPathForSelectedRow {
            tableView.deselectRow(at: selectedRow, animated: false)
        }
        tableView.backgroundColor = Theme.backgroundColor
        tableView.separatorColor = Theme.separatorColor
    }
    else if let cell = view as? UITableViewCell {
        cell.backgroundColor = Theme.cellBackgroundColor
        cell.contentView.backgroundColor = cell.backgroundColor
        cell.selectedBackgroundView?.backgroundColor = Theme.cellSelectedBackgroundColor
    }
    else if let collectionView = view as? UICollectionView {
        collectionView.backgroundColor = C.theme.backgroundColor
    }
    else if let cell = view as? UICollectionViewCell {
        cell.backgroundColor = Theme.cellBackgroundColor
        cell.selectedBackgroundView?.backgroundColor = Theme.cellSelectedBackgroundColor
    }
    else if let lab = view as? UILabel {
        if let key = lab.textColorKey {
            lab.textColor = self.color(forKey: key)
        } else {
            lab.textColor = Theme.textColor
        }
    }
    else if let btn = view as? UIButton {
        if let key = btn.titleColorKey {
            btn.setTitleColor(self.color(forKey: key), for: .normal)
        } else {
            btn.setTitleColor(Theme.textColor, for: .normal)
        }
        if let key = btn.selectedColorKey {
            btn.setTitleColor(self.color(forKey: key), for: .selected)
        }
    }
    else if let textField = view as? UITextField {
        if let key = textField.textColorKey {
            textField.textColor = self.color(forKey: key)
        } else {
            textField.textColor = Theme.textColor
        }
        if let key = textField.placeholderColorKey {
            textField.placeholderColor = self.color(forKey: key)
        }
    }
    else if let textView = view as? UITextView {
        if let key = textView.textColorKey {
            textView.textColor = self.color(forKey: key)
        } else {
            textView.textColor = Theme.textColor
        }
        //UITextView不能通过appearance设置keyboardAppearance,所以在此处设置
        let keyboardAppearance: UIKeyboardAppearance = self.style == .default ? .default : .dark
        textView.keyboardAppearance = keyboardAppearance
    }
    else if let imageView = view as? UIImageView {
        if let key = imageView.imageNamedKey {
            imageView.image = self.image(forKey: key)
        }
    }
    else if let switchView = view as? UISwitch {
        switchView.onTintColor = Theme.switchTintColor
    }
    else if let datePicker = view as? UIDatePicker {
        datePicker.setValue(Theme.textColor, forKey: "textColor")
        datePicker.setValue(false, forKey: "highlightsToday")
    }
    //主题色
    if let key = view.tintColorKey {
        view.tintColor = self.color(forKey: key)
    }
    //背景色
    if let key = view.backgroundColorKey {
        view.backgroundColor = self.color(forKey: key)
    }
    //更新subviews
    //UIButton中有UILabel,所以不需要更新subviews
    guard !(view is UIButton) else {
        return
    }
    view.subviews.forEach { (subView) in
        updateThemeSubviews(with: subView)
    }
}

其中Theme.xxxColor是扩展的getter属性,用于访问当前样式某个颜色值,建议自定义的颜色与图片也基于Theme扩展。

由于自动更新过程就是对view递归设置,而该方法需要手动调用,调用时机一般是在viewDidLoad中或者收到ThemeStyleChange通知时。对于UITableView与UICollectionView中,通常会在cell的awakeFromNib中调用一次。

BaseXXX

切换样式后会通知ThemeStyleChange,我们在各种BaseXXX中调用updateThemeSubviews

使用BaseXXX基类的方式确实不优雅,在意的读者可以看下 DKNightVersion 代码,它是基于NSObject扩展的,对业务代码耦合低,但遗憾没有自动更新通用样式功能。

class BaseVC: UIViewController {

    deinit {
        NotificationCenter.default.removeObserver(self)
    }
    
    override func viewDidLoad() {
        super.viewDidLoad()
        updateTheme()
        //监听主题改变通知
        NotificationCenter.default.addObserver(self, selector: #selector(self.onThemeChange), name: .ThemeStyleChange, object: nil)
    }
    
    @objc func onThemeChange() {
        UIView.animate(withDuration: 0.25) {
            self.updateTheme()
        }
    }
    
    /// 更新当前ViewController的主题
    func updateTheme() {
        if view.backgroundColorKey == nil {
            view.backgroundColor = Theme.backgroundColor //顶层View
        }
        Theme.shared.updateThemeSubviews(with: view)
    } 
}

其它BaseXXX直接套用以上的代码,放在updateTheme中就行了

BaseTabBarController

tabBar.tintColor = Theme.tabBarSelectedColor
tabBar.barTintColor = Theme.tabBarBackgroundColor
tabBar.backgroundColor = Theme.tabBarBackgroundColor
tabBar.isTranslucent = false
if #available(iOS 10.0, *) {
    tabBar.unselectedItemTintColor = Theme.tabBarNormalColor
} else {
    UIView.performWithoutAnimation {
        self.viewControllers?.forEach({ (vc) in
            vc.tabBarItem.setTitleTextAttributes([NSAttributedStringKey.foregroundColor: Theme.tabBarNormalColor],
                                                 for: .normal)
            vc.tabBarItem.setTitleTextAttributes([NSAttributedStringKey.foregroundColor: Theme.tabBarSelectedColor],
                                                 for: .selected)
        })
    }
}

BaseNavigationController

//背景
let bgImageSize = CGSize(width: view.frame.width, height: 64)
UIGraphicsBeginImageContext(bgImageSize)
Theme.navigationBarBackgroundColor.setFill()
UIGraphicsGetCurrentContext()!.fill(CGRect(origin: CGPoint(), size: bgImageSize))
let bgImage = UIGraphicsGetImageFromCurrentImageContext()
UIGraphicsEndImageContext()
navigationBar.setBackgroundImage(bgImage, for: .default)
navigationBar.backgroundColor = Theme.navigationBarBackgroundColor

navigationBar.barTintColor = Theme.textColor
navigationBar.tintColor = Theme.textColor
navigationBar.titleTextAttributes = [NSAttributedStringKey.foregroundColor: Theme.textColor]
UIBarButtonItem.appearance().tintColor = Theme.textColor
//已打开的页面使用appearance无效
viewControllers.forEach { (vc) in
    vc.navigationItem.backBarButtonItem?.tintColor = Theme.textColor
    vc.navigationItem.leftBarButtonItems?.forEach({ (item) in
        item.tintColor = Theme.textColor
    })
    vc.navigationItem.rightBarButtonItems?.forEach({ (item) in
        item.tintColor = Theme.textColor
    })
}

BaseXXXCell

class BaseTableViewCell: UITableViewCell {
    override func awakeFromNib() {
        super.awakeFromNib()
        if selectionStyle != .none {
            selectedBackgroundView = UIView(frame: frame)
        }
        Theme.shared.updateThemeSubviews(with: self)
    }
}

这里没有监听ThemeStyleChange通知是因为自动更新的过程会更新到TableView下所有可见的UITableViewCell,当然不可见的UITableViewCell也需要更新,我们可以用以下代码手动更新

if let dataSource = tableView.dataSource {
    let sectionNumber = dataSource.numberOfSections?(in: tableView) ?? tableView.numberOfSections
    for section in 0..<sectionNumber {
        for row in 0..<dataSource.tableView(tableView, numberOfRowsInSection: section) {
            let cell = dataSource.tableView(tableView, cellForRowAt: IndexPath(row: row, section: section))
            Theme.shared.updateThemeSubviews(with: cell)
        }
    }
}

Cell的Selection不可以设置颜色,我们通过自定义selectedBackgroundView来实现,在自动更新的过程中设置cell.selectedBackgroundView.backgroundColor
另外如果TableView处于选中状态,选中行的selectedBackgroundView会为nil,我们在设置前先deselectRow

web页面夜间模式

由于css样式优先级的机制,最新的样式可覆盖旧的样式,所以我们只需要为每种样式添加一种夜间模式样式就行。

/*夜间模式样式*/
.night-mode {
    background-color: #333333;
}
.night-mode #articleCon p,
.night-mode #articleCon ol li,
.night-mode #articleCon ul li {
    color: #CDCDCD;
}

在原生端切换样式时,通过JS函数把夜间模式的css附加上去就行了,切换回默认主题删除样式即可。

//JS代码

//切换至夜间模式
Enclave.switchToNightMode = function() {
    document.querySelector('html').classList.add('night-mode')
}

//切换至白天模式
Enclave.switchToLightMode = function() {
    document.querySelector('html').classList.remove('night-mode')
}

细节

  • UIApplication.shared.statusBarStyle设置

    iOS默认不可以通过UIApplication.shared.statusBarStyle设置样式,需要info.plist中把UIViewControllerBasedStatusBarAppearance设置为false

  • 设置UIPickerView文字颜色
func pickerView(_ pickerView: UIPickerView, attributedTitleForRow row: Int, forComponent component: Int) -> NSAttributedString? { 
    let string = self.dataSource[row]
    return NSAttributedString(string: string, attributes: [NSForegroundColorAttributeName: C.theme.textColor])
}
  • 设置UIDatePicker文字颜色
datePicker.setValue(C.theme.textColor, forKey: "textColor")
datePicker.setValue(false, forKey: "highlightsToday") //取消datePicker.date当前日期高亮
  • UITextView通过appearance设置keyboardAppearance会crash
    切换到夜间主题时可能需要把keyboardAppearance设置为UIKeyboardAppearance.dark
let keyboardAppearance: UIKeyboardAppearance = style == .default ? .default : .dark
UITextField.appearance().keyboardAppearance = keyboardAppearance

但以上代码应用在UITextView会Crash,暂不知道什么原因造成的,有同学知道可以告诉下。
所以对于UITextView的keyboardAppearance我们需要通过实例设置

let keyboardAppearance: UIKeyboardAppearance = style == .default ? .default : .dark
textView.keyboardAppearance = keyboardAppearance

文中有何错误还望指教~

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 158,117评论 4 360
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 66,963评论 1 290
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 107,897评论 0 240
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,805评论 0 203
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,208评论 3 286
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,535评论 1 216
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,797评论 2 311
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,493评论 0 197
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,215评论 1 241
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,477评论 2 244
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 31,988评论 1 258
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,325评论 2 252
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 32,971评论 3 235
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,055评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,807评论 0 194
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,544评论 2 271
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,455评论 2 266

推荐阅读更多精彩内容

  • 概述 DKNightVersion是github上面一个用于实现iOS应用夜间模式和多种主题的开源库。github...
    HiIgor阅读 4,766评论 33 10
  • 项目中加了夜间模式的功能,现将我的实现方案记录下: 夜间模式无非是图片、色值变了。那么在赋值的时候我们就要设置两套...
    mws100阅读 3,577评论 0 7
  • 今天上午去買東西,可能是心沒有靜下來的原因,在商場逛的時候什麼都不想買,看著琳琅滿目的商品怎麼也提不起精神,妈妈說...
    静心_安心阅读 182评论 0 0
  • 对于这次月考,我已经想到了结果。但是知道成绩的时候心里不免有一点波动。心情很复杂,我们的课堂比其他班的特殊,我们...
    王若欣阅读 193评论 0 1
  • 如梦初醒,梦想,何为梦想呢?一个老生常谈的话题! 20岁时,你我相识,相爱! 25岁时,你告诉我要去追梦,你等我,...
    zs123阅读 219评论 0 0