iOS实现夜间模式

作者: _GKK_ | 来源:发表于2017-12-23 19:19 被阅读26次

    本文出自: 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
    

    文中有何错误还望指教~

    相关文章

      网友评论

        本文标题:iOS实现夜间模式

        本文链接:https://www.haomeiwen.com/subject/ylqkextx.html