ios黑暗模式

1,656 阅读9分钟

前言

黑暗模式已经出来两年多了, ios13可以随意切换,这里简单回顾一下,适配的过程中,一定会碰到非常苦恼的事情,一个是版本兼容的问题,导致代码很多,另外一个是 viewcontroler 的处理还不一样,且非属性UI变量还需要tag来获取更新,那么下面就提供一个简单的方案吧

---- 案例demo

主题变更相关函数介绍

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等信息之外,还要适配黑暗模式,可以通过 traitCollectioncolorWithDynamicProvider 来针对不同主题显示不同的颜色

//这里仅仅是一个演示
- (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等方法,当布局更新他也会随之回调,因此会有额外的性能消耗,这也是没办法的事情,毕竟适配起来要方便不少,就是 controllerview 也要区别对待一些

优缺点:适配方便,但性能略差(每次视图变更都会回调,每回都要走两次,有点多余),使用相对不灵活,只在当前视图或者控制器内更新颜色等使用方便,且ios12版本判断的代码将会到处都是

注意:如果有不少布局不是以属性的方式添加到 view 上,那么可能需要tag等来更新主题颜色,性能还要进一步下降,因此灵活性,再一次下降

自定义适配方案

由于 layoutSubviews,等方法会造成多余的函数调用,因此我们仅仅使用主题相关的 traitCollectionDidChange函数回调来实现该操作

UIWindow 继承了 UIView,因此他也实现了 UITraitEnvironment协议,并且它还可以控制整体风格默认或者黑暗模式,控制代码如下所示

self.window.overrideUserInterfaceStyle = UIUserInterfaceStyleLight;

实现思路:由于我们一般不会主动用windowtraitCollectionDidChange,我们通过 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,根据主题创建子类,每一个主题对应子类对应的标签,返回颜色都是不一样的,这样性能更高,代码更少,是不是很心动呢(前提设计完全按照这个规范来,否则很难受😂)