夜間模式的探討
與其他App切換夜間模式不同:
微博采取了護眼模式:
兩種方案各有利弊:
-
夜間模式優點:
可以對每一個原生控件
和元素
進行定制(背景色
、字體顏色
、分隔線顏色
等)UI
整體上更加精致;
夜間模式缺點:
夜間主題的配色比較難掌握,對設計有一定的要求。一旦沒有選擇好配色,不僅起不到夜間的效果,反而晃瞎眼;
另一個嚴重的問題是web
頁面以及三方的url
無法控制,如下圖很明顯navigationbar
和tabbar
還處于夜間狀態,中間的web
卻亮瞎眼
知乎活動廣場頁面
-
護眼模式的優點:
與Mac OS 10.14
推出黑色主題不同的是 ,iOS
設備一直對于夜間沒有做特殊的處理,僅僅通過感光元件的檢測來實時調整手機的亮度;護眼模式的出發點也一樣,App自己通過一定的方式再一次降低亮度以達到保護眼睛的作用
全局生效,只要還停留在App內不論是原生
還是web
,所有的頁面都會被護眼的陰影籠罩;
護眼模式的缺點:
感官上沒有夜間模式那么討喜,那么酷炫
選擇適合的方案
如果App內有很多web
和外鏈
,首推微博的護眼方案;如果有自己設計團隊全力配合且大部分為原生的頁面和控件夜間模式可以讓App看起來更高級
我個人更喜歡護眼方案,不僅僅是因為App內web較多,護眼這種模式實施起來起來也更加的簡單
護眼模式是何如做到的?
前面這么多廢話,終于講到正題。憑借多年開發經驗第一次看到微博的護眼模式我果斷的認為微博是通過降低手機屏幕的亮度來護眼的(就像微信打開二維碼時提高了亮度一樣)。于是我自信滿滿的檢查手機的亮度調節開關,驚訝的發現亮度并沒有發生變化!這一下子吊起了我的胃口,飛快的打開百度、google進行搜索,無果!
與此同時開始萌生一個新的想法:難道在keywindow上覆蓋一層灰色半透明的mask??急于驗證的我只能耍流氓的使用逆向工具對微博進行視圖調試!
幸好有牛逼的 MonkeyDev讓逆向App變得如此簡單。不會的同學可以看我的另一篇《MonkeyDev的安裝以及與Reveal配合使用》
配置完MonkeyDev和Reveal后查看,果然!?。。?/p>
微博在最上層加了一個windowLevel
為2099
的UIWindow
,這個window
層級高于keywindow
的0
,UITextEffectsWindow
的10
,UIStatusWindow
的1000
以及UIWindowLevelAlert
的2000
;但是遠低于鍵盤所在window
,這意味著:除了鍵盤之外所有的視圖都會被這個WBSkinCoverWindow
所覆蓋。
如此簡單粗暴卻行之有效的方案!
另外通過Reveal
可以發現:
WBSkinCoverWindow
上添加了一個Opacity
為0.5
的黑色背景layer
既然原理有了,接下來就開始模仿微博實現一個自己的護眼模式吧!
護眼模式的開發
為了降低耦合性,創建一個工具類 ,我這里起名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];
}
思路很簡單,接下來就開始寫切換的具體實現
護眼模式切換的實現
參考微博的實現:
我們也創建一個自己的WESkinCoverLayer
和WESkinCoverWindow
@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].windows
和self.layer.sublayers
從快速找出屬于護眼模式的專用window
和layer
;同時這樣操作在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
- 需要將
window
的userInteractionEnabled
屬性設為NO
,為的是將交互事件傳遞到下面的其他window
上 - UIWindow如果需要覆蓋到屏幕上,有兩種方式:作為某一個
window
的subWindow
或者直接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_animationDidStopBlock
是CAAnimation
的一個分類方法(來自于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
,而之前的keywindow
的rootViewController
還沒有完成transitionContext:
因此需要對switchEyeCareMode:
進行延遲操作,或者將護眼模式的配置推遲到rootViewController
的viewWillAppear:
中。
總結
- 善用觀察學習大廠的App
- 適當了解一些逆向的知識很有益處
- 自己動手擼代碼比看再多的技術文章都管用