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

你們的項目都是隻支持豎屏的吧?大多數朋友(這其中固然也包括博主),都沒有作過橫屏開發,此次項目恰好有這個需求,所以把橫豎屏相關的心得寫成一遍文章供諸位參考。html

01.綜述

大多數公司的項目都只支持豎屏,只有一兩個界面須要同時支持橫屏,就像視頻 APP 同樣,只有視頻播放的時候須要橫屏,其餘時候都只容許豎屏。給出的 demo 中處理兩種須要橫屏的情形:git

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

具體使用演示請前往優酷視頻查看:BLLandscape Demogithub

02.錄製視頻橫屏

通常可能只須要播放視頻時橫屏,錄製橫屏通常用不到,可是若是有朋友須要作橫屏視頻錄製,這時候就須要錄製橫屏處理,就像下面這樣的。web

這個思路是這樣的:markdown

  • 橫屏的時候,首先把要橫屏的 view 從原先的 superView 中移除,添加到當前的 keyWindow 上,而後作 frame 動畫,將窗口的高設爲 view 的寬,窗口的寬設置爲 view 的高,而後將 view 的旋轉 90°,執行動畫,就能獲得當前的效果。架構

  • 豎屏的時候,是一個相反的過程,先在窗口上作完動畫,再將 view 插入到橫屏以前的 superView 中的對應位置上。app

2.1.橫屏切換

我把這些實現都抽成一個 UIView 的分類,看一下實現:ide

// 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.豎屏切換

豎屏和橫屏就是一個相反的過程,這裏不貼代碼也不作解釋了。不懂的去看源碼就知道了。oop

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 錯亂。

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 錯亂。

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

相關文章
相關標籤/搜索