【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)和窗口,你就有更大的问题了-但是在你特定的实现中请随意调整这个,让它变得更保守。

这样我们就完成了,一个有效实现的夜间模式和对主题化的深入了解。如果你想试试一个有效的实现,你可以用示例代码玩玩。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 159,117评论 4 362
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,328评论 1 293
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,839评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,007评论 0 206
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,384评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,629评论 1 219
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,880评论 2 313
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,593评论 0 198
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,313评论 1 243
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,575评论 2 246
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,066评论 1 260
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,392评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,052评论 3 236
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,082评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,844评论 0 195
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,662评论 2 274
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,575评论 2 270

推荐阅读更多精彩内容

  • Lua 5.1 参考手册 by Roberto Ierusalimschy, Luiz Henrique de F...
    苏黎九歌阅读 13,517评论 0 38
  • Spring Cloud为开发人员提供了快速构建分布式系统中一些常见模式的工具(例如配置管理,服务发现,断路器,智...
    卡卡罗2017阅读 134,100评论 18 139
  • Swift2.0 1.defer译为延缓、推迟之意类似栈 注意作用域,其次是调用顺序——即一个作用域结束(注意),...
    zeqinjie阅读 3,295评论 0 50
  • 当你还是一个码农的时候,每天都要编写多少行代码每次都要创建很多类的时候,每建立一次给你一点经验值,累计到十级的时...
    暗夜精灵_NightElf阅读 2,899评论 3 14
  • 秋意是凉爽的!一天也是幸福美好的!结束一天或许有些疲惫,有些幸福。回家的路上满满的思念想快点飞奔回家!路上时常会遇...
    小朱绘本馆阅读 268评论 0 0