-
Notifications
You must be signed in to change notification settings - Fork 1.4k
使用 QMUITheme 实现换肤并适配 iOS 13 Dark Mode
iOS 13 系统新增了 Dark Mode,需要项目自行适配,系统提供的适配方案简述如下:
-
UIView 层面
-
对于
UIColor
,使用UIColor (DynamicColors)
里提供的新 API 去初始化颜色,例如+[UIColor colorWithDynamicProvider:]
,或者用 Xcode 11 在 Assets 里为每个颜色创建一个 Color Set 然后在 Appearances 里选择带有 Dark 的选项来生成一个动态颜色,代码里通过+[UIColor colorNamed:]
来获取颜色。 -
对于
UIImage
,使用 Xcode 11 在 Assets 里的 Appearances 选择带有 Dark 的选项来创建一个动态的图片。 -
对于
UIVisualEffect
,使用 iOS 10 之后的UIBlurEffectStyle
(例如UIBlurEffectStyleRegular
),系统会自动切换 Light/Dark 样式。 -
其他内容可在
traitCollectionDidChange:
里根据self.traitCollection.userInterfaceStyle
的值来判断当前的主题。
-
-
UIViewController 层面,通过在
traitCollectionDidChange:
里根据self.traitCollection.userInterfaceStyle
的值来判断当前的主题。 -
WebView 层面,需要业务通过 CSS Media Query 自己适配,这里推荐使用来自 Tencent QQMail @lianzhifeng 的实现方案,与 WebView 相关的内容下文不再展开。
@media (prefers-color-scheme: dark) { html { background: #fff !important; filter: invert(1) hue-rotate(180deg); } img { filter: hue-rotate(180deg) invert(1); } }
-
对于
UIColor
,系统有 API 生成动态颜色,但CGColor
却没有,所以使用到CGColor
的地方都需要重写traitCollectionDidChange:
在里面重新设置一遍,无法保持代码风格的一致性。 -
在 App 回到后台的时候,系统会分别使用 Light Mode 和 Dark Mode 为 App 当前界面截两张图,从而保证你切换了主题后再唤醒 App,不会看到颜色跳变的过程,保证了体验,但由于
CALayer
带有隐式动画,在截图时UIView
已经渲染完新样式了,CALayer
还是旧的,从而表现出明显的 UI 问题(注意下图最终唤醒 App 后,色块(CALayer)能看到从白变黑的过程,而界面上其他地方的颜色是看不到变化过程的)。 -
对于
UIColor
、UIImage
这些对象,如果你将其用于UIView.backgroundColor
、UIView.tintColor
这种系统原生自带的属性,当设备主题发生切换时,UIView 会自动去刷新这些值(因为它知道有这些值需要刷新),但如果你是一个自定义 View 里的自定义属性(例如QMUIGridView.separatorColor
),系统并不知道你总共有哪些属性需要更新,所以你依然需要借助traitCollectionDidChange:
来重新设置一遍,导致 UI 代码需要分散到多个地方,不好维护。 -
从开发者的角度,让一个项目兼容 iOS 13 Dark Mode,和让项目在所有 iOS 版本下都能支持 Dark Mode,这两者的工作量相差不大,都是要对已有的 UI 代码做大量修改。如果要兼容 iOS 13 Dark Mode,最大的收益肯定是同时支持所有 iOS 版本,然而系统提供的方案并不考虑 iOS 12 及以下版本的实现。
基于以上状况,我们设计了 QMUITheme 组件,它解决的问题是:
-
支持全 iOS 版本的换肤,可设置多个主题。
-
兼容 iOS 13 Dark Mode,可将 Dark Mode 映射为 App 中的某一个主题,对业务而言只需要关心业务主题,不需要关心设备当前是否开启了 iOS 13 Dark Mode。
-
支持
UIColor
、CGColor
、UIImage
、UIVisualEffect
的动态化,在 View 初始化的时候直接使用这些动态对象即可,不需要强制写在某些 updateXxx、xxxDidChange 的方法里,以保持最优雅的编码风格。 -
支持 UIKit 自带的 View 组件和业务自定义 View 组件里与颜色、图片相关属性的自动刷新。
下面以 QMUI Demo 为例,讲解 QMUITheme 组件的使用。QMUI Demo 拥有5个主题,其中“Dark”主题对应 iOS 13 Dark Mode。在 iOS 13 下,每次 QMUI Demo 启动时都会根据当前设备是否开启了 Dark Mode,来强制将 QMUI Demo 的主题设置为“Default” 或 “Dark”。
-
QMUIThemeManager
这是全局的主题管理器,业务项目可以通过它注册、移除、切换主题。同时在 iOS 13 下它也负责监听系统 Dark Mode 的切换,并将其映射为已注册的某个业务主题。
-
QMUIThemeManagerCenter
管理 QMUIThemeManager 的工具,
QMUIThemeManager
也只有通过QMUIThemeManagerCenter
才能生成具体的实例。主要用于实现一个项目里存在多个维度的主题的场景。对于只需要一个维度的主题的项目而言,任何时候都使用QMUIThemeManagerCenter.defaultThemeManager
获取 manager 实例即可。 -
主题(theme)
对项目而言,主题可以是任意的对象类型,存在多个主题时,不同主题也可以是不相同的类型,甚至可以是一个无意义仅占位用的
NSNull
。一个主题对象通常用于存储全局的颜色、图片等信息。在 QMUI Demo 里,每个主题对应一个 QMUI 配置表,也即NSObject<QDThemeProtocol> *
类型。对
QMUIThemeManager
而言,一个主题对应一个唯一的identifier
。与主题类似,identifier 对类型也没有要求,只需要支持NSCopying
协议即可,所以可以用NSString
、NSNumber
等常见类型,怎么方便怎么来。 -
动态对象
换肤的直接操作对象通常只有
UIColor
(包含CGColor
)、UIImage
、UIVisualEffect
这三种,在以前,当你拿到一个UIColor
对象,它对应什么实体的色值,是确定的,但在 iOS 13 或 QMUITheme 体系下,同一个 color 对象在不同的主题下可能会展示出不同的色值,于是这种对象我们称之为“动态对象”。动态对象均需要以特定的方式来创建,例如:// 创建一个动态颜色 UIColor *dynamicColor = [UIColor qmui_colorWithThemeProvider:^UIColor * _Nonnull(__kindof QMUIThemeManager * _Nonnull manager, NSString * _Nullable identifier, NSObject<QDThemeProtocol> *theme) { return [identifier isEqualToString:@"Dark"] ? UIColor.blackColor : UIColor.whiteColor; }]; // 创建一个动态图片 UIImage *dynamicImage = [UIImage qmui_imageWithThemeProvider:^UIImage * _Nonnull(__kindof QMUIThemeManager * _Nonnull manager, NSString * _Nullable identifier, NSObject<QDThemeProtocol> *theme) { return [UIImage imageNamed:[identifier isEqualToString:@"Dark"] ? @"image_dark" : @"image_default"]; }]; // 创建一个动态模糊效果 UIVisualEffect *dynamicEffect = [UIVisualEffect qmui_effectWithThemeProvider:^UIVisualEffect * _Nonnull(__kindof QMUIThemeManager * _Nonnull manager, NSString * _Nullable identifier, NSObject<QDThemeProtocol> *theme) { return [UIBlurEffect effectWithStyle:[identifier isEqualToString:@"Dark"] ? UIBlurEffectStyleDark : UIBlurEffectStyleLight]; }];
首先,请先确认你希望以什么样的形式封装你的主题对象,以 QMUI Demo 为例,每个主题对应一个配置表,每个配置表均为一个 NSObject<QDThemeProtocol> *
类型。
然后,在尽量早的时机初始化 QMUIThemeManager
,以保证其他使用颜色、图片的地方能获取到正确的值。QMUI Demo 中选择的时机是 application:didFinishLaunchingWithOptions:
。
// 1. 先注册主题监听,在回调里将主题持久化存储(存到数据库或者 NSUserDefaults),避免启动过程中主题发生变化时读取到错误的值
[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(handleThemeDidChangeNotification:) name:QMUIThemeDidChangeNotification object:nil];
// 2. 然后设置用于生成主题的 block,在需要的时候 QMUIThemeManager 会通过这个 block 得到一个主题对象
QMUIThemeManagerCenter.defaultThemeManager.themeGenerator = ^__kindof NSObject * _Nonnull(NSString * _Nonnull identifier) {
if ([identifier isEqualToString:@"Default"]) return QMUIConfigurationTemplate.new;
if ([identifier isEqualToString:@"Dark"]) return QMUIConfigurationTemplateDark.new;
return nil;
};
// 3. 再针对 iOS 13 开启自动响应系统的 Dark Mode 切换
// 如果不需要兼容 iOS 13 Dark Mode,则不需要这一段代码
if (@available(iOS 13.0, *)) {
// 先通过这个 block 来决定当系统的 Dark Mode 发生切换时,要如何映射到业务的主题
QMUIThemeManagerCenter.defaultThemeManager.identifierForTrait = ^__kindof NSObject<NSCopying> * _Nonnull(UITraitCollection * _Nonnull trait) {
if (trait.userInterfaceStyle == UIUserInterfaceStyleDark) {
return @"Dark";// 表示当检测到系统开启了 Dark Mode 时,将主题自动切换到 Dark
}
return @"Default";
};
// 然后让 QMUIThemeManager 自动响应系统的 Dark Mode 切换
QMUIThemeManagerCenter.defaultThemeManager.respondsSystemStyleAutomatically = YES;
}
做完以上的初始化配置后,剩下的就是业务界面的适配了,按照上文提到的,将 color、image、effect 都换成对应的动态对象。
view.backgroundColor = [UIColor qmui_colorWithThemeProvider:^UIColor * _Nonnull(__kindof QMUIThemeManager * _Nonnull manager, NSString * _Nullable identifier, __kindof NSObject * _Nullable theme) {
return [identifier isEqualToString:@"Dark"] ? UIColor.blackColor : UIColor.whiteColor;
}];
layer.backgroundColor = [UIColor qmui_colorWithThemeProvider:^UIColor * _Nonnull(__kindof QMUIThemeManager * _Nonnull manager, NSString * _Nullable identifier, __kindof NSObject * _Nullable theme) {
return [identifier isEqualToString:@"Dark"] ? UIColor.blackColor : UIColor.whiteColor;
}].CGColor;
imageView.image = [UIImage qmui_imageWithThemeProvider:^UIImage * _Nonnull(__kindof QMUIThemeManager * _Nonnull manager, NSString * _Nullable identifier, NSObject<QDThemeProtocol> *theme) {
return [UIImage imageNamed:[identifier isEqualToString:@"Dark"] ? @"image_dark" : @"image_default"];
}];
visualEffectView.effect = [UIVisualEffect qmui_effectWithThemeProvider:^UIVisualEffect * _Nonnull(__kindof QMUIThemeManager * _Nonnull manager, NSString * _Nullable identifier, NSObject<QDThemeProtocol> *theme) {
return [UIBlurEffect effectWithStyle:[identifier isEqualToString:@"Dark"] ? UIBlurEffectStyleDark : UIBlurEffectStyleLight];
}];
通常来说,一个项目里的颜色、模糊效果一般都只有可枚举的固定的几个,建议将这些颜色、模糊效果缓存起来(使用 static 变量,或者用一个单例去保存,可参考 QDThemeManager
),这样可以大大减少代码量,也不需要去记住创建动态对象的语法。
// 适当做一些抽取,就可以减少大量的代码
view.backgroundColor = UIColor.qd_backgroundColor;
layer.backgroundColor = UIColor.qd_separatorColor.CGColor;
visualEffectView.effect = UIVisualEffect.qd_standardBlurEffect;
到此大部分界面已经可以兼容,而对于自定义的 View 组件,它们的自定义属性需要在主题切换时被刷新,则可参照下方来注册属性给 QMUIThemeManager。
@implementation CustomView
- (instancetype)initWithFrame:(CGRect)frame {
if (self = [super initWithFrame:frame]) {
// ...
// 将组件里的样式定义为 property,然后通过 qmui_registerThemeColorProperties: 注册给 QMUIThemeManager,这样当主题发生变化时,会遍历整个界面的所有 view,重新设置被注册的属性
[self qmui_registerThemeColorProperties:@[NSStringFromSelector(@selector(borderColor)),
NSStringFromSelector(@selector(contentImage)),
NSStringFromSelector(@selector(backgroundEffect))]];
}
return self;
}
@end
如果上述提供的功能仍未能满足业务的需求,也可直接重写 UIView/UIViewController 的 qmui_themeDidChangeByManager:identifier:theme:
方法,在内部写自己的逻辑。
至此整个 App 的主题实现和 Dark Mode 兼容工作就全部完成了。
至于主题的切换,如果 App 只是兼容 iOS 13 Dark Mode,则不需要理会这一点,如果 App 里有提供主动切换主题的操作给用户,则可参照以下代码:
QMUIThemeManagerCenter.defaultThemeManager.currentThemeIdentifier = @"Dark";// 切换到名为 Dark 的主题
// 或
QMUIThemeManagerCenter.defaultThemeManager.currentTheme = darkTheme;// 切换到 darkTheme 主题对象