iOS:Dark Mode-暗黑模式调研

背景

iOS 13苹果公司推出了暗黑模式,APP默认支持,用户可以通过在设置-显示与亮度-外观栏中选择深色来打开暗黑模式,但是,如果开发工程师不进行适配,应用内可能会出现某些视图的颜色变成黑色,影响显示效果。

要防止这种情况可以给控制器或者视图设置overrideUserInterfaceStyle属性为UIUserInterfaceStyleLight或者UIUserInterfaceStyleDark,这样当前视图和它的所有子视图都会固定为Dark或者Light模式。也可以在info.plist中加入UIUserInterfaceStyle键,给定Light值,使整个应用忽略暗黑模式。

苹果公司在News And Updates这样说:

If you need more time to make your apps look fantastic in Dark Mode, or if Dark Mode is not suited for your app, you can learn how to opt out.如果你需要更多的时间让你的APP在暗黑模式下更加出色,或者暗黑模式不适合你的APP,你可以学习如何退出。

同时,适配暗黑模式是强烈建议的,仅在适配暗黑模式的过程中,使用UIUserInterfaceStyle键暂时退出:

Choosing a Specific Interface Style for Your iOS App:Supporting Dark Mode is strongly encouraged. Use the UIUserInterfaceStyle key to opt out only temporarily while you work on improvements to your app's Dark Mode support.

原理

苹果公司使用UITraitCollection对象记录界面环境特征,里面包含Size Class,Layout Direction,User Interface Style信息(Dark或者Light)。每个UIView,UIViewController和UIPresentationController对象都持有这个对象。子视图被添加到父视图的时候,子视图会继承父视图的UITraitCollection,UITraitCollection信息就从UIScreen一直传递到当前显示的UIView:UIScreen->UIWindow->UIPresentationViewController->UIViewController→UIView。

用户更改了系统外观后,系统通过调用以下方法重新渲染视图,完成系统外观的切换:

UIView:
traitCollectionDidChange(_:)
layoutSubviews()
draw(_:)
updateConstraints()
tintColorDidChange()

UIViewController:
traitCollectionDidChange(_:)
updateViewConstraints()
viewWillLayoutSubviews()
viewDidLayoutSubviews()

UIPresentationController:
traitCollectionDidChange(_:)
containerViewWillLayoutSubviews()
containerViewDidLayoutSubviews()

在这些方法调用前,系统会更新UITraitCollection对象,所以要在这些方法中加入Dark模式和Light模式有区别的代码,如Dark模式下要在图片上加一层遮罩,Light则要隐藏。如果写在别的地方,如在初始化方法或者viewDidLoad中,会造成模式切换后,遮罩还在,或者一直不显示。

适配

在适配实践中会总结出更好的实现方式,或者发现很多细节需要处理,这些都会影响开发时间。所以调研时编写Demo并根据实际项目调试效果是很有必要的。

颜色适配

颜色适配只要将UIColor对象改成动态颜色对象即可。动态颜色对象在不同的外观下,有不同的颜色值。它也是UIColor对象,但是创建的方式不一样。UIKit会根据UITraitCollection信息解析出对应外观的颜色值。具体使用如下:

if (@available(iOS 13.0, *)) {
    label.textColor = [UIColor colorWithDynamicProvider:^UIColor * _Nonnull(UITraitCollection * _Nonnull traitCollection) {
        if (traitCollection.userInterfaceStyle == UIUserInterfaceStyleDark) {
            return [[UIColor secondarySystemBackgroundColor] resolvedColorWithTraitCollection:traitCollection];
        } else {
            return lightColor;
        }
    }];
} else {
    label.textColor = lightColor;
};

colorWithDynamicProvider是创建动态颜色的方法。resolvedColorWithTraitCollection是把动态颜色解析成固定颜色的方法,在创建动态颜色的block中不能返回动态颜色,这里在Dark模式下使用了系统的secondarySystemBackgroundColor动态颜色,所以返回时做了解析。

动态颜色也可以通过Xcode创建,步骤如下:

image.png

使用的时候用指定方法获取,如下:

if (@available(iOS 11.0, *)) {
    label.textColor = [UIColor colorNamed:@"testColor"];
} else {
    label.textColor = UIColor.redColor;
}

colorNamed方法只支持iOS11以上版本。

看起来使用很麻烦。具体项目中运用可以封装一下。封装代码案例如下:

#define MJCOLOR [MJDynamicColor shareInstance]
//所有动态颜色获取的地方,适配暗黑模式
@interface MJDynamicColor : NSObject
+ (instancetype)shareInstance;
//背景色
/// 一级背景色,如UIViewController的View的背景色,一般是四周都能接触到屏幕的视图的背景色
@property (nonatomic, strong) UIColor *mj_backgroundColor;
/// 二级背景色,如UITableViewCell的背景色
@property (nonatomic, strong) UIColor *mj_secondaryBackgroundColor;
/// 三级背景色,如UITableViewCell中button的背景色,一般是最上层的视图的背景色
@property (nonatomic, strong) UIColor *mj_tertiaryBackgroundColor;
// UILabel的文字的颜色
/// 类似一级标题
@property (nonatomic, strong) UIColor *mj_labelColor;
/// 类似二级标题
@property (nonatomic, strong) UIColor *mj_secondaryLabelColor;
/// 类似三级标题
@property (nonatomic, strong) UIColor *mj_tertiaryLabelColor;
/// 类似四级标题
@property (nonatomic, strong) UIColor *mj_quaternaryLabelColor;
@end

@implementation MJDynamicColor
...此部分代码省略,都是类似下面代码重写get方法,使用懒加载

- (UIColor *)mj_labelColor {
    if (!_mj_labelColor) {
        UIColor *lightColor = [UIColor mjl_colorFromHexString:@"0x666666" alpha:1.0];
        if (@available(iOS 13.0, *)) {
            _mj_labelColor = [UIColor colorWithDynamicProvider:^UIColor * _Nonnull(UITraitCollection * _Nonnull traitCollection) {
                if (traitCollection.userInterfaceStyle == UIUserInterfaceStyleDark) {
                    return [[UIColor labelColor] resolvedColorWithTraitCollection:traitCollection];
                } else {
                    return lightColor;
                }
            }];
        } else {
            _mj_labelColor = lightColor;
        };
    }
    return _mj_labelColor;
}
@end

这样有两个好处:一是懒加载使得性能提高了;二是多个地方使用相同的颜色时更方便统一修改。

使用起来如下:

label.textColor = MJCOLOR.mj_labelColor;

以上使用方式都是创建动态颜色,也就是自定义的动态颜色,苹果的API也提供了官方的动态颜色,也称为语义颜色,直接使用就可以,在UIInterface.h文件中可以看到。

图片适配

图片适配和颜色适配类似,也有动态图片的概念,通过XCode创建,在.xcassets文件中把图片改成动态图片就行:

iOS:Dark Mode-暗黑模式调研_第1张图片
image.png

使用处的代码不用修改,还是通过imageNamed方法获取。这个是iOS13之前的方法,所以不用判断系统版本

[self.leftCloseButton setImage:[UIImage imageNamed:@"feeds_back_white"]

在夜间模式下如果重新使用一张图片,会使得图片资源大小翻倍,所以一般都是加一层遮罩,特定情况下才使用新图片。这种情况有种偷懒的方法:

#import "UIImageView+NightMask.h"

static const char *MJUIImageViewNightMaskKey = "MJUIImageViewNightMaskKey";

@implementation UIImageView (NightMask)

- (void)traitCollectionDidChange:(nullable UITraitCollection *)previousTraitCollection {
    [super traitCollectionDidChange:previousTraitCollection];
    if (@available(iOS 13.0, *)) {
        if ([self.traitCollection hasDifferentColorAppearanceComparedToTraitCollection:previousTraitCollection]) {
            if (self.traitCollection.userInterfaceStyle == UIUserInterfaceStyleDark) {
                self.mj_nightMask.hidden = false;
            } else {
                self.mj_nightMask.hidden = true;
            }
        }
    } else {
        // Fallback on earlier versions
    }
}

- (UIView *)mj_nightMask {
    UIView *obj = objc_getAssociatedObject(self, MJUIImageViewNightMaskKey);
    if (!obj) {
        UIView *view = [[UIView alloc] init];
        view.backgroundColor = [UIColor mjl_colorFromHexString:@"0x000000" alpha:1.0];
        view.alpha = 0.3;
        [self addSubview:view];
        view.frame = self.bounds;
        objc_setAssociatedObject(self, MJUIImageViewNightMaskKey, view, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
    }
    return obj;
}

- (void)didMoveToWindow:(UIWindow *)newWindow {
    if (@available(iOS 13.0, *)) {
        if (self.traitCollection.userInterfaceStyle == UIUserInterfaceStyleDark) {
            self.mj_nightMask.hidden = false;
        } else {
            self.mj_nightMask.hidden = true;
        }
    } else {
        // Fallback on earlier versions
    }
}

@end

给UIImageView添加一个分类,重写traitCollectionDidChange方法,这个方法在traitCollection更改时会调用,这时候加一个遮罩就可以了。重写了didMoveToWindow方法的原因是,在Dark模式下启动APP,不会显示Dark模式(夜间模式)的外观。因为traitCollectionDidChange没有调用,这个方法在traitCollection更改的时候才会调用。

总结:

编写Demo时发现的很费时间的点,也发现给图片或者颜色统一做处理行不通,夜间模式下的每个页面都需要UI重新设计,开发也需要重新联调每个页面,需要的时间非常漫长。

  1. 在颜色适配中,每个夜间(Dark)模式下给的颜色都要UI重新设计,并和开发联调,因为夜间模式下,不能随便给一个对应颜色,使用苹果官方提供的动态颜色效果也差(相当于开发人员自己来设计UI,并反复调试效果)。所以需要给APP所有页面重新设计一套夜间模式下的UI。

  2. 图片适配中,单一处理行不通,也需要每个页面单独过UI如:

    1. 统一添加遮罩:本身就是起遮罩作用的UIImageView。
    2. 统一添加遮罩:很多图片的外边缘部分是透明的,加遮罩后外边缘不透明了,显示出边缘部分了。
    3. 统一添加遮罩:有些图片会动态修改大小,但是遮罩不会跟随着变动。
    4. 给定新图片 :新图片也需要UI重新设计的时间,因为要保证和页面其它部分协调,直接给定一张单一方式处理的图片,如只是改了下图片的亮度,很可能跟页面不协调。

折中方案:

  1. 只修改背景色,图片和文字在用户能正常使用的情况下都不做处理,我看微信在夜间模式下的效果基本就是这样。
  2. 整个APP不支持暗黑模式,因为苹果官方并没有在APP Store Review Guidelines中规定某些类型APP必须兼容暗黑模式,某些不需要兼容,而是没有提到相关内容。

你可能感兴趣的:(iOS:Dark Mode-暗黑模式调研)