大家的專案都是隻支援豎屏的吧?大多數朋友(這其中當然也包括博主),都沒有做過橫屏開發,這次專案剛好有這個需求,因此把橫豎屏相關的心得寫成一遍文章供諸位參考。
01.綜述
大多數公司的專案都只支援豎屏,只有一兩個介面需要同時支援橫屏,就像視訊 APP 一樣,只有視訊播放的時候需要橫屏,其他時候都只允許豎屏。給出的 demo 中處理兩種需要橫屏的情形:
- 第一種是錄製視訊時橫屏
- 第二種是播放視訊時橫屏
具體使用演示請前往優酷視訊檢視:BLLandscape Demo。
02.錄製視訊橫屏
一般可能只需要播放視訊時橫屏,錄製橫屏一般用不到,但是如果有朋友需要做橫屏視訊錄製,這時候就需要錄製橫屏處理,就像下面這樣的。
這個思路是這樣的:
-
橫屏的時候,首先把要橫屏的
view
從原先的superView
中移除,新增到當前的keyWindow
上,然後做frame
動畫,將視窗的高設為view
的寬,視窗的寬設定為view
的高,然後將view
的旋轉 90°,執行動畫,就能得到當前的效果。 -
豎屏的時候,是一個相反的過程,先在視窗上做完動畫,再將
view
插入到橫屏之前的superView
中的對應位置上。
2.1.橫屏切換
我把這些實現都抽成一個 UIView
的分類,看一下實現:
// frame 轉換.
- (void)landscapeExecute{
self.transform = CGAffineTransformMakeRotation(M_PI_2);
CGRect bounds = CGRectMake(0, 0, CGRectGetHeight(self.superview.bounds), CGRectGetWidth(self.superview.bounds));
CGPoint center = CGPointMake(CGRectGetMidX(self.superview.bounds), CGRectGetMidY(self.superview.bounds));
self.bounds = bounds;
self.center = center;
}
- (void)bl_landscapeAnimated:(BOOL)animated animations:(BLScreenEventsAnimations)animations complete:(BLScreenEventsComplete)complete{
if (self.viewStatus != BLLandscapeViewStatusPortrait) {
return;
}
self.viewStatus = BLLandscapeViewStatusAnimating;
self.parentViewBeforeFullScreenSubstitute.anyObject = self.superview;
self.frame_beforeFullScreen = [NSValue valueWithCGRect:self.frame];
NSArray *subviews = self.superview.subviews;
if (subviews.count == 1) {
self.indexBeforeAnimation = 0;
}
else{
for (int i = 0; i < subviews.count; i++) {
id object = subviews[i];
if (object == self) {
self.indexBeforeAnimation = i;
break;
}
}
}
CGRect rectInWindow = [self.superview convertRect:self.frame toView:nil];
[self removeFromSuperview];
self.frame = rectInWindow;
[[UIApplication sharedApplication].keyWindow addSubview:self];
if (animated) {
[UIView animateWithDuration:0.35 animations:^{
[self landscapeExecute];
if (animations) {
animations();
}
} completion:^(BOOL finished) {
[self landscpeFinishedComplete:complete];
}];
}
else{
[self landscapeExecute];
[self landscpeFinishedComplete:complete];
}
self.viewStatus = BLLandscapeViewStatusLandscape;
[self refreshStatusBarOrientation:UIInterfaceOrientationLandscapeRight];
}
複製程式碼
2.2.豎屏切換
豎屏和橫屏就是一個相反的過程,這裡不貼程式碼也不做解釋了。不懂的去看原始碼就知道了。
2.3.注意點
2.3.1.分類中實現 weak
原始碼沒什麼難度,但是有一個細節需要注意,我們要在分類中以 weak
的記憶體管理策略去引用動畫之前的 superView
,以便我們回來做豎屏動畫完成以後將當前 view
新增到動畫之前的 superView
上。但是在分類中新增屬性的記憶體管理策略中沒有 weak
屬性,但是有一個 OBJC_ASSOCIATION_ASSIGN
,它類似我們常用的 assign
,assign
策略的特點就是在物件釋放以後,不會主動將應用的物件置為 nil
,這樣會有訪問殭屍物件導致應用奔潰的風險。
為了解決這個問題,我們可以建立一個替身物件,我們可以在分類中以 OBJC_ASSOCIATION_RETAIN_NONATOMIC
的策略來強引用替身物件,然後在替身物件中以 weak
的策略去引用我們真實需要儲存的物件。這樣就能解決這個可能導致奔潰的問題了。
最近在知乎上有一個朋友提及了另外一種方式,我們可以建立一個 block
,在 block
中引用我們需要使用 weak
記憶體管理的物件,然後我們強引用這個 block
,就像下面這樣:
#import <Foundation/Foundation.h>
@interface NSObject (Weak)
/**
* weak
*/
@property(nonatomic) id weakObject;
@end
#import "NSObject+Weak.h"
#import <objc/runtime.h>
@implementation NSObject (Weak)
- (void)setWeakObject:(id)weakObject {
id __weak __weak_object = weakObject;
id (^__weak_block)() = ^{
return __weak_object;
};
objc_setAssociatedObject(self, @selector(weakObject), __weak_block, OBJC_ASSOCIATION_COPY);
}
- (id)weakObject {
id (^__weak_block)() = objc_getAssociatedObject(self, _cmd);
return __weak_block();
}
@end
複製程式碼
2.3.2.佈局
由於我們做的是 frame
動畫,所以之後在這個 view
上再新增子控制元件的時候必須使用 frame
佈局,Autolayout
佈局在當前的 view
上將不會被更新,導致 UI 錯亂。
03.播放視訊橫屏
大多數場景都是播放視訊的時候橫屏,比如下面這樣的:
如果你在網上搜 iOS 橫豎屏切換
能搜到的也就是播放視訊的時候的橫屏了,而這些文章似乎都是抄的某一篇文章,大家說的都一樣。雖然大家抄來抄去,似乎他們在文章中寫的都能解決問題,但實際上他們的文章是不能解決實際問題的。
3.1.播放視訊橫屏
我們來看一下控制螢幕旋轉的兩個方法:
@interface UIViewController (UIViewControllerRotation)
...
- (BOOL)shouldAutorotate NS_AVAILABLE_IOS(6_0) __TVOS_PROHIBITED;
- (UIInterfaceOrientationMask)supportedInterfaceOrientations NS_AVAILABLE_IOS(6_0) __TVOS_PROHIBITED;
...
@end
複製程式碼
可以看到可以控制螢幕方向的方法是定義在 UIViewController
裡的,第一個 -shouldAutorotate
方法,系統會詢問當前控制器是否支援旋轉,第二個方法 -supportedInterfaceOrientations
告訴系統當前控制器支援那幾個方向的旋轉。
真實專案中,我們的 UI 架構可能是這樣的:
我們的專案中從視窗開始,依次是一個根控制器,然後再是 UITabbarController
然後再是 UINavigationController
,最後才到我們的 UIViewController
,我們是某些介面需要橫屏,所以必須要把系統的詢問細化到每個控制器的方法才行。
結合上圖,我們看下一個橫豎屏事件的傳遞過程:
- 先是陀螺儀捕獲到一個橫屏事件
- 接下來系統會找到當前使用者操作的那個 APP
- APP 會找到當前的視窗 window
- 視窗 window 會找到根控制器,這個時候事件終於傳到我們開發者手裡了
- 對於我們自定義的根控制器,它需要把這個事件傳遞到
UITabbarController
- 對於
UITabbarController
,需要把事件傳遞到UINavigationController
- 對於
UINavigationController
,需要把事件傳遞到我們自己的控制器 - 最後在我們自己的控制器中決定某個介面是否需要橫屏
等等,你我的專案是一個已經可能有上千個控制器的大工程了,如果按照這個邏輯走下去,我們要在每個控制器寫這個兩個方法,不敢想象。
此時我們第一要考慮的就是藉助分類來實現,既簡單又優雅,而且維護起來集中乾淨,何樂而不為?
#import <UIKit/UIKit.h>
@interface UIViewController (Landscape)
/**
* 是否需要橫屏(預設 NO, 即當前 viewController 不支援橫屏).
*/
@property(nonatomic) BOOL bl_shouldAutoLandscape;
@end
#import "UIViewController+Landscape.h"
#import <objc/runtime.h>
#import <JRSwizzle.h>
@implementation UIViewController (Landscape)
+ (void)load{
[self jr_swizzleMethod:@selector(shouldAutorotate) withMethod:@selector(bl_shouldAutorotate) error:nil];
[self jr_swizzleMethod:@selector(supportedInterfaceOrientations) withMethod:@selector(bl_supportedInterfaceOrientations) error:nil];
}
- (BOOL)bl_shouldAutorotate{ // 是否支援旋轉.
if ([self isKindOfClass:NSClassFromString(@"BLAppRootViewController")]) {
return self.childViewControllers.firstObject.shouldAutorotate;
}
if ([self isKindOfClass:NSClassFromString(@"UITabBarController")]) {
return ((UITabBarController *)self).selectedViewController.shouldAutorotate;
}
if ([self isKindOfClass:NSClassFromString(@"UINavigationController")]) {
return ((UINavigationController *)self).viewControllers.lastObject.shouldAutorotate;
}
if ([self checkSelfNeedLandscape]) {
return YES;
}
if (self.bl_shouldAutoLandscape) {
return YES;
}
return NO;
}
- (UIInterfaceOrientationMask)bl_supportedInterfaceOrientations{ // 支援旋轉的方向.
if ([self isKindOfClass:NSClassFromString(@"BLAppRootViewController")]) {
return [self.childViewControllers.firstObject supportedInterfaceOrientations];
}
if ([self isKindOfClass:NSClassFromString(@"UITabBarController")]) {
return [((UITabBarController *)self).selectedViewController supportedInterfaceOrientations];
}
if ([self isKindOfClass:NSClassFromString(@"UINavigationController")]) {
return [((UINavigationController *)self).viewControllers.lastObject supportedInterfaceOrientations];
}
if ([self checkSelfNeedLandscape]) {
return UIInterfaceOrientationMaskAllButUpsideDown;
}
if (self.bl_shouldAutoLandscape) {
return UIInterfaceOrientationMaskAllButUpsideDown;
}
return UIInterfaceOrientationMaskPortrait;
}
- (void)setBl_shouldAutoLandscape:(BOOL)bl_shouldAutoLandscape{
objc_setAssociatedObject(self, @selector(bl_shouldAutoLandscape), @(bl_shouldAutoLandscape), OBJC_ASSOCIATION_RETAIN_NONATOMIC);
}
- (BOOL)bl_shouldAutoLandscape{
return [objc_getAssociatedObject(self, _cmd) boolValue];
}
- (BOOL)checkSelfNeedLandscape{
NSProcessInfo *processInfo = [NSProcessInfo processInfo];
NSOperatingSystemVersion operatingSytemVersion = processInfo.operatingSystemVersion;
if (operatingSytemVersion.majorVersion == 8) {
NSString *className = NSStringFromClass(self.class);
if ([@[@"AVPlayerViewController", @"AVFullScreenViewController", @"AVFullScreenPlaybackControlsViewController"
] containsObject:className]) {
return YES;
}
if ([self isKindOfClass:[UIViewController class]] && [self childViewControllers].count && [self.childViewControllers.firstObject isKindOfClass:NSClassFromString(@"AVPlayerViewController")]) {
return YES;
}
}
else if (operatingSytemVersion.majorVersion == 9){
NSString *className = NSStringFromClass(self.class);
if ([@[@"WebFullScreenVideoRootViewController", @"AVPlayerViewController", @"AVFullScreenViewController"
] containsObject:className]) {
return YES;
}
if ([self isKindOfClass:[UIViewController class]] && [self childViewControllers].count && [self.childViewControllers.firstObject isKindOfClass:NSClassFromString(@"AVPlayerViewController")]) {
return YES;
}
}
else if (operatingSytemVersion.majorVersion == 10){
if ([self isKindOfClass:NSClassFromString(@"AVFullScreenViewController")]) {
return YES;
}
}
return NO;
}
@end
複製程式碼
3.2.注意點
3.2.1. JPWarpViewController
當前 demo 中使用了 JPNavigationController
,因為 JPNavigationController
結構的特殊性,所以這裡加了一個
if ([self isKindOfClass:NSClassFromString(@"JPWarpViewController")]) {
return [self.childViewControllers.firstObject supportedInterfaceOrientations];
}
複製程式碼
如果你專案中有為每個介面定製導航條的需求,你或許可以前往我的 GitHub 檢視。
3.2.2.AVFullScreenViewController
當網頁中有 video 標籤的時候,iPhone 開啟這個網頁的的時候會把 video 標籤替換為對應的系統的播放器,當我們點選這個視訊的時候,系統會全屏進入一個視訊播放介面,通過列印這個控制器我們可以看到這個控制器的類名是 AVFullScreenViewController
,所以,這個介面需要橫屏,就返回橫屏對應的屬性就可以實現這個控制器橫屏。
3.2.3.實現有視訊的網頁需要橫屏
並不是所有的網頁都需要橫屏,但是如果這個網頁有視訊,往往需要橫屏,那我們怎麼知道某個頁面是否需要橫屏,是否有視訊呢?
一種方式是和 h5 約定一個事件,如果有視訊就告訴原生 APP 做一個標記,將 bl_shouldAutoLandscape
置為 YES
。
但是我這裡提供一種更加簡便優雅的方式,我們的 UIWebView
是可以通過 -stringByEvaluatingJavaScriptFromString:
方法和我們互動的,所以我們可以嘗試下面的方法:
#pragma mark - UIWebViewDelegate
- (void)webViewDidFinishLoad:(UIWebView *)webView{
NSString *result = [webView stringByEvaluatingJavaScriptFromString:@"if(document.getElementsByTagName('video').length>0)document.getElementsByTagName('video').length;"];
if (result.length && result.integerValue != 0) {
self.bl_shouldAutoLandscape = YES;
}
}
複製程式碼
當 WebView
載入完以後,我們去查詢當前的 h5 頁面中有沒有 Video
標籤,如果有,那我們就可以拿到結果,做對應的橫屏處理。
3.2.4.大坑來了
本來我們的這個 UITableViewController
是不支援橫豎屏的,就像這樣,注意,這個時候那個 UISwitch
按鈕是關閉的。
接下來,我們把這個開關開啟,這個開關對應的程式碼是這樣:
- (void)switchValueChanged:(UISwitch *)aswitch{
if (aswitch.isOn) {
BLAnotherWindowViewController *vc = [BLAnotherWindowViewController new];
self.anotherWindow.rootViewController = vc;
[self.anotherWindow insertSubview:vc.view atIndex:0];
}
else{
self.anotherWindow.rootViewController = nil;
}
}
複製程式碼
就是開啟後會為另外一個視窗新增一個根控制器,而這個根控制器的程式碼是這樣的:
#import "BLAnotherWindowViewController.h"
@interface BLAnotherWindowViewController ()
@end
@implementation BLAnotherWindowViewController
- (BOOL)shouldAutorotate{
return YES;
}
- (UIInterfaceOrientationMask)supportedInterfaceOrientations{
return UIInterfaceOrientationMaskAll;
}
@end
複製程式碼
這樣以後,我們觀察一下控制器的表現:
看起來我們的主介面確實仍然不支援橫豎屏,這是沒有問題的,但是好像我們的狀態列被藍色的這個視窗劫持了,它們倆雙宿雙飛,一起幹了這麼一個橫豎屏的勾當。
我們想象一下,現在這個藍色的視窗在最前面,我們能敏捷的觀察到時這個藍色的視窗劫持了狀態列。那如果這個藍色的視窗在我們的主視窗後面呢,那我們根本就不會察覺到這個細節,我們能看到的就是下面這樣:
第一次碰到這個 bug,我的內心是奔潰的。
我們一起來分析一下這個問題是怎麼造成的。再來看一下這個橫豎屏系統詢問路徑圖,當我們有多個視窗之時,每個視窗都是平等的,那個藍色的視窗也收到了系統的詢問。
- 還記得之前兩個系統詢問的方法是
UIViewController
的方法,此時如果視窗並沒有rootViewController
的話,那系統問也白問,所以藍色視窗並不會劫持狀態列和橫屏事件。 - 如果此時藍色視窗有
rootViewController
的話,那麼該控制的返回值就會決定裝置的方向。也就造成了這個 bug。
04.補充更新
又發現了一個新的 bug,現在補充一下,如果我們的應用是豎屏的,只是某些介面需要橫屏,那麼如果我們把專案的 info.plist
的橫豎屏全部都開啟的話,就像下面這樣:
那麼對於 plus 手機的話,如果你是橫著手機開啟 APP,那麼首個介面肯定是會 UI 錯亂的,因為這個時候 APP 還沒啟動完,系統會根據我們 info.plist
的配置進行初始化,所以會導致這個 bug。
現在解決方式是這樣的, info.plist
我們仍然這樣寫,以保證剛啟動 APP 的時候不至於 UI 錯亂。
接下來,我們來到 AppDelegate
裡返回支援的橫屏方向,就像下面這麼寫,功能和在 info.plist
也是一樣的。
- (UIInterfaceOrientationMask)application:(UIApplication *)application supportedInterfaceOrientationsForWindow:(UIWindow *)window{
return self.window.rootViewController.supportedInterfaceOrientations;
}
複製程式碼
05.最後
最後 GitHub 地址在這裡 BLLandscape。