前言
現(xiàn)在大部分的智能移動(dòng)設(shè)備通過自動(dòng)旋轉(zhuǎn)赴蝇,能夠自動(dòng)切換去呈現(xiàn)最適合當(dāng)前屏幕顯示的內(nèi)容菩浙,無疑大大提升了使用者的用戶體驗(yàn)。不過作為開發(fā)者,想要達(dá)到完美的適配效果劲蜻,還是要下一番功夫鉆研嘗試才能做得的陆淀。筆者就根據(jù)自己適配屏幕自動(dòng)旋轉(zhuǎn)的工作經(jīng)驗(yàn),在此做一點(diǎn)總結(jié)先嬉。
硬件原理
為了檢測設(shè)備(最關(guān)鍵的就是面子——屏幕)當(dāng)前在三維空間中的朝向轧苫,現(xiàn)在的智能設(shè)備都內(nèi)置了加速計(jì)。這一部分完全參照來源【1】:
通過感知特定方向的慣性力總量疫蔓,加速計(jì)可以測量出加速度和重力含懊,ios設(shè)備內(nèi)的加速計(jì)是一個(gè)三軸加速計(jì),這意味著它能夠檢測出三維空間中的運(yùn)動(dòng)或重力引力衅胀。因此加速計(jì)不但可以指示握持電話的方式(如自動(dòng)旋轉(zhuǎn)功能)岔乔,而且如果電話放在桌子上的話還可以指示電話的正面朝上還是朝下。
加速計(jì)可以測量g引力(g代表重力)拗小,因此加速計(jì)返回值為1.0時(shí)重罪,表示在特定的方向上感知到1g。
- 如果是靜止握持iphone而沒有任何運(yùn)動(dòng)哀九,那么地球引力對(duì)其施加的力大約為1g
- 如果是縱向豎直握持剿配,那么設(shè)備會(huì)檢測并報(bào)告在其y軸上施加的力大約為1g
- 如果是以一定的角度握持,那么1g的力會(huì)分布到不同的軸上阅束,這取決于握持的方式呼胚,在以45度握持時(shí),1g的力會(huì)均勻的分解到兩個(gè)軸上息裸。如果檢測到加速計(jì)值遠(yuǎn)大于1g蝇更,那么可以判斷是突然運(yùn)動(dòng),呼盆,正常使用時(shí)加速計(jì)在任何一個(gè)軸上都不會(huì)檢測到遠(yuǎn)大于1g的值年扩,如果搖動(dòng)、墜落或投擲設(shè)備访圃,那么加速計(jì)便會(huì)在一個(gè)或多個(gè)軸上檢測到很大的力厨幻。
下圖所示加速計(jì)所使用的三軸結(jié)構(gòu)
當(dāng)然,如今的智能手機(jī)里往往不光內(nèi)置了加速計(jì)腿时,往往還有陀螺儀况脆。這一方面的知識(shí)就由大家自行去挖掘吧,很多游戲都是利用它去實(shí)現(xiàn)很自然的操作感批糟。
軟件適配
朝向定義
既然硬件能獲取到當(dāng)前屏幕的朝向格了,蘋果的SDK也一定會(huì)為開發(fā)者提供接口指定有哪些朝向可選,以及如何獲取到當(dāng)前朝向徽鼎。在 UIDevice.h 以及 UIApplication.h 中可見其定義如下:
7種設(shè)備朝向:
typedef NS_ENUM(NSInteger, UIDeviceOrientation) {
UIDeviceOrientationUnknown,
UIDeviceOrientationPortrait, // Device oriented vertically, home button on the bottom
UIDeviceOrientationPortraitUpsideDown, // Device oriented vertically, home button on the top
UIDeviceOrientationLandscapeLeft, // Device oriented horizontally, home button on the right
UIDeviceOrientationLandscapeRight, // Device oriented horizontally, home button on the left
UIDeviceOrientationFaceUp, // Device oriented flat, face up
UIDeviceOrientationFaceDown // Device oriented flat, face down
} __TVOS_PROHIBITED;
5種界面朝向:
// Note that UIInterfaceOrientationLandscapeLeft is equal to UIDeviceOrientationLandscapeRight (and vice versa).
// This is because rotating the device to the left requires rotating the content to the right.
typedef NS_ENUM(NSInteger, UIInterfaceOrientation) {
UIInterfaceOrientationUnknown = UIDeviceOrientationUnknown,
UIInterfaceOrientationPortrait = UIDeviceOrientationPortrait,
UIInterfaceOrientationPortraitUpsideDown = UIDeviceOrientationPortraitUpsideDown,
UIInterfaceOrientationLandscapeLeft = UIDeviceOrientationLandscapeRight,
UIInterfaceOrientationLandscapeRight = UIDeviceOrientationLandscapeLeft
} __TVOS_PROHIBITED;
可見二者的枚舉值相互之間對(duì)應(yīng)得上盛末。
另外還有可組合使用的OrientationMask定義弹惦,通常在頁面聲明支持的朝向時(shí)用到,后面再展開討論悄但。
typedef NS_OPTIONS(NSUInteger, UIInterfaceOrientationMask) {
UIInterfaceOrientationMaskPortrait = (1 << UIInterfaceOrientationPortrait),
UIInterfaceOrientationMaskLandscapeLeft = (1 << UIInterfaceOrientationLandscapeLeft),
UIInterfaceOrientationMaskLandscapeRight = (1 << UIInterfaceOrientationLandscapeRight),
UIInterfaceOrientationMaskPortraitUpsideDown = (1 << UIInterfaceOrientationPortraitUpsideDown),
UIInterfaceOrientationMaskLandscape = (UIInterfaceOrientationMaskLandscapeLeft | UIInterfaceOrientationMaskLandscapeRight),
UIInterfaceOrientationMaskAll = (UIInterfaceOrientationMaskPortrait | UIInterfaceOrientationMaskLandscapeLeft | UIInterfaceOrientationMaskLandscapeRight | UIInterfaceOrientationMaskPortraitUpsideDown),
UIInterfaceOrientationMaskAllButUpsideDown = (UIInterfaceOrientationMaskPortrait | UIInterfaceOrientationMaskLandscapeLeft | UIInterfaceOrientationMaskLandscapeRight),
} __TVOS_PROHIBITED;
朝向獲取和設(shè)置
有了朝向的定義肤频,該如何獲取當(dāng)前的朝向取值呢?
如果是要獲取設(shè)備朝向算墨,可以直接通過 UIDevice 實(shí)例的屬性
// return current device orientation. this will return UIDeviceOrientationUnknown unless device orientation notifications are being generated.
@property(nonatomic,readonly) UIDeviceOrientation orientation __TVOS_PROHIBITED;
需要注意注釋的內(nèi)容,也就是必須首先在 UIDevice 朝向通知生成之后才可以正常獲取朝向數(shù)據(jù)汁雷。
也就是要監(jiān)聽UIDevice拋出的系統(tǒng)通知 UIDeviceOrientationDidChangeNotification
[[NSNotificationCenter defaultCenter]addObserver:self
selector:@selector(updateOrientation:)
name:UIDeviceOrientationDidChangeNotification object:nil];
不過這里其實(shí)有一點(diǎn)小坑净嘀,那就是還有一對(duì)關(guān)鍵的接口蘋果沒有直接告訴你,那就是
- (void)beginGeneratingDeviceOrientationNotifications __TVOS_PROHIBITED; // nestable
- (void)endGeneratingDeviceOrientationNotifications __TVOS_PROHIBITED;
必須要在調(diào)用前者之后侠讯,才會(huì)在每次設(shè)備朝向變化時(shí)觸發(fā) UIDeviceOrientationDidChangeNotification 通知挖藏。
不過沒有必要的話,也要及時(shí)調(diào)用后者去結(jié)束對(duì)加速計(jì)數(shù)據(jù)的獲取厢漩,默默的為用戶電池續(xù)航助力膜眠。
類似的,也同樣可以通過監(jiān)聽下面兩個(gè)通知去獲取UIInterfaceOrientation的變化:
UIKIT_EXTERN NSString *const UIApplicationWillChangeStatusBarOrientationNotification __TVOS_PROHIBITED; // userInfo contains NSNumber with new orientation
UIKIT_EXTERN NSString *const UIApplicationDidChangeStatusBarOrientationNotification __TVOS_PROHIBITED; // userInfo contains NSNumber with old orientation
二者的差異關(guān)鍵是在notification的userInfo中攜帶的值溜嗜,一個(gè)是新的朝向值宵膨,一個(gè)是舊的朝向值,可不要搞反了哦炸宵。
再有是通過UIApplication的下面這個(gè)屬性也可以獲取界面朝向辟躏。
// Explicit setting of the status bar orientation is more limited in iOS 6.0 and later.
@property(readwrite, nonatomic) UIInterfaceOrientation statusBarOrientation NS_DEPRECATED_IOS(2_0, 9_0) __TVOS_PROHIBITED;
有的同學(xué)可能會(huì)有疑問,DeviceOrientation 和 StatusBarOrientation是否可以等同使用土全?關(guān)于這個(gè)問題捎琐,有句話說的好:
紙上得來終覺淺,絕知此事要躬行
動(dòng)手試一試就會(huì)明白裹匙,二者實(shí)則有著本質(zhì)不同瑞凑。
真相在此:前者是指示設(shè)備朝向,而后者則是指示當(dāng)前界面中狀態(tài)欄的朝向概页;在[UIDevice beginGeneratingDeviceOrientationNotifications]之后籽御,每次設(shè)備旋轉(zhuǎn),都會(huì)有UIDeviceOrientationDidChangeNotification的通知生成绰沥,而 UIApplicationWillChangeStatusBarOrientationNotification 則是當(dāng)前顯示controller支持對(duì)應(yīng)的InterfaceOrientation時(shí)才會(huì)觸發(fā)篱蝇。
所以可能會(huì)出現(xiàn)這種情況,DeviceOrientation 值 為UIDeviceOrientationLandscapeLeft徽曲,但I(xiàn)nterfaceOrientation 值卻是 UIInterfaceOrientationPortrait零截,下圖就是典型的例子:
另外,某些應(yīng)用場景下秃臣,還需要去手動(dòng)設(shè)置屏幕旋轉(zhuǎn)涧衙,比如播放器往往都既支持自動(dòng)旋轉(zhuǎn)屏幕去切換全屏播放哪工,同時(shí)也允許用戶去手動(dòng)切換全屏或小屏播放。但翻看了半天API描述和文檔弧哎,要么就是不提供接口雁比,要么就是警告設(shè)置受限,那要怎么做呢撤嫩?其實(shí)很簡單偎捎,只要兩行代碼搞定:
NSNumber *value = @(UIInterfaceOrientationPortrait);//或者別的想要的值
[[UIDevice currentDevice] setValue:value forKey:@"orientation"];
App及頁面適配
- App全局配置
App中全局配置支持朝向的地方,最方便的就是在工程的Target中了序攘,如圖所示:
理所當(dāng)然全局配置其優(yōu)先級(jí)當(dāng)然是最高的茴她,即使某個(gè)頁面聲明支持某Orientation,但全局配置中并沒有選中對(duì)應(yīng)的Device Orientation程奠,是不會(huì)起效的丈牢。
-
單個(gè)頁面配置
具體到某個(gè)頁面(controller)層級(jí)的配置,UIViewController提供了如下的回調(diào)方法// New Autorotation support. - (BOOL)shouldAutorotate NS_AVAILABLE_IOS(6_0) __TVOS_PROHIBITED; - (UIInterfaceOrientationMask)supportedInterfaceOrientations NS_AVAILABLE_IOS(6_0) __TVOS_PROHIBITED; // Returns interface orientation masks. - (UIInterfaceOrientation)preferredInterfaceOrientationForPresentation NS_AVAILABLE_IOS(6_0) __TVOS_PROHIBITED;
第一個(gè)方法在首次進(jìn)入controller以及屏幕方向未鎖定且觸發(fā)旋轉(zhuǎn)時(shí)會(huì)被系統(tǒng)調(diào)用(且不重寫的話瞄沙,默認(rèn)返回值為YES)己沛,如果返回NO,那么表明該頁面不支持對(duì)屏幕旋轉(zhuǎn)做適配距境;若返回YES申尼,則表明支持旋轉(zhuǎn),但具體適配了哪幾個(gè)朝向肮疗,則依賴于supportedInterfaceOrientations 方法的返回值晶姊,也就是UIInterfaceOrientationMask類型的Option組合。
看起來并不復(fù)雜對(duì)不對(duì)伪货?在設(shè)定了App的全局配置们衙,并在相應(yīng)的controller中實(shí)現(xiàn)了這些回調(diào)之后發(fā)現(xiàn),有同學(xué)可能會(huì)失望地發(fā)現(xiàn)碱呼,設(shè)備旋轉(zhuǎn)時(shí)這些方法卻并沒有期望地那樣被調(diào)到蒙挑,這是為什么呢?
通過反復(fù)驗(yàn)證愚臀,發(fā)現(xiàn)其實(shí)系統(tǒng)確實(shí)會(huì)調(diào)用這個(gè)方法忆蚀,但默認(rèn)執(zhí)行粒度是到系統(tǒng)級(jí)的 Container View Controller(UINavigationController/UITabBarController)為止(其實(shí)直接掛在UIWindow上作為其rootViewController的UIViewController對(duì)象的 shouldAutoRotate 方法也會(huì)得到調(diào)用,但畢竟大多數(shù)情況下姑裂,我們不會(huì)用這么簡單的組合結(jié)構(gòu)的)馋袜。所以我們額外需要實(shí)現(xiàn)的一步,就是轉(zhuǎn)發(fā)這個(gè)調(diào)用消息到我們真正想要處理的那個(gè)controller上舶斧。當(dāng)然欣鳖,可以通過hook系統(tǒng)類的對(duì)應(yīng)方法去做實(shí)現(xiàn),但筆者采用的是在自行定義的UINavigationController繼承類中重寫這些方法:
#pragma mark Orientation
- (BOOL)shouldAutorotate
{
BOOL shouldAutorotate = NO;
UIViewController *viewController;
if (IOS_VERSION_FLOAT_VALUE >= 8.0)
{
viewController = [self visibleViewController];
}
else
{
viewController = [self topViewController];
}
if ([viewController isKindOfClass:K12RootViewController.class] && ((K12RootViewController *)viewController).visibleNav) {
viewController = ((K12RootViewController *)viewController).visibleNav;
}
if (viewController.ht_currentChildViewController) {
viewController = viewController.ht_currentChildViewController;
}
if ([viewController isKindOfClass:[UIViewController class]])
{
shouldAutorotate = [(UIViewController *)viewController shouldAutorotate];
}
//彈框也要支持旋轉(zhuǎn)
if ([viewController isKindOfClass:K12PlayerController.class] || ((IOS_VERSION_FLOAT_VALUE >= 8.0) ? [viewController isKindOfClass:UIAlertController.class] : NO)) {
return YES;
}
else {
return NO;
}
return shouldAutorotate;;
}
- (UIInterfaceOrientationMask)supportedInterfaceOrientations
{
NSUInteger supportedInterfaceOrientations = UIInterfaceOrientationMaskPortrait;
UIViewController *viewController;
if (IOS_VERSION_FLOAT_VALUE >= 8.0)
{
viewController = [self visibleViewController];
}
else
{
viewController = [self topViewController];
}
if ([viewController isKindOfClass:K12RootViewController.class] && ((K12RootViewController *)viewController).visibleNav) {
viewController = ((K12RootViewController *)viewController).visibleNav;
}
if (viewController.ht_currentChildViewController) {
viewController = viewController.ht_currentChildViewController;
}
//向UIAlertController發(fā)送supportedInterfaceOrientations消息會(huì)crash……
if ([viewController isKindOfClass:UIAlertController.class]) {
return UIInterfaceOrientationMaskAllButUpsideDown;
}
if ([viewController isKindOfClass:[UIViewController class]])
{
supportedInterfaceOrientations = [(UIViewController *)viewController supportedInterfaceOrientations];
}
return supportedInterfaceOrientations;
}
可以看到其中有各種各樣case的處理茴厉,原因就是除了播放頁面支持豎屏泽台、左橫屏以及右橫屏(UIInterfaceOrientationMaskAllButUpsideDown)之外什荣,我們產(chǎn)品中的其他頁面都是只支持橫屏顯示的(UIInterfaceOrientationMaskPortrait),同時(shí)在當(dāng)前頁面上有UIAlertController(iOS8 之后)彈出時(shí)怀酷,也要設(shè)置其支持跟隨屏幕旋轉(zhuǎn)稻爬。
類似的,如果 UIWindow 對(duì)象的 rootViewController 是 UITabBarController 的話蜕依,則需要轉(zhuǎn)發(fā)消息給其 selectedViewController 屬性對(duì)象桅锄,具體實(shí)現(xiàn)就不再贅言啦。
- 踩過的坑
說起來样眠,項(xiàng)目開發(fā)中不踩點(diǎn)坑簡直對(duì)不起程序猿這個(gè)title啊 —— 前面提到過
...掛在UIWindow上作為其rootViewController的UIViewController對(duì)象的 shouldAutoRotate 方法也會(huì)得到調(diào)用
這里往往會(huì)隱藏一個(gè)問題竞滓,默認(rèn)在AppDelegate.m中,我們會(huì)這樣做:
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
...
self.window = [[UIWindow alloc] initWithFrame:[UIScreen mainScreen].bounds];
self.k12RootController = [[K12RootViewController alloc] init];
K12NavigationController *navController = [[K12NavigationController alloc] initWithRootViewController: self.k12RootController];
self.window.rootViewController = navController;
[self.window makeKeyAndVisible];
...
}
當(dāng)然吹缔,這看起來沒有問題。但是假如App中還存在別的 UIWindow 對(duì)象呢锯茄?旋轉(zhuǎn)時(shí)厢塘,它的rootViewController 的 shouldAutoRotate 方法也將被調(diào)用,若沒有重寫過肌幽,則其默認(rèn)返回YES晚碾;如果與其他 UIWindow對(duì)象(特別是keyWindow) 所呈現(xiàn)的最頂部頁面的返回值不一致,就會(huì)出現(xiàn)一些神奇的表現(xiàn)喂急,如下圖所示:
切換到橫屏下時(shí)格嘁,狀態(tài)欄居然消失了!廊移!該情況的出現(xiàn)吸申,就是因?yàn)樵谠摯痤}頁面的上一個(gè)頁面(播放頁面)中使用了一個(gè)第三方組件去繪制Menu剪撬,而其設(shè)計(jì)存在瑕疵,在生成Menu對(duì)象而非顯示時(shí)就已經(jīng)生成了一個(gè)UIWindow對(duì)象并持有了它。然后在進(jìn)入答題頁面時(shí)丙躏,雖然對(duì)應(yīng)的controller的 shouldAutoRotate 方法返回了 NO,但Menu對(duì)應(yīng)的UIWindow對(duì)象其rootViewController默認(rèn)返回YES止毕,導(dǎo)致出現(xiàn)頁面保持豎屏顯示套才,但狀態(tài)欄響應(yīng)了旋轉(zhuǎn)的奇怪現(xiàn)象。
這個(gè)問題最終還是通過hook掉 UIViewController 的shouldAutoRotate 方法辱揭,去追蹤究竟是哪個(gè)controller對(duì)象返回了默認(rèn)值 YES 才最終大白天下离唐。這也提醒我們,對(duì)開源庫的品質(zhì)也是謹(jǐn)慎對(duì)待的问窃,往往太復(fù)雜業(yè)務(wù)場景亥鬓,還是需要自己去定制功能才能滿足。
總結(jié)
這篇文章也算是在參與某產(chǎn)品開發(fā)過程中泡躯,屏幕旋轉(zhuǎn)適配過程中贮竟,踩了不少坑之后經(jīng)驗(yàn)教訓(xùn)的一個(gè)總結(jié)丽焊。當(dāng)然,想要實(shí)現(xiàn)頁面的橫豎屏切換效果咕别,并不是只有這一條路徑技健,還可以通過UIView的transform屬性去實(shí)現(xiàn),不過那就是另一個(gè)話題啦 惰拱。
@property(nonatomic) CGAffineTransform transform; // default is CGAffineTransformIdentity. animatable
ヾ( ̄▽ ̄)ByeBye
參考資料
【1】ios 關(guān)于屏幕旋轉(zhuǎn)和屏幕晃動(dòng)
【2】iOS指定頁面屏幕旋轉(zhuǎn)雌贱,手動(dòng)旋轉(zhuǎn)(某app實(shí)現(xiàn)功能全過程)