iOS实现夜间模式

2018/1/1 comments

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

theme_demo

附带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

文中有何错误还望指教~