IOS學習,夜間模式的實現

1.jpg
隨着愈來愈多的人晚上用電子設備,夜間模式變得越發重要。

image

夜間模式示範api

咱們的目標是經過簡單辦法給你的UI組件添加主題,並在主題間動態切換。爲了達到這個目標,咱們要創建一個協議,稱爲Themed,任何參與主題的要符合它。數組

extension MyView: Themed {bash

func applyTheme(_ theme: AppTheme) {閉包

backgroundColor = theme.backgroundColorapp

titleLabel.textColor = theme.textColoride

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 triggersapplyTheme()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)和窗口,你就有更大的問題了-可是在你特定的實現中請隨意調整這個,讓它變得更保守。

image

這樣咱們就完成了,一個有效實現的夜間模式和對主題化的深刻了解。若是你想試試一個有效的實現,你能夠用示例代碼玩玩。

相關文章
相關標籤/搜索