簡介
2018年蘋果在macOS系統引入了暗黑模式,一經推出廣受好評。尤其是我們程序員,經常與代碼、文本打交道,亮色風格的界面看久了,眼睛會特別累。有了暗黑模式之后,我們的眼睛終于能被溫柔對待了。而且系統內置的應用適配的非常好,拿我們常用的XCode來說,也挑不出什么大毛病。反正我是用了暗黑模式之后就沒有回去過了。
當然推出暗黑模式不只是為了程序員準備的,也有其他的原因:
- 可以當做夜間模式:晚上看屏幕的時候,不會亮到你睜不開眼睛。
- 信息重點的表達需要:在黑色系更能突出關鍵信息,能做到一目了然,抓住用戶的焦點。
- 用戶審美的需要:有相當多的用戶對黑色系的產品很鐘愛,當然要迎合他們的需求了。
- 硬件設備省電的需要:現在流行的OLED屏幕,對于純黑色像素點是不需要通電的。
其實不管它有多少原因,蘋果爸爸這么大力的推廣,肯定有它的價值,我們跟著蘋果爸爸走的就行。這不iOS13就引入到了iOS系統,對于我們開發者來說,就是又愛又恨啊。如果你們的產品是有格調的產品,多半暗黑模式適配的需求就在路上了,就像我一樣??。但是最尷尬的地方在于,適配暗黑模式的api只在iOS13可用,你又要讓我適配暗黑模式,又要讓我最低支持iOS9,你這不是讓我為難嗎???沒辦法系統原生支持不了的,那就到咱們的寶藏網站Github上面找一找iOS9+的換膚方案。當然找到了許多,大部分是OC的,因為項目主要語言是Swift,所以pass掉。找到了許多swift三方庫,但是里面的一些設計有點過時、有些不支持swift5、有些功能太重了。我只想要一個輕量級、高度自定義的方案即可。沒有現成的滿足的庫,與其委曲求全,不如自己實現。所以就有了JXTheme
方案,一個輕量級、api友好、高度自定義的換膚方案。
該方案主要借鑒了iOS13的暗黑模式適配API,所以建議你先去網上查閱iOS13的暗黑模式適配指南。先對系統的方案有一定了解,再來看JXTheme
你會感到非常親切。關鍵在于JXTheme
最低支持iOS9,等于說在iOS9就能使用iOS13的暗黑模式適配方案。而且后面還給出了當你的應用最低支持iOS13時,從JXTheme
切換到系統API的指南。
Github地址
大家可以先進入github地址,看一下效果。JXTheme Github地址
核心代碼&關鍵流程介紹
下面按照換膚API的調用流程來介紹實現方案
1.如何優雅的設置主題屬性
通過給控件擴展命名空間屬性theme
,類似于SnapKit
的snp
、Kingfisher
的kf
,這樣可以將支持主題修改的屬性,集中到theme
屬性。這樣比直接給控件擴展屬性theme_backgroundColor
更加優雅。
核心代碼如下:
view.theme.backgroundColor = ThemeProvider({ (style) in
if style == .dark {
return .white
}else {
return .black
}
})
2.如何根據傳入的style配置對應的值
借鑒iOS13系統APIUIColor(dynamicProvider: <UITraitCollection) -> UIColor>)
。自定義ThemeProvider
結構體,初始化器為init(_ provider: @escaping ThemePropertyProvider<T>)
。傳入的參數ThemePropertyProvider
是一個閉包,定義為:typealias ThemePropertyProvider<T> = (ThemeStyle) -> T
。這樣就可以針對不同的控件,不同的屬性配置,實現最大化的自定義。
核心代碼參考第一步示例代碼。
3.如何保存主題屬性配置閉包
對控件添加Associated object
屬性providers
存儲ThemeProvider
。
核心代碼如下:
public extension ThemeWrapper where Base: UIView {
var backgroundColor: ThemeProvider<UIColor>? {
set(new) {
if new != nil {
let baseItem = self.base
let config: ThemeCustomizationClosure = {[weak baseItem] (style) in
baseItem?.backgroundColor = new?.provider(style)
}
//存儲在擴展屬性providers里面
var newProvider = new
newProvider?.config = config
self.base.providers["UIView.backgroundColor"] = newProvider
ThemeManager.shared.addTrackedObject(self.base, addedConfig: config)
}else {
self.base.configs.removeValue(forKey: "UIView.backgroundColor")
}
}
get { return self.base.providers["UIView.backgroundColor"] as? ThemeProvider<UIColor> }
}
}
4.如何記錄支持主題屬性的控件
為了在主題切換的時候,通知到支持主題屬性配置的控件。通過在設置主題屬性時,就記錄目標控件。
核心代碼就是第3步里面的這句代碼:
ThemeManager.shared.addTrackedObject(self.base, addedConfig: config)
然后它會被記錄到ThemeManager
的trackedHashTable
屬性里面。因為trackedHashTable
是NSHashTable<AnyObject>.init(options: .weakMemory)
,通過弱引用記錄控件,所以不存在內存問題。
5.如何切換主題并調用主題屬性配置閉包
通過ThemeManager.changeTheme(to: style)
完成主題切換,方法內部再調用被追蹤的控件的providers
里面的ThemeProvider.provider
主題屬性配置閉包。
核心代碼如下:
public func changeTheme(to style: ThemeStyle) {
currentThemeStyle = style
self.trackedHashTable.allObjects.forEach { (object) in
if let view = object as? UIView {
view.providers.values.forEach { self.resolveProvider($0) }
}
}
}
private func resolveProvider(_ object: Any) {
//castdown泛型
if let provider = object as? ThemeProvider<UIColor> {
provider.config?(currentThemeStyle)
}else ...
}
預覽
特性
- [x] 支持iOS 9+,讓你的APP更早的實現
DarkMode
; - [x] 使用
theme
命名空間屬性:view.theme.xx = xx
。告別theme_xx
屬性擴展用法; - [x] 使用
ThemeProvider
傳入閉包配置。根據不同的ThemeStyle
完成主題屬性配置,實現最大化的自定義; - [x]
ThemeStyle
可通過extension
自定義style,不再局限于light
和dark
; - [x] 提供
customization
屬性,作為主題切換的回調入口,可以靈活配置任何屬性。不再局限于提供的backgroundColor
、textColor
等屬性; - [x] 支持控件設置
overrideThemeStyle
,會影響到其子視圖; - [x] 提供根據
ThemeStyle
配置屬性的常規封裝、Plist文件靜態加載、服務器動態加載示例;
使用示例
擴展ThemeStyle
添加自定義style
ThemeStyle
內部僅提供了一個默認的unspecified
style,其他的業務style需要自己添加,比如只支持light
和dark
,代碼如下:
extension ThemeStyle {
static let light = ThemeStyle(rawValue: "light")
static let dark = ThemeStyle(rawValue: "dark")
}
基礎使用
view.theme.backgroundColor = ThemeProvider({ (style) in
if style == .dark {
return .white
}else {
return .black
}
})
imageView.theme.image = ThemeProvider({ (style) in
if style == .dark {
return UIImage(named: "catWhite")!
}else {
return UIImage(named: "catBlack")!
}
})
自定義屬性配置
view.theme.customization = ThemeProvider({[weak self] style in
//可以選擇任一其他屬性
if style == .dark {
self?.view.bounds = CGRect(x: 0, y: 0, width: 30, height: 30)
}else {
self?.view.bounds = CGRect(x: 0, y: 0, width: 80, height: 80)
}
})
配置封裝示例
JXTheme
是一個提供主題屬性配置的輕量級基礎庫,不限制使用哪種方式加載資源。下面提供的三個示例僅供參考。
常規配置封裝示例
一般的換膚需求,都會有一個UI標準。比如UILabel.textColor
定義三個等級,代碼如下:
enum TextColorLevel: String {
case normal
case mainTitle
case subTitle
}
然后可以封裝一個全局函數傳入TextColorLevel
返回對應的配置閉包,就可以極大的減少配置時的代碼量,全局函數如下:
func dynamicTextColor(_ level: TextColorLevel) -> ThemeProvider<UIColor> {
switch level {
case .normal:
return ThemeProvider({ (style) in
if style == .dark {
return UIColor.white
}else {
return UIColor.gray
}
})
case .mainTitle:
...
case .subTitle:
...
}
}
主題屬性配置時的代碼如下:
themeLabel.theme.textColor = dynamicTextColor(.mainTitle)
本地Plist文件配置示例
與常規配置封裝一樣,只是該方法是從本地Plist文件加載配置的具體值,具體代碼參加Example
的StaticSourceManager
類
根據服務器動態添加主題
與常規配置封裝一樣,只是該方法是從服務器加載配置的具體值,具體代碼參加Example
的DynamicSourceManager
類
有狀態的控件
某些業務需求會存在一個控件有多種狀態,比如選中與未選中。不同的狀態對于不同的主題又會有不同的配置。配置代碼參考如下:
statusLabel.theme.textColor = ThemeProvider({[weak self] (style) in
if self?.statusLabelStatus == .isSelected {
//選中狀態一種配置
if style == .dark {
return .red
}else {
return .green
}
}else {
//未選中狀態另一種配置
if style == .dark {
return .white
}else {
return .black
}
}
})
當控件的狀態更新時,需要刷新當前的主題屬性配置,代碼如下:
func statusDidChange() {
statusLabel.theme.textColor?.refresh()
}
如果你的控件支持多個狀態屬性,比如有textColor
、backgroundColor
、font
等等,你可以不用一個一個的主題屬性調用refresh
方法,可以使用下面的代碼完成所有配置的主題屬性刷新:
func statusDidChange() {
statusLabel.theme.refresh()
}
overrideThemeStyle
不管主題如何切換,overrideThemeStyleParentView
及其子視圖的themeStyle
都是dark
overrideThemeStyleParentView.theme.overrideThemeStyle = .dark
其他說明
為什么使用theme
命名空間屬性,而不是使用theme_xx
擴展屬性呢?
- 如果你給系統的類擴展了N個函數,當你在使用該類時,進行函數索引時,就會有N個擴展的方法干擾你的選擇。尤其是你在進行其他業務開發,而不是想配置主題屬性時。
- 像
Kingfisher
、SnapKit
等知名三方庫,都使用了命名空間屬性實現對系統類的擴展,這是一個更Swift
的寫法,值得學習。
主題切換通知
extension Notification.Name {
public static let JXThemeDidChange = Notification.Name("com.jiaxin.theme.themeDidChangeNotification")
}
ThemeManager
根據用戶ID存儲主題配置
/// 配置存儲的標志key。可以設置為用戶的ID,這樣在同一個手機,可以分別記錄不同用戶的配置。需要優先設置該屬性再設置其他值。
public var storeConfigsIdentifierKey: String = "default"
遷移到系統API指南
當你的應用最低支持iOS13時,如果需要的話可以按照如下指南,遷移到系統方案。
遷移到系統API指南,點擊閱讀
Github地址
最后再復習一下github地址,點擊進入查看更多細節。JXTheme Github地址