IOS學習,夜間模式的實現
隨著越來越多的人晚上用電子設備,夜間模式變得愈加重要。
夜間模式示範
我們的目標是通過簡單辦法給你的UI組件添加主題,並在主題間動態切換。為了達到這個目標,我們要建立一個協議,稱為Themed,任何參與主題的要符合它。
extension MyView: Themed {
func applyTheme(_ theme: AppTheme) {
backgroundColor = theme.backgroundColor
titleLabel.textColor = theme.textColor
subtitleLabel.textColor = theme.textColor
}
}
extension AppTabBarController: Themed {
func applyTheme(_ theme: AppTheme) {
tabBar.barTintColor = theme.barBackgroundColor
tabBar.tintColor = theme.barForegroundColor
}
}
想象一下應用的表現,來讓我們理出一些基本的需求:
- 用於存儲和改變當前主題的核心地區
- 由有標簽的顏色定義組成的主題類型
- 當主題改變時候,能夠通知我們應用的相應機制
- 讓任何東西都可以參與到主題的簡潔方法
- 通過自定視圖與視圖控制器改變應用的狀態欄,標簽欄,導航欄
- 通過精美的淡入淡出動畫來表現主題變化
如果一個應用能支持夜間模式,顯然它也能支持更多其他模式
帶著這些想法,讓我們去開始制作我們的主要內容吧
定義主題協議
我們說過需要一些地方存儲當前主題,並能夠訂閱通知來知曉主題是否改變。首先我們要定義這句話是什麽意思。
/// Describes a type that holds a current `Theme` and allows /// an object to be notified when the theme is changed. protocol ThemeProvider { /// Placeholder for the theme type that the app will actually use associatedtype Theme /// The current theme that is active var currentTheme: Theme { get } /// Subscribe to be notified when the theme changes. Handler will be /// removed from subscription when `object` is deallocated. func subscribeToChanges(_ object: AnyObject, handler: @escaping (Theme) -> Void) }
ThemeProvider描述了我們通過什麽來及時從單點(single point)取得當前主題,還有我們在哪裏訂閱關於主題改變的通知。
註意我們把Theme做成了關聯類型,這裏我們不想定義一個特定的類型,因為我們希望應用能通過任何它們希望的方式表現主題。
訂閱機制通過對對象的弱引用運行,當對象被釋放時,它會從訂閱列表出移除。我們會用這種方法代替Notification和NotificationCenter,因為這樣我們可以用協議拓展來回避樣本/重復代碼,從而避免通知的使用變得更復雜。
現在我們定義了處理當前主題的地方,我們來看看它是怎麽被使用的吧。一旦被實例化/配置,一個要被themed化的對象就需要知道當前的主題,並且如果主題變化還可以通知到它。
/// Describes a type that can have a theme applied to it
protocol Themed {
/// A Themed type needs to know about what concrete type the
/// ThemeProvider is. So we don‘t clash with the protocol,
/// let‘s call this associated type _ThemeProvider
associatedtype _ThemeProvider: ThemeProvider
/// Will return the current app-wide theme provider
var
?themeProvider: _ThemeProvider { get
?}
/// This will be called whenever the current theme changes
func applyTheme(_ theme: _ThemeProvider.Theme)
}
extension Themed where Self: AnyObject {
/// This is to be called once when Self wants to start listening for
/// theme changes. This immediately triggers
applyTheme()with the
/// current theme.
func setUpTheming() {
applyTheme(themeProvider.currentTheme)
themeProvider.subscribeToChanges(self) { [weak self] newTheme in
self?.applyTheme(newTheme)
}
}
}
如果符合的類型是AnyObject,就使用一個便利的協議擴展,我們這樣就避免了每一個一致性都需要做的“應用最初主題,訂閱,當主題改變時候再應用下一個主題”步驟。這些都被放入了setUpTheming()方法中,每個對象都可以調用。
為了做到這個,Themed對象需要知道當前ThemeProvider是什麽。當我們知道app的ThemeProvider的具體類型(無論什麽類型都會最終符合ThemeProvider),我們就可以提供在Themed上提供一個擴展來返回應用的ThemeProvider,我們馬上就要做這些。
這些都意味著符合的對象只需要調用setUpTheming()一次,並提供applyTheme()的一個實現去給它配置這個主題。
App的實現
現在我們已經定義了帶主題的API,我們可以用它做點有趣的事情,然後把它應用到我們的app上。讓我們定義我們app的主題類型,並聲明我們的白天與夜間主題。
struct AppTheme {
var
?statusBarStyle: UIStatusBarStyle
var
?barBackgroundColor: UIColor
var
?barForegroundColor: UIColor
var
?backgroundColor: UIColor
var
?textColor: UIColor
}
extension AppTheme {
static
?let light = AppTheme(
statusBarStyle: .
default,
barBackgroundColor: .white,
barForegroundColor: .black,
backgroundColor: UIColor(white: 0.9, alpha: 1),
textColor: .darkText
)
static
?let dark = AppTheme(
statusBarStyle: .lightContent,
barBackgroundColor: UIColor(white: 0, alpha: 1),
barForegroundColor: .white,
backgroundColor: UIColor(white: 0.2, alpha: 1),
textColor: .lightText
)
}
這裏我們定義我們的AppTheme類型是一個啞結構(dumb struct),包含用於設計我們app的標簽化的顏色和值。我們之後為每一個可用的主題聲明一些靜態特性-對於本文的情況,就是白天和夜間主題。
現在是時候建立我們app的ThemeProvider了
final
?class
?AppThemeProvider: ThemeProvider {
static
?let shared: AppThemeProvider = .init()
private
?var
?theme: SubscribableValue
var
?currentTheme: AppTheme {
get
?{
return
?theme.value
}
set
?{
theme.value = newTheme
}
}
init() {
// We‘ll default to the light theme to start with, but
// this could read directly from UserDefaults to get
// the user‘s last theme choice.
theme = SubscribableValue(value: .light)
}
func subscribeToChanges(_ object: AnyObject, handler: @escaping (AppTheme) -> Void) {
theme.subscribe(object, using: handler)
}
}
現在我們要面對2件事情:第一,使用一個靜態共享的單體(singleton),第二,SubscribableValue到底是什麽
單體?真的?
我們為我們的ThemeProvider建立了一個app範圍共享的單體實例,這通常是個需要警惕的地方。
我們的ThemeProvider很適合單元測試,考慮到這種主題化是表示層上的工作,這是一個可接受的考慮。
在現實世界,app的UI是由多屏幕組成,每個都有內嵌視圖組成的龐大層級。為一個視圖模式或視圖控制器使用依賴註入(dependency injection)非常容易,但是為屏幕上的每個視圖進行依賴註入會是件大工作,需要很多行代碼去完成。
總體上說,你的商務邏輯應該能進行單元測試,你應該不需要向下測試到表示層。這確實是一個有趣的話題,以後我們也許會再討論它。
SubscribableValue
你也許已經很好奇SubscribableValue到底是什麽!ThemeProvider需要對象去訂閱當前主題的改變。這個邏輯上很簡單,可以很容易合並到ThemeProvider中,但是訂閱一個數值的習慣可以,也應該變得更加通用。
一個分開的,通用的”可以訂閱的值”的實現,意味著它可以被孤立的測試和再使用。它也讓ThemeProvider變得更幹凈,即允許它處理只屬於自己的特定職責。
當然如果你在你的項目中用Rx(或有同樣功能的),你可以用一些類似的代替它,比如Variable/BehaviorSubject
SubscribableValue的實現看起來像這樣:
/// A box that allows us to weakly hold on to an object
struct Weak {
weak var
?value: Object?
}
/// Stores a value of type T, and allows objects to subscribe to
/// be notified with this value is changed.
struct SubscribableValue {
private
?typealias Subscription = (object: Weak, handler: (T) -> Void)
private
?var
?subscriptions: [Subscription] = []
var
?value: T {
didSet {
for
?(object, handler) in
?subscriptions where object.value != nil {
handler(value)
}
}
}
init(value: T) {
self.value = value
}
mutating func subscribe(_ object: AnyObject, using handler: @escaping (T) -> Void) {
subscriptions.append((Weak(value: object), handler))
cleanupSubscriptions()
}
private
?mutating func cleanupSubscriptions() {
subscriptions = subscriptions.filter({ entry in
return
?entry.object.value != nil
})
}
}
SubscribableValue含有一個弱對象引用與閉包組成的數組。當數值改變時,我們在didSet中叠代這些訂閱並調用閉包。當對象被釋放時,它還會移除訂閱。
現在我們有了一個可以用的ThemeProvider,距離一切就緒就差一件事了。這就是為Themed添加一個擴展,用來返回我們app的單一AppThemeProvider實例。
extension Themed where Self: AnyObject {
var
?themeProvider: AppThemeProvider {
return
?AppThemeProvider.shared
}
}
如果你還從Themed協議與擴展中記得它,對象需要這個特性來使用方便的setUpTheming()方法,從而管理對ThemeProvider的訂閱。現在它意味著每個Themed對象需要做的事情就是實現applyTheme()。完美!
獲得Themed
現在我們已經準備好,讓我們的視圖,視圖控制器和app欄目響應主題的變化,讓我們開始一致化吧!
UIView
如果你有一個很好的UIView子類,想要它響應主題變化。你要做的就是讓它符合Themed,在init中調用setUpTheming(),保證所有主題相關設置都在applyTheme()中。
別忘了在準備時也調用applyTheme()一次,這樣你所有的主題代碼就能放在一個適合的地方。
class
?MyView: UIView {
var
?label
?= UILabel()
init() {
super.init(frame: .zero)
setUpTheming()
}
}
extension MyView: Themed {
func applyTheme(_ theme: AppTheme) {
backgroundColor = theme.backgroundColor
label.textColor = theme.textColor
}
}
UIStatusBar 和 UINavigationBar
你可能還想根據當前主題更新app狀態欄與導航欄的外觀。假設你的app正在使用基於視圖控制器的狀態欄外觀(這是默認設置),你可以把導航控制器劃入子類,並使它符合themed。
class
?AppNavigationController: UINavigationController {
private
?var
?themedStatusBarStyle: UIStatusBarStyle?
override
?var
?preferredStatusBarStyle: UIStatusBarStyle {
return
?themedStatusBarStyle ?? super.preferredStatusBarStyle
}
override
?func viewDidLoad() {
super.viewDidLoad()
setUpTheming()
}
}
extension AppNavigationController: Themed {
func applyTheme(_ theme: AppTheme) {
themedStatusBarStyle = theme.statusBarStyle
setNeedsStatusBarAppearanceUpdate()
navigationBar.barTintColor = theme.barBackgroundColor
navigationBar.tintColor = theme.barForegroundColor
navigationBar.titleTextAttributes = [
NSAttributedStringKey.foregroundColor: theme.barForegroundColor
]
}
}
類似的對你的UITabViewController子類
class
?AppTabBarController: UITabBarController {
override
?func viewDidLoad() {
super.viewDidLoad()
setUpTheming()
}
}
extension AppTabBarController: Themed {
func applyTheme(_ theme: AppTheme) {
tabBar.barTintColor = theme.barBackgroundColor
tabBar.tintColor = theme.barForegroundColor
}
}
現在在你的故事板(storyboard)(或代碼)中,確保你app的標簽欄與導航控制器是你新的子類類型。
這樣就可以了,你app的狀態與導航欄會響應主題變化,非常巧妙!
隨著每一個組件和視圖都符合Themed,整個app就會響應主題的變化了。
讓主題變化的邏輯與每一個獨立組件緊密耦合,意味著每一部分都可以在自己的範圍內做好自己工作,這樣每部分都做的很好。
循環主題
我們需要一些功能來在可用的主題間循環,我們可以通過添加下面的代碼來調整app的ThemeProvider的一些實現
final
?class
?AppThemeProvider: ThemeProvider {
// ...
private
?var
?availableThemes: [AppTheme] = [.light, .dark]
// ...
func nextTheme() {
guard let nextTheme = availableThemes.rotate() else
?{
return
}
currentTheme = nextTheme
}
}
extension Array
?{
/// Move the last element of the array to the beginning
/// - Returns: The element that was moved
mutating func rotate() -> Element? {
guard let lastElement = popLast() else
?{
return
?nil
}
insert(lastElement, at: 0)
return
?lastElement
}
}
我們列出了在ThemeProvider中的可用主題,並用了一個nextTheme()函數來讓它們循環。
要想實現在一組主題中循環,而不需要一個記錄索引的變量,一個簡單的方法是獲取主題組中的最後一個,並把它移動到開頭。為了在所有數值間循環,這個操作可以被重復進行。我們通過延伸主題組並寫一個名為rotate()的mutating方法做到。
現在當我們想切換主題時就可以調用AppThemeProvider.shared.nextTheme(),這樣就會更新了。
動畫化
我們想潤色一下,為主題改變添加一個同步淡入淡出的動畫。我們可以在每個applyTheme()方法中把每個屬性變化進行動畫化,但考慮到整個窗口都要改變,使用UIKit來表現整個窗口的快照轉換會更加簡潔高效,代碼更少。
讓我們再次調整app的ThemeProvider,讓它帶給我們這個功能:
final
?class
?AppThemeProvider: ThemeProvider {
// ...
var
?currentTheme: AppTheme {
// ...
set
?{
setNewTheme(newValue)
}
}
// ...
private
?func setNewTheme(_ newTheme: AppTheme) {
let window = UIApplication.shared.delegate!.window!! //
UIView.transition(
with: window,
duration: 0.3,
options: [.transitionCrossDissolve],
animations: {
self.theme.value = newTheme
},
completion: nil
)
}
}
你可以看到,我們把主題數值的改變包裝到一個UIView同步淡入淡出轉換中。所有applyTheme()方法會通過設定主題的新數值而被調用,所有的改變都在轉換的動畫區塊發生。
為了這個操作,我們需要app的窗口,本例裏比起整個app中應該存在的數量,實際有著更多強制解包(在一條線中)。從現實考慮,這應該是完全可以的。就面對它把,如果你的app沒有一個委托(delegate)和窗口,你就有更大的問題了-但是在你特定的實現中請隨意調整這個,讓它變得更保守。
這樣我們就完成了,一個有效實現的夜間模式和對主題化的深入了解。如果你想試試一個有效的實現,你可以用示例代碼玩玩。
IOS學習,夜間模式的實現