实施夜间模式

为了获得最佳风味,此帖子最好在 Late Night Swift上找到


欢迎发布《深夜雨燕》的第一条! 与开始构建“夜间模式”相比,这比启动一个功能要好得多,因为越来越多的人在晚上使用他们的设备,这是一项非常重要的功能。

我们的目标是使将主题应用于UI组件并使主题之间的过渡动​​画化变得非常简单。 为了实现这一目标,我们将构建一个名为Themed的协议,任何符合该协议的内容都将参与主题化。

 扩展名MyView:主题{ 
func applyTheme(_主题:AppTheme){
backgroundColor = theme.backgroundColor
titleLabel.textColor = theme.textColor
subtitleLabel.textColor = theme.textColor
}
}

扩展AppTabBarController:主题{
func applyTheme(_主题:AppTheme){
tabBar.barTintColor = theme.barBackgroundColor
tabBar.tintColor = theme.barForegroundColor
}
}

如果您只是想潜入,这是示例代码的链接!
github.com/latenightswift/night-mode

对行为的思考使我们概述了一些初始要求:

  • 用于存储和更改当前主题的中心位置。
  • 一个主题类型,将主要由标记的颜色定义组成。
  • 当前主题更改时通知我们应用程序感兴趣部分的机制。
  • 任何事物都能以干净,迅速的方式参与主题的能力。
  • 要更改应用程序的状态栏,选项卡栏和导航栏以及自定义视图和视图控制器。
  • 要使用漂亮的交叉溶解动画执行主题更改。

也没有理由为什么支持夜间模式的应用程序不支持许多其他主题。

考虑到这些想法,让我们进入并为主要演员建模。

主题协议的定义

让我们首先定义当我们说需要在某个地方存储当前主题并允许我们在主题更改时订阅通知时的含义。

  ///描述一种类型,该类型具有当前的“主题”并允许 
///更改主题时要通知的对象。
协议ThemeProvider {
///应用实际使用的主题类型的占位符
关联主题

///当前活动的主题
var currentTheme:主题{get}

///订阅以在主题更改时收到通知。 处理程序将
///当释放`object`时,从订阅中删除。
func subscriptionToChanges(_ object:AnyObject,handler:@escaping(Theme)-> Void)
}

ThemeProvider描述了我们可以在单个时间点获取当前主题的内容,以及可以订阅以通知主题随时间变化的地方。

请注意,我们已将Theme作为关联类型。 我们不想在这里定义特定的类型,因为我们希望应用程序的实现能够代表他们想要的主题。

订阅机制将通过保留对object的弱引用来工作,并且在释放对象时,将从订阅列表中将其删除。 我们将使用此方法来代替NotificationNotificationCenter因为它将允许我们使用协议扩展来避免大量样板代码/重复的代码,而使用通知很难实现这些代码。

现在,我们在某个地方定义了管理当前主题的位置,让我们看看某些东西可能会消耗掉它。 想要“主题化”的对象将需要在实例化/设置对象时了解当前主题,并且还需要在主题的生命周期内更改主题时得到通知。

  ///描述可以对其应用主题的类型 
协议主题{
///主题类型需要了解哪种具体类型
/// ThemeProvider是。 因此,我们不会与协议冲突,
///我们将此关联类型称为_ThemeProvider
relatedtype _ThemeProvider:ThemeProvider

///将返回当前应用范围的主题提供程序
var themeProvider:_ThemeProvider {get}

///每当当前主题更改时,将调用此方法
func applyTheme(_主题:_ThemeProvider.Theme)
}

扩展主题为Self:AnyObject {
///当Self要开始收听时,将调用一次
///主题更改。 这会立即触发`applyTheme()`
///当前主题。
func setUpTheming(){
applyTheme(themeProvider.currentTheme)
themeProvider.subscribeToChanges(self){[weak self] newTheme in
自我?.applyTheme(newTheme)
}
}
}

当一致类型为AnyObject ,使用方便的协议扩展,我们设法消除了对每个一致的需要,以进行“应用初始主题+订阅+将来主题更改时重新应用”舞蹈。 所有这些都打包在每个对象可以调用的setUpTheming()方法中。

为了实现这一点,主题对象需要知道当前的主题提供者是什么。 当我们知道了应用程序主题提供程序的具体类型(无论哪种类型最终都符合ThemeProvider )时,我们将能够在Themed上提供扩展,以返回应用程序的主题提供程序,我们将在一两分钟内完成。

所有这些意味着符合条件的对象只需要调用一次setUpTheming() ,并提供applyTheme()的实现applyTheme()自行配置主题。

应用实施

现在,我们定义了主题API,我们可以做一些有趣的事情并将其应用到我们的应用程序中。 让我们定义应用程序的主题类型,并声明我们的明暗主题。

  struct AppTheme { 
var statusBarStyle:UIStatusBarStyle
var barBackgroundColor:UIColor
var barForegroundColor:UIColor
var backgroundColor:UIColor
var textColor:UIColor
}

AppTheme扩展{
静态让光= AppTheme(
statusBarStyle:.`default`,
barBackgroundColor:.white,
barForegroundColor:.black,
backgroundColor:UIColor(white:0.9,alpha:1),
textColor:.darkText


静态让黑暗= AppTheme(
statusBarStyle:.lightContent,
barBackgroundColor:UIColor(white:0,alpha:1),
barForegroundColor:.white,
backgroundColor:UIColor(white:0.2,alpha:1),
textColor:.lightText

}

在这里,我们已将AppTheme类型定义为一个哑巴结构,其中包含可用于样式化应用程序的标签颜色和值。 然后,我们为每个可用主题声明一些静态属性,在本例中为dark主题。

现在是时候构建我们​​的应用程序的主题提供程序了。

 最终类AppThemeProvider:ThemeProvider { 
静态让共享:AppThemeProvider = .init()
私人var主题:SubscribableValue

var currentTheme:AppTheme {
得到{
返回theme.value
}
设置{
theme.value = newTheme
}
}

在里面() {
//我们将默认使用浅色主题作为开始,但是
//这可以直接从UserDefaults读取以获取
//用户的最后一个主题选择。
主题= SubscribableValue (值:.light)
}

func subscriptionToChanges(_ object:AnyObject,handler:@escaping(AppTheme)-> Void){
theme.subscribe(对象,使用:处理程序)
}
}

这里可能有两件事:第一,使用静态共享单例,第二, SubscribableValue到底是什么?

单身人士? 真?

我们已经创建了主题提供程序的共享的应用程序范围内的单例实例,通常这是一个很大的危险信号。

鉴于我们的主题提供者可以很好地进行单元测试,并且主题是表示层的工作主体,因此这是可以接受的折衷方案。

在现实世界中,应用的用户界面是由多个屏幕构成的,每个屏幕都有巨大的嵌套视图层次结构。 对视图模型或视图控制器使用依赖注入很容易,但是将依赖注入到屏幕上的每个视图中将是一项艰巨的工作,并且需要管理更多的代码行。

一般而言,您的业务逻辑应该经过单元测试,并且您不应该发现需要对表示层进行测试。 这实际上是一个非常有趣的话题,我们将在以后的文章中讨论。

可订阅的值

因此,您可能想知道通用的SubscribableValue是什么! 主题提供程序要求对象订阅当前主题更改。 该逻辑非常简单,可以轻松地放入主题提供程序中,但是,订阅值的行为可以并且应该转移到更通用的东西中。

“可以订阅的值”的单独通用实现意味着可以单独对其进行测试并重新使用。 它还清理主题提供程序,使其仅管理其特定职责

当然,如果您在项目中使用Rx(或等效功能),则可以改用类似的名称,例如Variable / BehaviorSubject

SubscribableValue的实现如下所示:

  ///一个允许我们微弱地抓住物体的盒子 
struct Weak {
弱var值:对象?
}

///存储类型T的值,并允许对象订阅
///通知此值更改。
struct SubscribableValue {
私有typealias订阅=(对象:Weak ,处理程序:(T)->无效)
私人var订阅:[订阅] = []

var值:T {
didSet {
用于(object,handler)在订阅中,其中object.value!= nil {
处理程序(值)
}
}
}

初始化(值:T){
self.value =值
}

更改func订阅(_对象:AnyObject,使用处理程序:@转义(T)->无效){
subscriptions.append((弱(值:对象),处理程序))
cleanupSubscriptions()
}

私人变异函数的cleanupSubscriptions(){
subscriptions = subscriptions.filter({
返回entry.object.value!=无
})
}
}

SubscribableValue包含一组弱对象引用和闭包。 更改值后,我们将在didSet迭代这些预订并调用闭包。 它还通过删除已释放对象的订阅来进行一些清理。

现在我们有了一个可以正常使用的主题提供程序,在准备就绪之前,我们还需要做一件事,那就是对AppThemeProvider添加扩展,以返回应用程序的单个AppThemeProvider实例。

 扩展主题为Self:AnyObject { 
var themeProvider:AppThemeProvider {
返回AppThemeProvider.shared
}
}

如果您还记得Themed协议和扩展中的内容,则对象需要此属性来帮助驱动方便的setUpTheming()方法,该方法可以处理主题提供者的订阅。 现在,这意味着每个Themed对象唯一需要做的就是实现applyTheme() 。 完善!

主题化

现在,我们拥有获取视图,视图控制器和应用栏所需的一切,以很好地响应主题更改,因此让我们保持一致!

UIView

假设您有一个不错的UIView子类,并希望它响应主题更改。 您所要做的就是遵循Themed ,在init调用setUpTheming()并确保所有与主题相关的设置都位于applyTheme()

不要忘了applyTheme()在设置时也会被调用一次,因此您所有的主题代码都可以放在一个快乐的地方。

 类MyView:UIView { 
var label = UILabel()

在里面() {
super.init(frame:.zero)
setUpTheming()
}
}

扩展名MyView:主题{
func applyTheme(_主题:AppTheme){
backgroundColor = theme.backgroundColor
label.textColor = theme.textColor
}
}

UIStatusBar和UINavigationBar

您还需要根据当前主题更新应用程序状态和导航栏的外观。 假设您的应用程序使用的是基于视图控制器的状态栏外观(现在是默认设置),则可以将导航控制器子类化并符合主题:

 类AppNavigationController:UINavigationController { 
私有变量themedStatusBarStyle:UIStatusBarStyle?

覆盖var preferredStatusBarStyle:UIStatusBarStyle {
返回themedStatusBarStyle? super.preferredStatusBarStyle
}

覆盖func viewDidLoad(){
super.viewDidLoad()
setUpTheming()
}
}

扩展AppNavigationController:主题{
func applyTheme(_主题:AppTheme){
themedStatusBarStyle = theme.statusBarStyle
setNeedsStatusBarAppearanceUpdate()

navigationBar.barTintColor = theme.barBackgroundColor
navigationBar.tintColor = theme.barForegroundColor
navigationBar.titleTextAttributes = [
NSAttributedStringKey.foregroundColor:theme.barForegroundColor
]
}
}

同样,对于您的UITabViewController子类:

 类AppTabBarController:UITabBarController { 
覆盖func viewDidLoad(){
super.viewDidLoad()
setUpTheming()
}
}

扩展AppTabBarController:主题{
func applyTheme(_主题:AppTheme){
tabBar.barTintColor = theme.barBackgroundColor
tabBar.tintColor = theme.barForegroundColor
}
}

现在,在情节提要(或代码)中,确保应用程序的选项卡栏和导航控制器属于新的子类类型。

有了它,应用程序的状态和导航栏现在将响应主题更改。 超级整洁!

通过使每个组件和视图都符合Themed ,您将发现整个应用程序在主题更改时会做出响应。

将主题更改的逻辑与每个单独的组件紧密地结合在一起,意味着一切都在其自己的范围内照顾自己,并且一切正常!

自行车主题

我们需要一些功能来循环可用的主题,因此我们可以更改应用程序的主题提供程序实现以添加以下内容:

 最终类AppThemeProvider:ThemeProvider { 
// ...
private var availableThemes:[AppTheme] = [.light,.dark]
// ...
func nextTheme(){
守卫let nextTheme = availableThemes.rotate()else {
返回
}
currentTheme = nextTheme
}
}

扩展数组{
///将数组的最后一个元素移到开头
///-返回:被移动的元素
变异func rotation()->元素? {
守卫let lastElement = popLast()else {
返回零
}
insert(lastElement,at:0)
返回lastElement
}
}

我们在主题提供程序中列出了可用的主题,并公开了一个nextTheme()主题的nextTheme()函数。

在没有主题变量的情况下通过主题数组循环的一种好方法是简单地获取数组的最后一个元素,然后将该元素移到数组的开头。 可以重复此操作以循环显示所有值。 为此,我们扩展了Array并编写了一个称为rotate()mutating方法。

现在,只要我们想要切换主题,我们都可以简单地调用AppThemeProvider.shared.nextTheme()

动画

我们希望很好地完善事物,并在主题更改之间添加淡入淡出动画。 我们可以为每个applyTheme()方法中的每个属性更改设置动画,但是鉴于整个窗口都将更改,因此让UIKit执行整个窗口的快照过渡实际上要更少的代码,更applyTheme() ,更高效。

让我们再次调整应用程序的主题提供程序以提供此功能:

 最终类AppThemeProvider:ThemeProvider { 
// ...
var currentTheme:AppTheme {
// ...
设置{
setNewTheme(newValue)
}
}
// ...
私人功能setNewTheme(_ newTheme:AppTheme){
让窗口= UIApplication.shared.delegate!.window !! //🤞🏻
UIView.transition(
使用:窗口,
持续时间:0.3,
选项:[。transitionCrossDissolve],
动画:{
self.theme.value = newTheme
},
完成:无

}
}

如您所见,我们已经在UIView交叉UIView过渡中包装了更改主题的值。 由于设置主题的新值将调用所有applyTheme()方法,因此所有更改都将发生在过渡的动画块内。

对于此操作,我们需要应用程序的窗口,并且在此示例中,(在一行中!)强制展开的力超过整个应用程序中可能存在的力! 尽管现实,这应该是完全可以的。 让我们面对现实吧,如果您的应用程序没有委托和窗口,那么您会遇到更大的问题-但可以随时对其进行修改,使其在您的特定实现中更具防御性。


因此,我们有了它,夜间模式的有效实现以及对主题的深入研究。 如果您想使用可行的实现,则可以试用示例代码。

示例代码:github.com/latenightswift/night-mode

如果您喜欢此帖子,请随时在Twitter上关注@latenightswift_或通过电子邮件订阅,以获取有关将来帖子的通知。

谢谢阅读! 🌙