本文出自: http://mokai.me/theme.html
本文实现思路主要参考了这里,大概就是为日间模式与夜间模式各提供一份资源文件,资源文件中包含颜色值与图标名,切换主题加载相应主题的资源并刷新页面的控件即可,这和实现国际化有点类似。
这是本文附带的Demo,Github地址
定义资源文件
首先定义资源文件,我们使用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配置好属性样式名就行了,比如backgroundColorKey
、textColorKey
,而之后自动更新样式的过程就可以优先判断这些值是否不为空,否则就使用上面的通用样式。
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..
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
文中有何错误还望指教~