iOS-使用QMUITheme实现换肤并适应iOS 13黑暗模式

释放双眼,带上耳机,听听看~!

 

背景

一,iOS 13黑暗模式

iOS 13系统新增了Dark Mode,需要项目自行适应,系统提供的适应方案简述如下:

  1. UIView框架
    1. 对于UIColor,使用UIColor (DynamicColors)里提供的新API去初始化颜色,例如+[UIColor colorWithDynamicProvider:],或者用Xcode 11在资产里为每个颜色创建一个Color Set然后在外观里选择带有Dark的选项来生成一个动态颜色,代码里通过+[UIColor colorNamed:]来获取颜色。
    2. 对于UIImage,使用Xcode 11在Assets里的外观选择带有Dark的选项来创建一个动态的图片。 
    3. 对于UIVisualEffect,使用iOS 10之后的UIBlurEffectStyle(例如UIBlurEffectStyleRegular),系统会自动切换Light / Dark样式。
    4. 其他内容可在traitCollectionDidChange:里根据self.traitCollection.userInterfaceStyle的值来判断当前的主题。
  2. UIViewController尺寸,通过在traitCollectionDidChange:里根据self.traitCollection.userInterfaceStyle的值来判断当前的主题。

二,系统适应方案的局限性

  1. 对于UIColor,系统有API生成动态颜色,但CGColor却没有,所以到使用CGColor的地方都需要重写traitCollectionDidChange:在里面重新设置一遍,无法保持代码风格的一致性。
  2. 在App回到后台的时候,系统会分别使用Light Mode和Dark Mode为App当前界面截两张图,从而保证你切换了主题后再唤醒App,不会看到颜色跳变的过程,保证了体验,,但由于CALayer带有隐式动画,在截图时UIView已经渲染完新样式了,CALayer还是旧的,从而表现出明显的UI问题(注意下图最终重启App后,色块(CALayer)能看到从白变黑的过程,而界面上其他地方的颜色是看不到变化过程的)。
  3. 对于UIColorUIImage这些对象,如果你将其使用UIView.backgroundColorUIView.tintColor这种系统原生自带的属性,当设备主题发生切换时,UIView会自动去刷新这些值(因为它知道有这些值需要刷新),但如果你是一个自定义视图里的自定义属性(例如QMUIGridView.separatorColor),系统并不知道你总共有任何属性需要更新,所以你依然需要进行traitCollectionDidChange:来重新设置一遍,导致UI代码需要分散到多个地方,不好维护。
  4. 从开发者的角度,让一个项目兼容iOS 13 Dark Mode,和让项目在所有iOS版本下都能支持Dark Mode,这两者的工作量相差不大,都是要对现有的UI代码做大量修改。如果要兼容iOS 13黑暗模式,最大的收益肯定是同时支持所有iOS版本,而系统提供的方案并不考虑iOS 12及以下版本的实现。

三,QMUI提供的方案

基于以上状况,我们设计了QMUITheme组件,它解决的问题是:

  1. 支持全iOS版本的换肤,可设置多个主题。
  2. 兼容iOS 13 Dark Mode,可将Dark Mode映射为App中的某一个主题,对业务而言只需要关心业务主题,不需要关心设备当前是否开启了iOS 13 Dark Mode。
  3. 支持UIColorCGColorUIImageUIVisualEffect的动态化,在视图初始化的时候直接使用这些动态对象即可,不需要强制写在某些updateXxx,xxxDidChange的方法里,以保持最优雅的编码风格。
  4. 支持UIKit自带的视图组件和业务自定义视图组件里与颜色,图片相关属性的自动刷新。

QMUITheme使用教程

拥有5个主题,其中“ Dark”主题对应iOS 13 Dark Mode。在iOS 13下,每次QMUI Demo启动时都会根据当前设备是否开启了Dark模式,来强制将QMUI Demo的主题设置为“默认”或“黑暗”。

一,基本概念解释

  1. QMUI主题管理器同时在iOS 13下它也负责监听系统Dark Mode的切换,变为其映射为已注册的某个业务主题。
  2. QMUI主题管理中心管理QMUIThemeManager的工具,QMUIThemeManager也只有通过QMUIThemeManagerCenter才能生成特定实例的。主要用于实现一个项目里存在多个维度的主题的场景。对于只需要一个维度的主题的项目而言,任何时候都使用QMUIThemeManagerCenter.defaultThemeManager获取manager实例即可。
  3. 主题(theme)对项目而言,主题可以是任意的对象类型,存在多个主题时,不同主题也可以是不相同的类型,甚至可以是一个无意义仅占位用的NSNull。颜色,图片等信息。在QMUI Demo里,每个主题对应一个QMUI配置表,也即NSObject<QDThemeProtocol> *类型。对QMUIThemeManager主题,一个主题对应一个唯一的identifier。与主题类似,标识符对类型也没有要求,只需要支持NSCopying协议即可,所以可以用NSStringNSNumber等常见类型,怎么方便怎么来。
  4. 动态对象换肤的直接操作对象通常只有UIColor(包含CGColor), UIImageUIVisualEffect这三种,在以前,你当拿到一个UIColor对象,它对应什么实体的色值,是确定的,但在13的iOS或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演示为例,每个主题对应一个配置表,每个配置表替换一个NSObject<QDThemeProtocol> *类型。

然后,在提示早的时机初始化QMUIThemeManager,以保证其他使用颜色,图片的地方能获取到正确的值。QMUIDemo中选择的时机是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;
}

做完以上的初始化配置后,其余的就是业务界面的适应了,按照上述提及的,将颜色,图像,效果都换成对应的动态对象。

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;

到此大部分界面已经可以兼容,而对于自定义的视图组件,它们的自定义属性需要在主题切换时被刷新,则可以参照下方来注册属性给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 主题对象

 

人已赞赏
iOS文章

Android一个ListView/GridView顶部新增一个View,滑动ListView/GridView,顶部View跟着滑动

2019-10-18 6:25:34

iOS文章

iOS 关于低耦合封装

2019-10-18 9:35:30

个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
有新消息 消息中心
搜索