前言
黑暗模式已经出来两年多了, ios13可以随意切换,这里简单回顾一下,适配的过程中,一定会碰到非常苦恼的事情,一个是版本兼容的问题,导致代码很多,另外一个是 view 和 controler 的处理还不一样,且非属性UI变量还需要tag来获取更新,那么下面就提供一个简单的方案吧
主题变更相关函数介绍
UITraitEnvironment
为了应对主题的更新,ios8 就已经加入了 UITraitEnvironment 这个协议,并且所有的视图都遵循了这个协议,且 ios12 还加入了黑暗模式相关类型 userInterfaceStyle类型,以方便主题变更变更的判断(ios13才可以切换模式,但仍然要做好 api 版本的兼容判断)
UITraitEnvironment 协议如下所示,有属性和方法,view、controller等都遵循了该协议
@protocol UITraitEnvironment <NSObject>
//通过其属性 userInterfaceStyle 可以获取当前模式,注意只支持ios12以后,可以点进去查看
@property (nonatomic, readonly) UITraitCollection *traitCollection;
//主题更新时,或者默认、黑暗模式切换时回调
- (void)traitCollectionDidChange:(nullable UITraitCollection *)previousTraitCollection;
@end
模式的枚举值如下所示 traitCollection.userInterfaceStyle 即可获取
typedef NS_ENUM(NSInteger, UIUserInterfaceStyle) {
UIUserInterfaceStyleUnspecified,
UIUserInterfaceStyleLight,
UIUserInterfaceStyleDark,
} API_AVAILABLE(tvos(10.0)) API_AVAILABLE(ios(12.0)) API_UNAVAILABLE(watchos);
由于主题变更更多的是颜色相关,因此系统还提供了下面的方法供大家使用(注意其只是根据主题返回响应的颜色而已)
UIColor *dynamicColor = [UIColor colorWithDynamicProvider:
^UIColor * _Nonnull(UITraitCollection * _Nonnull trainCollection) {
if ([trainCollection userInterfaceStyle] == UIUserInterfaceStyleDark) {
return [UIColor blackColor];
} else {
return [UIColor whiteColor];
}
}];
主题变更时函数调用
UITraitEnvironment 协议的回调,仅仅当主题更新时会回调(每次都会回调两次)
- (void)traitCollectionDidChange:(nullable UITraitCollection *)previousTraitCollection;
于此同时,除了协议回调,主题更新时,系统会回调更新布局、重绘界面等操作,如下所示
NSView
- (void)updateLayer;
- (void)drawRect:(NSRect)dirtyRect;
- (void)layout;
- (void)updateConstraints;
UIView
- (void)traitCollectionDidChange:(UITraitCollection *)previousTraitCollection;
- (void)layoutSubviews;
- (void)drawRect:(NSRect)dirtyRect;
- (void)updateConstraints;
- (void)tintColorDidChange;
UIViewController
- (void)traitCollectionDidChange:(UITraitCollection *)previousTraitCollection;
- (void)updateViewConstraints;
- (void)viewWillLayoutSubviews;
- (void)viewDidLayoutSubviews;
UIPresentationController
- (void)traitCollectionDidChange:(UITraitCollection *)previousTraitCollection;
- (void)containerViewWillLayoutSubviews;
- (void)containerViewDidLayoutSubviews;
适配黑暗模式
采用系统重新布局的方法
如果我们使用 frame 布局,我们可能为了适应横竖屏,平时使用的比较多的应该就是 layoutSubviews、viewWillLayoutSubviews、viewDidLayoutSubviews等属性了,在界面、视图更新时,会自动回调下面方法(且可能多次回调),因此在这里设置布局frame等信息
因此我们也可以在这里除了重写我们的frame等信息之外,还要适配黑暗模式,可以通过 traitCollection 或 colorWithDynamicProvider 来针对不同主题显示不同的颜色
//这里仅仅是一个演示
- (void)layoutSubviews {
[super layoutSubviews];
//更新frame等信息
...
//更新颜色
self.view.backgroundColor = [UIColor colorWithDynamicProvider:
^UIColor * _Nonnull(UITraitCollection * _Nonnull trainCollection) {
if ([trainCollection userInterfaceStyle] == UIUserInterfaceStyleDark) {
return [UIColor blackColor];
} else {
return [UIColor whiteColor];
}
}];
//这种估计用的比较多,只需要判断一次
if (self.trainCollection.userInterfaceStyle == UIUserInterfaceStyleDark) {
self.view.backgroundColor = [UIColor blackColor];
} else {
self.view.backgroundColor = [UIColor blackCwhiteColorolor];
}
}
当然由于 layoutSubviews等方法,当布局更新他也会随之回调,因此会有额外的性能消耗,这也是没办法的事情,毕竟适配起来要方便不少,就是 controller 和 view 也要区别对待一些
优缺点:适配方便,但性能略差(每次视图变更都会回调,每回都要走两次,有点多余),使用相对不灵活,只在当前视图或者控制器内更新颜色等使用方便,且ios12版本判断的代码将会到处都是
注意:如果有不少布局不是以属性的方式添加到 view 上,那么可能需要tag等来更新主题颜色,性能还要进一步下降,因此灵活性,再一次下降
自定义适配方案
由于 layoutSubviews,等方法会造成多余的函数调用,因此我们仅仅使用主题相关的 traitCollectionDidChange函数回调来实现该操作
且 UIWindow 继承了 UIView,因此他也实现了 UITraitEnvironment协议,并且它还可以控制整体风格为默认或者黑暗模式,控制代码如下所示
self.window.overrideUserInterfaceStyle = UIUserInterfaceStyleLight;
实现思路:由于我们一般不会主动用window的 traitCollectionDidChange,我们通过 hook,将window实现协议的 traitCollectionDidChange,交换到我们的自定义类中,那样当主题更新时,我们就可以主动回调我们定义的函数了,然后通过 NSMapTable保存我们的视图以及回调,当切换主题时更新我们的回调,且添加回调时,主动回调一次,设置代码写两遍
实现代码如下所示
@interface LightDarkManager : NSObject
//style属性,用于外部备用,也可以设置成 readonly 避免误操作
@property (nonatomic, assign) UIUserInterfaceStyle style;
//appdelegate中注册,需要初始化window后立即调用即可
//传入style,为了避免 window 在 appdelegate 或 Scene 中的问题
+ (void)registerByDefalutStyle:(UIUserInterfaceStyle)style;
//默认注册,使用默认主题light
+ (instancetype)shared;
//主题设置代码回调block,平时使用这个
- (void)setThemeBlock:(void (^)(UIUserInterfaceStyle style))block observe:(id)object;
@end
@interface LightDarkManager () {
//哈希表
NSMapTable *_mapTable;
}
@end
@implementation LightDarkManager
static LightDarkManager *instance = nil;
+ (void)registerByDefalutStyle:(UIUserInterfaceStyle)style {
[self setupChanged];
[self shared];
instance.style = style;
}
+ (instancetype)shared {
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
instance = [[self alloc] init];
//key为视图,设置成weak,当视图释放时,该键值对也会被删除
instance->_mapTable = [NSMapTable mapTableWithKeyOptions:(NSPointerFunctionsWeakMemory| NSPointerFunctionsObjectPersonality) valueOptions:NSPointerFunctionsStrongMemory];
});
return instance;
}
//交换方法
+ (void)setupChanged {
SEL oriSEL = @selector(traitCollectionDidChange:);
SEL swiSEL = @selector(m_traitCollectionDidChange:);
Method oriMethod = class_getInstanceMethod([UIWindow class], oriSEL);
Method swiMethod = class_getInstanceMethod([self class], swiSEL);
//查看原方法是否存在
if (!oriMethod) {
//原方法不存在,给原方法赋值新方法
class_addMethod([UIWindow class], oriSEL, method_getImplementation(swiMethod), method_getTypeEncoding(swiMethod));
return;
}
//向子类添加原方法,实现为新方法,如果添加成功,证明原方法不存在,并添加了一个新的方法占用原方法
BOOL isAdd = class_addMethod([UIWindow class], oriSEL, method_getImplementation(swiMethod), method_getTypeEncoding(swiMethod));
if (isAdd) {
//添加成功后,替换本类中新添加方法为原有的父类方法实现,并未与父类交换
class_replaceMethod([self class], @selector(m_traitCollectionDidChange:), method_getImplementation(oriMethod), method_getTypeEncoding(oriMethod));
}else {
//说明原方法在子类中存在,直接交换即可
method_exchangeImplementations(oriMethod, swiMethod);
}
}
//取决于调用者,window
- (void)m_traitCollectionDidChange:(UITraitCollection *)previousTraitCollection {
//方法每次都会走多次(测试两次),过滤
UIWindow *window = (UIWindow *)self;
if (instance.style == window.traitCollection.userInterfaceStyle) return;
instance.style = window.traitCollection.userInterfaceStyle;
if (instance.style == previousTraitCollection.userInterfaceStyle) return;
//更新所有回调,当然为了避免用户操作卡顿,可以放一个很短的 timer 在 runloop 的 defalutMode 中
[instance invokeThemeBlocks];
}
//设置回调方法
- (void)setThemeBlock:(void (^)(UIUserInterfaceStyle style))block observe:(id)object {
[_mapTable setObject:block forKey:object];
//调用的同时执行一次,避免写两遍代码
block(self.style);
}
//执行所有主题变更的 block
- (void)invokeThemeBlocks {
for (id key in _mapTable) {
void(^block)(UIUserInterfaceStyle) = [_mapTable objectForKey:key];
if (block) block(self.style);
}
}
以上就实现了该功能,使用如下所示
//appDelegate
[LightDarkManager registerByDefalutStyle:self.window.traitCollection.userInterfaceStyle];
//设置主题的界面
[LightDarkManager shared] setThemeBlock:^(UIUserInterfaceStyle style) {
if (style == UIUserInterfaceStyleDark) {
self.view.backgroundColor = [UIColor blackColor];
}else {
self.view.backgroundColor = [UIColor whiteColor];
}
} observe:self];
注意:这里使用很方便,性能随略有提升,但是却没解决 ios12判断等问题,因此需要加强版
自定义适配方案EX
为了解决 ios12 版本判断的问题,这里引出新枚举一次性解决问题,如下所示,不多介绍了
typedef NS_ENUM(NSInteger, LightDarkStyle) {
LightDarkStyleUnspecified,
LightDarkStyleLight,
LightDarkStyleDark,
};
@interface LightDarkManagerEx : NSObject
@property (nonatomic, assign) LightDarkStyle style;
//appdelegate中注册,需要初始化window后立即调用即可
+ (void)registerByDefalutStyle:(LightDarkStyle)style;
+ (void)register;
+ (instancetype)shared;
- (void)setThemeBlock:(void (^)(LightDarkStyle style))block observe:(id)object;
@end
@interface LightDarkManagerEx () {
NSMapTable *_mapTable;
}
@end
@implementation LightDarkManagerEx
static LightDarkManagerEx *instance = nil;
+ (void)registerByDefalutStyle:(LightDarkStyle)style {
[self setupChanged];
[self shared];
instance.style = style;
}
+ (void)register {
[self setupChanged];
[self shared];
instance.style = LightDarkStyleLight;//默认白色
}
+ (instancetype)shared {
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
instance = [[self alloc] init];
instance->_mapTable = [NSMapTable mapTableWithKeyOptions:(NSPointerFunctionsWeakMemory| NSPointerFunctionsObjectPersonality) valueOptions:NSPointerFunctionsStrongMemory];
});
return instance;
}
+ (void)setupChanged {
if (@available(iOS 12.0, *)) {
SEL oriSEL = @selector(traitCollectionDidChange:);
SEL swiSEL = @selector(m_traitCollectionDidChange:);
Method oriMethod = class_getInstanceMethod([UIWindow class], oriSEL);
Method swiMethod = class_getInstanceMethod([self class], swiSEL);
//查看原方法是否存在
if (!oriMethod) {
//原方法不存在,给原方法赋值新方法
class_addMethod([UIWindow class], oriSEL, method_getImplementation(swiMethod), method_getTypeEncoding(swiMethod));
return;
}
//向子类添加原方法,实现为新方法,如果添加成功,证明原方法不存在,并添加了一个新的方法占用原方法
BOOL isAdd = class_addMethod([UIWindow class], oriSEL, method_getImplementation(swiMethod), method_getTypeEncoding(swiMethod));
if (isAdd) {
//添加成功后,替换本类中新添加方法为原有的父类方法实现,并未与父类交换
class_replaceMethod([self class], @selector(m_traitCollectionDidChange:), method_getImplementation(oriMethod), method_getTypeEncoding(oriMethod));
}else {
//说明原方法在子类中存在,直接交换即可
method_exchangeImplementations(oriMethod, swiMethod);
}
}else return;
}
//取决于调用者,window
- (void)m_traitCollectionDidChange:(UITraitCollection *)previousTraitCollection {
if (@available(iOS 12.0, *)) {
//方法每次都会走多次(测试两次),过滤
UIWindow *window = (UIWindow *)self;
if (instance.style == (LightDarkStyle)window.traitCollection.userInterfaceStyle) return;
instance.style = (LightDarkStyle)window.traitCollection.userInterfaceStyle;
if (instance.style == (LightDarkStyle)previousTraitCollection.userInterfaceStyle) return;
[instance invokeThemeBlocks];
}
}
- (void)setThemeBlock:(void (^)(LightDarkStyle style))block observe:(id)object {
[_mapTable setObject:block forKey:object];
//调用的同时执行一次,避免写两遍代码
block(self.style);
}
- (void)invokeThemeBlocks {
for (id key in _mapTable) {
void(^block)(LightDarkStyle) = [_mapTable objectForKey:key];
if (block) block(self.style);
}
}
//appdelegate
if (@available(iOS 12.0, *)) {
[LightDarkManagerEx registerByDefalutStyle:
(LightDarkStyle)self.window.traitCollection.userInterfaceStyle];
}else {
[LightDarkManagerEx register];
}
//设置主题的界面
[[LightDarkManagerEx shared] setThemeBlock:^(LightDarkStyle style) {
if (style == LightDarkStyleDark) {
self.view.backgroundColor = [UIColor blackColor];
}else {
self.view.backgroundColor = [UIColor whiteColor];
}
} observe:self];
自定义方案的不足
上面的两个自定义方案作为一个参考,如果使用建议使用第二个,如果仅支持 ios12以上版本,那么第一个更好
注意:另外上面代码有一个不足之处,你注意到了么,就是在新的类交换了 UIWindow 的方法,但却没有调用UIWindow的函数实现,可能会出现一些小问题,算是一个隐患,并且用户如果使用的是UIWindow的子类,且重写了 traitCollectionDidChange也会出现无法回调的问题
隐患方案: 将m_traitCollectionDidChange定义到 UIWindow的分类中,最好定义到一个 .m文件中,在 UIWindow回到我们的函数时,我们主动调用 m_traitCollectionDidChange方法即可(由于交换了方法,实际调用的是window原来的实现,不会出现递归现象),这里不能调用的原因就是,需要对UIWindow发送m_traitCollectionDidChange的消息,但UIWindow中没有该方法,因此会报错,因此最好在分类中处理,不然得创建新方法,但这样会出现命名隐患,且不好发现😂
window子类问题方案:不传递style,传入window即可,除了保存style,还可以直接使用 window 对象进行交换
tips:如果不喜欢block形式,也可以很容易改成协议的方式,看自己爱好了
最后
这里仅仅提供一个思路,还待完善(已经说明隐患和思路了,其实改动用不了几分钟),快来完善一下吧(内部实际已经加入,repair 的就是😂)
另外,如果有设计多个主题,或者主题方案的话,可以让设计使用固定的一套颜色标签,在不同主题下,显示不同的颜色即可,实现可以参考UIButton,根据主题创建子类,每一个主题对应子类对应的标签,返回颜色都是不一样的,这样性能更高,代码更少,是不是很心动呢(前提设计完全按照这个规范来,否则很难受😂)