微博iOS的护眼模式

夜间模式的探讨

与其他App切换夜间模式不同:


知乎的夜间模式

微博采取了护眼模式:


微博的护眼模式

两种方案各有利弊:

  • 夜间模式优点
    可以对每一个原生控件元素进行定制(背景色字体颜色分隔线颜色等)UI整体上更加精致;
    夜间模式缺点
    夜间主题的配色比较难掌握,对设计有一定的要求。一旦没有选择好配色,不仅起不到夜间的效果,反而晃瞎眼;
    另一个严重的问题是web页面以及三方的url无法控制,如下图很明显navigationbartabbar还处于夜间状态,中间的web却亮瞎眼
    知乎活动广场页面
  • 护眼模式的优点
    Mac OS 10.14推出黑色主题不同的是 ,iOS设备一直对于夜间没有做特殊的处理,仅仅通过感光元件的检测来实时调整手机的亮度;护眼模式的出发点也一样,App自己通过一定的方式再一次降低亮度以达到保护眼睛的作用
    全局生效,只要还停留在App内不论是原生还是web,所有的页面都会被护眼的阴影笼罩;
    护眼模式的缺点
    感官上没有夜间模式那么讨喜,那么酷炫

选择适合的方案

如果App内有很多web外链,首推微博的护眼方案;如果有自己设计团队全力配合且大部分为原生的页面和控件夜间模式可以让App看起来更高级
我个人更喜欢护眼方案,不仅仅是因为App内web较多,护眼这种模式实施起来起来也更加的简单

护眼模式是何如做到的?

前面这么多废话,终于讲到正题。凭借多年开发经验第一次看到微博的护眼模式我果断的认为微博是通过降低手机屏幕的亮度来护眼的(就像微信打开二维码时提高了亮度一样)。于是我自信满满的检查手机的亮度调节开关,惊讶的发现亮度并没有发生变化!这一下子吊起了我的胃口,飞快的打开百度、google进行搜索,无果!
与此同时开始萌生一个新的想法:难道在keywindow上覆盖一层灰色半透明的mask??急于验证的我只能耍流氓的使用逆向工具对微博进行视图调试!
幸好有牛逼的 MonkeyDev让逆向App变得如此简单。不会的同学可以看我的另一篇《MonkeyDev的安装以及与Reveal配合使用》

配置完MonkeyDev和Reveal后查看,果然!!!!


image.png

微博在最上层加了一个windowLevel2099UIWindow,这个window层级高于keywindow0UITextEffectsWindow10UIStatusWindow1000以及UIWindowLevelAlert2000;但是远低于键盘所在window,这意味着:除了键盘之外所有的视图都会被这个WBSkinCoverWindow所覆盖。

如此简单粗暴却行之有效的方案!

另外通过Reveal可以发现:
WBSkinCoverWindow上添加了一个Opacity0.5的黑色背景layer

image.png

既然原理有了,接下来就开始模仿微博实现一个自己的护眼模式吧!

护眼模式的开发

为了降低耦合性,创建一个工具类 ,我这里起名WEEyeCareModeUtil
这个类暴露三个接口方法:

/**
 * 单例创建方法
 * @return 单例对象
 */
+ (instancetype)sharedUtil;

/**
 * 护眼模式是否已经打开
 * @return 是否已经打开
 */
- (BOOL)queryEyeCareModeStatus;

/**
 * 切换护眼模式
 * @param on 是否打开
 */
- (void)switchEyeCareMode:(BOOL)on;

分别是创建方法查询状态方法以及切换模式方法

  • 单例就不多做介绍了,不会的可以参考《iOS单例的精心设计历程》

  • 查询状态的实现很简单,读取设置里的状态

/// NSUserDefaults存的key
static NSString * const kEyeCareModeStatus = @"kEyeCareModeStatus";
- (BOOL)queryEyeCareModeStatus
{
    return [[NSUserDefaults standardUserDefaults] boolForKey:kEyeCareModeStatus];
}
  • 切换状态
- (void)switchEyeCareMode:(BOOL)on
{
    // 切换的具体实现
    ...

    // 将状态写入设置
    [[NSUserDefaults standardUserDefaults] setBool:on forKey:kEyeCareModeStatus];
    [[NSUserDefaults standardUserDefaults] synchronize];
}

思路很简单,接下来就开始写切换的具体实现

护眼模式切换的实现

参考微博的实现:


image.png

我们也创建一个自己的WESkinCoverLayerWESkinCoverWindow

@interface WESkinCoverLayer : CALayer
@end

@implementation WESkinCoverLayer
@end
/// 专用于护眼模式的UIWindow,这样才能在`[[UIApplication sharedApplication] windows]`里方便地区分出来
@interface WESkinCoverWindow : UIWindow
@end

@implementation WESkinCoverWindow

- (instancetype)initWithFrame:(CGRect)frame
{
    if (self = [super initWithFrame:frame]) {
        // 移除所有的子layer
        [self.layer.sublayers makeObjectsPerformSelector:@selector(removeFromSuperlayer)];
        // 添加layer
        WESkinCoverLayer *skinCoverLayer = [WESkinCoverLayer layer];
        skinCoverLayer.frame = CGRectMake(0, 0, frame.size.width, frame.size.height);
        skinCoverLayer.backgroundColor = UIColorBlack.CGColor;
        skinCoverLayer.opacity = 0.5;
        [self.layer addSublayer:skinCoverLayer];
    }
    return self;
}

@end

分别创建两个子类是为了方便的从[UIApplication sharedApplication].windowsself.layer.sublayers从快速找出属于护眼模式的专用windowlayer;同时这样操作在Reveal中也能方便的找到他们

回到工具类WEEyeCareModeUtil

懒加载一个WESkinCoverWindow的实例:

/// 覆盖window的level
static NSInteger const kWeSkinCoverWindowLevel = 2099;
#pragma mark - setter & getter
- (WESkinCoverWindow *)skinCoverWindow
{
   if (!_skinCoverWindow) {
       // 给window赋值上初始的frame,在ios9之前如果不赋值系统默认认为是CGRectZero
       _skinCoverWindow = [[WESkinCoverWindow alloc] initWithFrame:CGRectMake(0, 0, SCREEN_WIDTH, SCREEN_HEIGHT)];
       _skinCoverWindow.windowLevel = kWeSkinCoverWindowLevel;
       _skinCoverWindow.userInteractionEnabled = NO;
       // 添加到UIScreen
       [_skinCoverWindow makeKeyWindow];
   }
   return _skinCoverWindow;
}

需要注意:

  • windowLevel设置大一些,我们参考微博的做法,将其设置为2099
  • 需要将windowuserInteractionEnabled属性设为NO,为的是将交互事件传递到下面的其他window
  • UIWindow如果需要覆盖到屏幕上,有两种方式:作为某一个windowsubWindow或者直接makeKeyWindow;这里显然使用后者更加合理。

上面我们通过[_skinCoverWindow makeKeyWindow]成功将mask显示在屏幕上,但[UIApplication sharedApplication]只能有一个keywindow,所以当skinCoverWindow加到UIScreen上之后需要将将key还给上一个keywindow

创建一个弱引用的属性用来记录上一个keywindow

// 之前的一个window
@property(nonatomic, weak) UIWindow *previousKeyWindow;

显示代码:

// 记录上一个keywindow
self.previousKeyWindow = [UIApplication sharedApplication].keyWindow;
// 将skinCoverWindow显示出来
self.skinCoverWindow.hidden = NO;
// 显示之后把key还给之前的window
[self.previousKeyWindow makeKeyWindow];

隐藏代码:

if ([[UIApplication sharedApplication].windows containsObject:self.skinCoverWindow]) {
    // 隐藏
    self.skinCoverWindow.hidden = YES;
    // 清空
    self.previousKeyWindow = nil;
}

至此,我们已经完成了大部分工作,但是运行项目会发现灰色半透明遮罩出现和消失都非常突兀。而微博明显加了动画,我们也依葫芦画瓢优化一下:

出现代码:

// 记录上一个keywindow
self.previousKeyWindow = [UIApplication sharedApplication].keyWindow;
// 显示出来
self.skinCoverWindow.hidden = NO;
// 出现动画
CABasicAnimation *opacityAnimation = [CABasicAnimation animationWithKeyPath:@"opacity"];
opacityAnimation.fromValue = @(0);
opacityAnimation.toValue = @(1);
opacityAnimation.duration = kAnimationDuration;
opacityAnimation.timingFunction = [CAMediaTimingFunction functionWithName:kCAMediaTimingFunctionEaseInEaseOut];
opacityAnimation.fillMode = kCAFillModeForwards;
opacityAnimation.removedOnCompletion = NO;
opacityAnimation.qmui_animationDidStopBlock = ^(__kindof CAAnimation *aAnimation, BOOL finished) {
    // 把key还给之前的window
    [self.previousKeyWindow makeKeyWindow];
};
[self.skinCoverWindow.layer addAnimation:opacityAnimation forKey:@"showAnimation"];

消失代码:

[self.previousKeyWindow makeKeyWindow];
if ([[UIApplication sharedApplication].windows containsObject:self.skinCoverWindow]) {
    // 隐藏skinCoverWindow
    // 消失动画
    CABasicAnimation *opacityAnimation = [CABasicAnimation animationWithKeyPath:@"opacity"];
    opacityAnimation.fromValue = @(1);
    opacityAnimation.toValue = @(0);
    opacityAnimation.duration = kAnimationDuration;
    opacityAnimation.timingFunction = [CAMediaTimingFunction functionWithName:kCAMediaTimingFunctionEaseInEaseOut];
    opacityAnimation.fillMode = kCAFillModeForwards;
    opacityAnimation.removedOnCompletion = NO;
    opacityAnimation.qmui_animationDidStopBlock = ^(__kindof CAAnimation *aAnimation, BOOL finished) {
        self.skinCoverWindow.hidden = YES;
        self.previousKeyWindow = nil;
    };
    [self.skinCoverWindow.layer addAnimation:opacityAnimation forKey:@"hideAnimation"];
} else {
    NSAssert(NO, @"Error:关闭护眼模式的时windows没有找到WESkinCoverWindow!!");
}

其中qmui_animationDidStopBlockCAAnimation的一个分类方法(来自于QMUI中的CAAnimation+QMUI);当然也可以自己实现CAAnimationDelegate用代理方法拿到动画完成回调

使用注意

最后在项目中使用的时候需要注意:

  • 每次进入App时候在didFinishLaunchingWithOptions:中需要根据设置里保存的状态判断是否开启护眼模式:
// 护眼模式配置
if ([[WEEyeCareModeUtil sharedUtil] queryEyeCareModeStatus]) {
    [[WEEyeCareModeUtil sharedUtil] switchEyeCareMode:YES];
}

运行项目会发现系统报错

*** Assertion failure in -[UIApplication _runWithMainScene:transitionContext:completion:], 
/BuildRoot/Library/Caches/[com.apple.xbs/Sources/UIKitCore/UIKit-3698.93.8/UIApplication.m:3855](com.apple.xbs/Sources/UIKitCore/UIKit-3698.93.8/UIApplication.m:3855)
(lldb)

这是因为在didFinishLaunchingWithOptions:方法中我们通常会像下面这样创建视图界面:

// 界面
self.window = [[UIWindow alloc] initWithFrame:[[UIScreen mainScreen] bounds]];
WEHomeViewController *homeVC = [[WEHomeViewController alloc] init];
WENavigationController *navi = [[WENavigationController alloc] initWithRootViewController:homeVC];
self.window.rootViewController = navi;
[self.window makeKeyAndVisible];

可以发现我们创建了一个window并将其makeKeyAndVisible,随后我们在switchEyeCareMode:里又创建我们自己的护眼模式window并将其makeKeyWindow,而之前的keywindowrootViewController还没有完成transitionContext:
因此需要对switchEyeCareMode:进行延迟操作,或者将护眼模式的配置推迟到rootViewControllerviewWillAppear:中。

总结

  • 善用观察学习大厂的App
  • 适当了解一些逆向的知识很有益处
  • 自己动手撸代码比看再多的技术文章都管用

你可能感兴趣的:(微博iOS的护眼模式)