[iOS]終極橫豎屏切換解決方案

NewPan發表於2019-03-04

大家的專案都是隻支援豎屏的吧?大多數朋友(這其中當然也包括博主),都沒有做過橫屏開發,這次專案剛好有這個需求,因此把橫豎屏相關的心得寫成一遍文章供諸位參考。

01.綜述

大多數公司的專案都只支援豎屏,只有一兩個介面需要同時支援橫屏,就像視訊 APP 一樣,只有視訊播放的時候需要橫屏,其他時候都只允許豎屏。給出的 demo 中處理兩種需要橫屏的情形:

  • 第一種是錄製視訊時橫屏
  • 第二種是播放視訊時橫屏

具體使用演示請前往優酷視訊檢視:BLLandscape Demo

02.錄製視訊橫屏

一般可能只需要播放視訊時橫屏,錄製橫屏一般用不到,但是如果有朋友需要做橫屏視訊錄製,這時候就需要錄製橫屏處理,就像下面這樣的。

[iOS]終極橫豎屏切換解決方案

這個思路是這樣的:

  • 橫屏的時候,首先把要橫屏的 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,它類似我們常用的 assignassign 策略的特點就是在物件釋放以後,不會主動將應用的物件置為 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 錯亂。

[iOS]終極橫豎屏切換解決方案

03.播放視訊橫屏

大多數場景都是播放視訊的時候橫屏,比如下面這樣的:

[iOS]終極橫豎屏切換解決方案

如果你在網上搜 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 架構可能是這樣的:

[iOS]終極橫豎屏切換解決方案

我們的專案中從視窗開始,依次是一個根控制器,然後再是 UITabbarController 然後再是 UINavigationController,最後才到我們的 UIViewController,我們是某些介面需要橫屏,所以必須要把系統的詢問細化到每個控制器的方法才行。

[iOS]終極橫豎屏切換解決方案

結合上圖,我們看下一個橫豎屏事件的傳遞過程:

  • 先是陀螺儀捕獲到一個橫屏事件
  • 接下來系統會找到當前使用者操作的那個 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 按鈕是關閉的。

[iOS]終極橫豎屏切換解決方案

接下來,我們把這個開關開啟,這個開關對應的程式碼是這樣:

- (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
複製程式碼

這樣以後,我們觀察一下控制器的表現:

[iOS]終極橫豎屏切換解決方案

看起來我們的主介面確實仍然不支援橫豎屏,這是沒有問題的,但是好像我們的狀態列被藍色的這個視窗劫持了,它們倆雙宿雙飛,一起幹了這麼一個橫豎屏的勾當。

我們想象一下,現在這個藍色的視窗在最前面,我們能敏捷的觀察到時這個藍色的視窗劫持了狀態列。那如果這個藍色的視窗在我們的主視窗後面呢,那我們根本就不會察覺到這個細節,我們能看到的就是下面這樣:

[iOS]終極橫豎屏切換解決方案

第一次碰到這個 bug,我的內心是奔潰的。

我們一起來分析一下這個問題是怎麼造成的。再來看一下這個橫豎屏系統詢問路徑圖,當我們有多個視窗之時,每個視窗都是平等的,那個藍色的視窗也收到了系統的詢問。

  • 還記得之前兩個系統詢問的方法是 UIViewController 的方法,此時如果視窗並沒有 rootViewController 的話,那系統問也白問,所以藍色視窗並不會劫持狀態列和橫屏事件。
  • 如果此時藍色視窗有 rootViewController 的話,那麼該控制的返回值就會決定裝置的方向。也就造成了這個 bug。

[iOS]終極橫豎屏切換解決方案

04.補充更新

又發現了一個新的 bug,現在補充一下,如果我們的應用是豎屏的,只是某些介面需要橫屏,那麼如果我們把專案的 info.plist 的橫豎屏全部都開啟的話,就像下面這樣:

[iOS]終極橫豎屏切換解決方案

那麼對於 plus 手機的話,如果你是橫著手機開啟 APP,那麼首個介面肯定是會 UI 錯亂的,因為這個時候 APP 還沒啟動完,系統會根據我們 info.plist 的配置進行初始化,所以會導致這個 bug。

現在解決方式是這樣的, info.plist 我們仍然這樣寫,以保證剛啟動 APP 的時候不至於 UI 錯亂。

image.png

接下來,我們來到 AppDelegate 裡返回支援的橫屏方向,就像下面這麼寫,功能和在 info.plist 也是一樣的。

- (UIInterfaceOrientationMask)application:(UIApplication *)application supportedInterfaceOrientationsForWindow:(UIWindow *)window{
    return self.window.rootViewController.supportedInterfaceOrientations;
}
複製程式碼

05.最後

最後 GitHub 地址在這裡 BLLandscape

NewPan 的文章集合

下面這個連結是我所有文章的一個集合目錄。這些文章凡是涉及實現的,每篇文章中都有 Github 地址,Github 上都有原始碼。

NewPan 的文章集合索引

如果你有問題,除了在文章最後留言,還可以在微博 @盼盼_HKbuy 上給我留言,以及訪問我的 Github

相關文章