【iOS】 橫豎屏 旋轉 解決方案 - Swift

本文基于 Swift 3.x,由于 Swift 4.x 在語法規則上有較大變動,后續出一個 Swift 4.x 版本, Demo 工程在最下面。

前言

我相信iOS的屏幕旋轉問題一直困擾著大多數的APP開發者,遇到界面需要旋轉,特別是界面之間的關聯性很強,幾個視圖控制器又是Push又是Present,然后又交叉Push、Present...說到這里,腦海里就浮現出未找到解決方案時,想拍案而起抓狂的場景。

案例場景

場景案例圖示.png

圖有點大,可以打開一個新標簽放大查看,我們項目APP的一個大概的結構圖,主要指示了一下涉及到旋轉屏的視圖控制器,以及各個控制器之間的關系,是Push出來的還是Present出來的。

簡單描述一下場景:

  1. 主視圖控制器是一個繼承自 UITabBarController 的視圖控制器。
  2. 底部有四個Tab,四個Tab分別指向繼承自 UINavigationController 的視圖控制器作為根視圖。
  3. 通常情況下,都是豎屏,四個Tab的部分界面中都有跳播放器視圖控制器的入口。
  4. 進播放器時,有兩種方式進入,豎屏 or 橫屏。
  5. 第一次是默認豎屏,之后進入時,由用戶最后退出播放器時的閱讀方向來決定。
  6. 播放器中有四個菜單和一個評論輸入框。
  7. 點擊 評論輸入框,彈出一個可輸入評論的視圖控制器,以 present 的形式彈出,會覆蓋在播放器之上,并且能看到后面的播放器內容。方向與當前閱讀器的方向一致。
  8. 點擊 目錄,以 push 的方式打開目錄頁。目錄頁方向與播放器方向一致。(之前的需求是目錄頁要以豎屏的方式出現,當然,這個也可以實現,下面會說解決方案)
  9. 點擊 旋轉 菜單,切換播放器方向,豎屏 -> 橫屏,or 橫屏 -> 豎屏
  10. 用戶在輸入評論之后,點擊右邊或者鍵盤的的 發送 按鈕,會先判斷當前用戶的登錄狀態,如果未登錄或者登錄信息失效,會 present 一個 豎屏登錄界面。
  11. 登錄界面 同樣包裝在一個 UINavigationController 之中,用戶未注冊時還可以 push 到一個 注冊 界面,同樣也是豎屏,第三方登錄方式有 微信,QQ微博 等。
  12. 播放器可以被外部APP調起,諸如 Safari瀏覽器 或者 QQ瀏覽器。(為什么要說到這一點,是因為當用在在這些外部APP中調起播放器時,用戶手持手機的方向會直接影響到調起之后,播放器的方向,處理不好的話就會錯亂,比如之前播放器時橫屏,從外部APP調起時,手機又是豎屏。)

了解一點基礎知識

在講解我的處理方案之前,我想先跟大家介紹一下Apple的官方文檔關于旋轉屏時的處理機制。
在Apple Documentation 中 關于 UIViewController 的介紹中,簡要提到過旋轉屏時,UIKit會干一些什么事以及你該怎么處理。我提取其中的部分簡單翻譯了一下。如下:

Handling View Rotations

As of iOS 8, all rotation-related methods are deprecated. Instead, rotations are treated as a change in the size of the view controller’s view and are therefore reported using the viewWillTransition(to:with:) method. When the interface orientation changes, UIKit calls this method on the window’s root view controller. That view controller then notifies its child view controllers, propagating the message throughout the view controller hierarchy.

從iOS8開始,所有旋轉相關的方法都被廢棄。旋轉被視為是視圖控制器的view的大小的改變并在viewWillTransition(to:with:) 方法中反饋給視圖控制器。當界面方向發生改變,UIKit會在窗口的根視圖控制器中調用此方法,然后根視圖控制器再通知它所管理的其他子視圖控制器。此消息將在整個視圖控制器棧中傳播貫穿。

In iOS 6 and iOS 7, your app supports the interface orientations defined in your app’s Info.plist file.

在iOS6和iOS7中,你的程序所支持的界面方向由程序的info.plist文件中定義的參數決定。

A view controller can override the supportedInterfaceOrientationsmethod to limit the list of supported orientations.Typically, the system calls this method only on the root view controller of the window or a view controller presented to fill the entire screen;

一個視圖可以通過重寫 supportedInterfaceOrientations 來控制支持的方向。通常情況下,系統只在window的rootViewController和一個充滿全屏的模態(presented view controller)視圖中調用此方法。

child view controllers use the portion of the window provided for them by their parent view controller and no longer participate directly in decisions about what rotations are supported.

子視圖不直接參與旋轉方向的決策,直接由它們的父視圖決定。

The intersection of the app's orientation mask and the view controller's orientation mask is used to determine which orientations a view controller can be rotated into.

程序支持的方向和視圖控制器支持的方向的交集被用來決定視圖控制器應該旋轉到哪個方向。

You can override the preferredInterfaceOrientationForPresentation for a view controller that is intended to be presented full screen in a specific orientation.

你可以為一個準備present成一個全屏的模態視圖控制器通過重寫 preferredInterfaceOrientationForPresentation 來指定特定的方向。

When a rotation occurs for a visible view controller, the willRotate(to:duration:), willAnimateRotation(to:duration:), and didRotate(from:) methods are called during the rotation. The viewWillLayoutSubviews() method is also called after the view is resized and positioned by its parent. If a view controller is not visible when an orientation change occurs, then the rotation methods are never called. However, the viewWillLayoutSubviews() method is called when the view becomes visible. Your implementation of this method can call the statusBarOrientation method to determine the device orientation.

對于一個可見的視圖控制器,當旋轉發生時,這些方法willRotate(to:duration:), willAnimateRotation(to:duration:), 和 didRotate(from:) 會在旋轉過程中被調用,當視圖控制器的view被重新拉伸并被父視圖定位完成時,viewWillLayoutSubviews() 將被調用。如果一個視圖控制器在旋轉過程中處于不可見狀態,那么上面提到的三個方法不會被調用。然而,在視圖重新可見時,viewWillLayoutSubviews() 會被調用。你可以重寫此方法并在該方法中調用 statusBarOrientation 方法來決定設備的方向。

Note

At launch time, apps should always set up their interface in a portrait orientation. After the application(_:didFinishLaunchingWithOptions:) method returns, the app uses the view controller rotation mechanism described above to rotate the views to the appropriate orientation prior to showing the window.

注意

在程序應該在啟動時保持豎屏,等到application(_:didFinishLaunchingWithOptions:) 方法返回之后,程序再使用上面提到過的旋轉機制來合理的處理窗口視圖的旋轉。

額外說一下 statusBarOrientation 這個屬性:

The value of this property is a constant that indicates an orientation of the receiver's status bar. See UIInterfaceOrientation for details. Setting this property rotates the status bar to the specified orientation without animating the transition. If your app has rotatable window content, however, you should not arbitrarily set status-bar orientation using this method. The status-bar orientation set by this method does not change if the device changes orientation. For more on rotatable window views, see View Controller Programming Guide for iOS.

  1. 通過 UIApplication.shared.statusBarOrientation 獲取和設置,還有另外一個方法來設置這個屬性的值,可以傳遞動畫與否的參數,UIApplication.shared.setStatusBarOrientation(:, animated: ),直接設置這個屬性值,相當于調用了該方法時傳入了 animated: false,即不使用任何動畫形式來改變狀態欄的方向。
  2. 如果你的程序中的某個視圖控制器的界面是可旋轉的,那么你不應該隨意的去設置這個屬性,意圖改變狀態欄的方向,因為這將可能無效。(我就曾遇到過,邏輯都是從另外一個項目中照搬過來的,但是調用此方法時,死活不改變方向。當然,這跟你是否正確的返回 shouldAutorotate有關系,下面會講到。)
  3. 作為總結,如果你的當前視圖控制器的 shouldAutorotate返回 true,則盡量不要再去調用 UIApplication.shared.statusBarOrientation 了, 一是可能無效,二是 statusBarOrientation的方向會隨著你返回的supportedInterfaceOrientation 改變而自動改變。

正題

按照官方的說法,我打算一步一步的告訴大家,如何配置,如何編寫代碼,從最根部,到最外層。

  1. 首先,配置程序的info.plist配置文件,只勾選豎屏,這樣可以保證豎屏啟動界面 (即 LaunchScreen.storyboard 配置的程序默認啟動界面在任何情況下都豎屏啟動)。

    程序Info.plist的配置

  2. AppDelegate 中的配置:

     @UIApplicationMain
     class AppDelegate: UIResponder, UIApplicationDelegate {
         ...
         func application(_ application: UIApplication, supportedInterfaceOrientationsFor window: UIWindow?) -> UIInterfaceOrientationMask {
             return .allButUpsideDown
         }
         ...
     }
    
    • 當然,如果你的程序支持 iPad ,可以返回 .all 來支持所有的方向。
    • 一般情況下,返回 .allButUpsideDown 就夠了。
    • 前面講到過,UIKit 會取視圖控制器返回的值和當前返回的值,做一個交叉,取交叉值,所有這里返回最大范圍的支持方向。
  3. 自定義五個基類,分別是:

    • BaseTabBarController,繼承自 UITabBarControlelr
    • BaseNavViewController,繼承自 UINavigationController
    • BaseViewController,繼承自 UIViewController
    • BaseTableViewController,繼承自 UITableViewController
    • BaseCollectionViewController,繼承自 UICollectionViewController

    這五個基類基本上覆蓋了程序的大部分需要的視圖控制器,如果您的程序中還有其他類型的視圖控制器,照著下面我所描述的原理,配置一下即可。

    • 先寫上一個 swift 文件,為程序配置幾個默認配置的屬性,供全局使用,并配置一些相關拓展,下面會用到。

        // 基礎視圖控制器的默認配置,涵蓋了跟旋轉屏、present時屏幕方向和狀態欄樣式有關系的常用配置
        let kDefaultPreferredStatusBarStyle: UIStatusBarStyle = .default // 狀態欄樣式,默認使用系統的
        let kDefaultPrefersStatusBarHidden: Bool = false // 狀態欄是否隱藏,默認不隱藏
        let kDefaultShouldAutorotate: Bool = true // 是否支持屏幕旋轉,默認支持
        let kDefaultSupportedInterfaceOrientations: UIInterfaceOrientationMask = .portrait // 支持的旋轉方向,默認豎屏
        let kDefaultPreferredInterfaceOrientationForPresentation: UIInterfaceOrientation = .portrait // present時,打開視圖控制器的方向,默認豎屏
      
        extension UIInterfaceOrientation {
            var orientationMask: UIInterfaceOrientationMask {
               switch self {
               case .portrait: return .portrait
               case .portraitUpsideDown: return .portraitUpsideDown
               case .landscapeLeft: return .landscapeLeft
               case .landscapeRight: return .landscapeRight
               default: return .all
               }
           }
        }
        
        extension UIInterfaceOrientationMask {
            
            var isLandscape: Bool {
                switch self {
                case .landscapeLeft, .landscapeRight, .landscape: return true
                default: return false
                }
            }
        
            var isPortrait: Bool {
                 switch self {
                case . portrait, . portraitUpsideDown: return true
                default: return false
                }
            }
        
        }
      
  4. 再來添加另外一個 swift 文件,起名 UIViewController+Extension.swift, 為 UIViewController 添加一些通用配置。

     extension UIViewController {
     
         // 是否禁用導航欄的左滑手勢,默認不禁用
         var isForbidInteractivePopGesture: Bool {
             return false
         }
         
     }
    

額呵,只有這么一個簡單的配置,為的是在播放器處于橫屏時,禁用導航控制器的左滑返回手勢,豎屏時正常可用。

為什么要禁用?。?!

因為上一個界面是豎屏!!而播放器也是被 Push 進來的。so!要么禁用,要么一觸發滑動,界面就立刻關閉了,體驗不好。

  1. 配置 BaseTabBarController:

     class BaseTabBarController: UITabBarController {
         override var prefersStatusBarHidden: Bool {
             return selectedViewController?.prefersStatusBarHidden ?? kDefaultPrefersStatusBarHidden
         }
     
         override var preferredStatusBarStyle: UIStatusBarStyle {
             return selectedViewController?.preferredStatusBarStyle ?? kDefaultPreferredStatusBarStyle
         }
     
         override var shouldAutorotate: Bool {
             return selectedViewController?.shouldAutorotate ?? kDefaultShouldAutorotate
         }
     
         override var supportedInterfaceOrientations: UIInterfaceOrientationMask {
             return [selectedViewController?.supportedInterfaceOrientations ?? kDefaultSupportedInterfaceOrientations, preferredInterfaceOrientationForPresentation.orientationMask]
         }
     
         override var preferredInterfaceOrientationForPresentation: UIInterfaceOrientation {
             return selectedViewController?.preferredInterfaceOrientationForPresentation ?? kDefaultPreferredInterfaceOrientationForPresentation
         }
     }
    

BaseTabBarController 作為根視圖,需要把參數傳遞給它的子視圖。

注意:上面的代碼,重寫 supportedInterfaceOrientations 時,也取了 preferredInterfaceOrientationForPresentation 的值并做了一個轉換,之所以這么處理,是因為很多情況下,我們會無意間返回與 supportedInterfaceOrientations 不一致的方向,導致這種錯誤:

UIApplicationInvalidInterfaceOrientation: preferredInterfaceOrientationForPresentation 'landscapeRight' must match a supported interface orientation: 'portrait'!

可以看出,系統要求我們返回的 supportedInterfaceOrientationspreferredInterfaceOrientationForPresentation 至少要有可交叉的值,UIInterfaceOrientation 只能定義一個值,UIInterfaceOrientationMask 支持 OptionSet 協議 可返回一個數組,因此可以是多個值,所以可做如上處理,避免你沒有重寫 preferredInterfaceOrientationForPresentation 由系統返回的默認值 或者 你重寫了,但是由于代碼邏輯錯誤,返回了一個與 supportedInterfaceOrientations 方向不一致的值。

  1. 配置 BaseNavViewController:

     class BaseNavViewController: UINavigationController {
     
         override func viewDidLoad() {
             super.viewDidLoad()
             interactivePopGestureRecognizer?.delegate = self // 切記不要放在構造方法中配置,因為那時的 interactivePopGestureRecognizer 可能是 nil
         }
    
         override var shouldAutorotate: Bool {
             if let presentedController = topViewController?.presentedViewController, presentedController.isBeingPresented {
                 return presentedViewController?.shouldAutorotate ?? kDefaultShouldAutorotate
             }
     
             if let presentedController = topViewController?.presentedViewController, presentedController.isBeingDismissed {
                 return topViewController?.shouldAutorotate ?? kDefaultShouldAutorotate
             }
     
             return visibleViewController?.shouldAutorotate ?? kDefaultShouldAutorotate
         }
     
         override var supportedInterfaceOrientations: UIInterfaceOrientationMask {
             if let presentedController = topViewController?.presentedViewController, presentedController.isBeingPresented {
                 return presentedViewController?.supportedInterfaceOrientations ?? kDefaultSupportedInterfaceOrientations
             }
     
             if let presentedController = topViewController?.presentedViewController, presentedController.isBeingDismissed {
                 return topViewController?.supportedInterfaceOrientations ?? kDefaultSupportedInterfaceOrientations
             }
     
             return visibleViewController?.supportedInterfaceOrientations ?? kDefaultSupportedInterfaceOrientations
         }
     
         override var preferredInterfaceOrientationForPresentation: UIInterfaceOrientation {
             if let presentedController = topViewController?.presentedViewController, presentedController.isBeingPresented {
                 return presentedViewController?.preferredInterfaceOrientationForPresentation ?? kDefaultPreferredInterfaceOrientationForPresentation
             }
     
             if let presentedController = topViewController?.presentedViewController, presentedController.isBeingDismissed {
                 return topViewController?.preferredInterfaceOrientationForPresentation ?? kDefaultPreferredInterfaceOrientationForPresentation
             }
     
             return visibleViewController?.preferredInterfaceOrientationForPresentation ?? kDefaultPreferredInterfaceOrientationForPresentation
         }
     
         override var prefersStatusBarHidden: Bool {
             if let presentedController = topViewController?.presentedViewController, presentedController.isBeingPresented {
                 return presentedViewController?.prefersStatusBarHidden ?? kDefaultPrefersStatusBarHidden
             }
     
             if let presentedController = topViewController?.presentedViewController, presentedController.isBeingDismissed {
                 return topViewController?.prefersStatusBarHidden ?? kDefaultPrefersStatusBarHidden
             }
     
             return visibleViewController?.prefersStatusBarHidden ?? kDefaultPrefersStatusBarHidden
         }
     
         override var preferredStatusBarStyle: UIStatusBarStyle {
             if let presentedController = topViewController?.presentedViewController, presentedController.isBeingPresented {
                 return presentedViewController?.preferredStatusBarStyle ?? kDefaultPreferredStatusBarStyle
             }
     
             if let presentedController = topViewController?.presentedViewController, presentedController.isBeingDismissed {
                 return topViewController?.preferredStatusBarStyle ?? kDefaultPreferredStatusBarStyle
             }
     
             return visibleViewController?.preferredStatusBarStyle ?? kDefaultPreferredStatusBarStyle
         }
     
     }
     
     extension BaseNavViewController: UIGestureRecognizerDelegate {
         
         func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
            if let controller = topViewController, controller.isForbidInteractivePopGesture {
                return false // 播放器處于橫屏時,禁用左滑手勢
            }
            return viewControllers.count > 1
         }
         
     }
    

    這里這么多代碼,其實都是一個處理邏輯,原則如下:

    如果你不了解導航控制器的 topViewController 、visibleViewController 、視圖控制器的 presentedViewControllerpresentingViewController 是什么概念,那么建議百度 or Google 一下再看下面的內容,這里就不做普及了,以免篇幅過長。

    1. 判斷導航控制器棧頂的視圖控制器 topViewController 是否有 presentedViewController,如果有,并且正在被 present 當中,則優先使用該 presentedViewController 的配置參數。
    2. 判斷導航控制器棧頂的視圖控制器 topViewController 是否有 presentedViewController,如果有,并且正在被 dismiss 當中,則優先使用該 topViewController 的配置參數。
    3. 剩下的是默認配置,不再判斷有沒有 presentedViewController ,也不再判斷 presentedViewController 的狀態,由系統決定。是使用 presentedViewController 還是使用 topViewController。
    4. 左滑返回手勢是否開啟由兩個原則,一是如果視圖控制器返回的 isForbidInteractivePopGesturetrue 時禁用,二是 默認判斷 視圖控制器的堆棧中視圖控制器的數量,大于 1 時可用。
  2. 兩大容器類型的視圖控制器重寫完了,接下來我們來寫其他三個。

  3. 配置 BaseViewController:

     class BaseViewController: UIViewController {
         
         // MARK: - 關于旋轉的一些配置和說明
    
         // _xxx_ 系列方法,由子類自定義實現,未實現時,使用下面的默認參數
         var _preferredStatusBarStyle_: UIStatusBarStyle? { return nil }
         var _prefersStatusBarHidden_: Bool? { return nil }
         var _shouldAutorotate_: Bool? { return nil }
         var _supportedInterfaceOrientations_: UIInterfaceOrientationMask? { return nil }
         var _preferredInterfaceOrientationForPresentation_: UIInterfaceOrientation? { return nil }
         
         override var preferredStatusBarStyle: UIStatusBarStyle {
             if let presentedController = presentedViewController, presentedController.isBeingPresented {
                 return presentedController.preferredStatusBarStyle
             }
             if let presentedController = presentedViewController, presentedController.isBeingDismissed {
                 return _preferredStatusBarStyle_ ?? kDefaultPreferredStatusBarStyle
             }
             if let presentedController = presentedViewController {
                 return presentedController.preferredStatusBarStyle
             }
             return _preferredStatusBarStyle_ ?? kDefaultPreferredStatusBarStyle
         }
             
         override var prefersStatusBarHidden: Bool {
             if let presentedController = presentedViewController, presentedController.isBeingPresented {
                 return presentedController.prefersStatusBarHidden
             }
             if let presentedController = presentedViewController, presentedController.isBeingDismissed {
                 return _prefersStatusBarHidden_ ?? kDefaultPrefersStatusBarHidden
             }
             if let presentedController = presentedViewController {
                 return presentedController.prefersStatusBarHidden
             }
             return _prefersStatusBarHidden_ ?? kDefaultPrefersStatusBarHidden
         }
             
         override var shouldAutorotate: Bool {
             if let presentedController = presentedViewController, presentedController.isBeingPresented {
                 return presentedController.shouldAutorotate
             }
             if let presentedController = presentedViewController, presentedController.isBeingDismissed {
                 return _shouldAutorotate_ ?? kDefaultShouldAutorotate
             }
             if let presentedController = presentedViewController {
                 return presentedController.shouldAutorotate
             }
             return _shouldAutorotate_ ?? kDefaultShouldAutorotate
         }
             
         override var supportedInterfaceOrientations: UIInterfaceOrientationMask {
             if let presentedController = presentedViewController, presentedController.isBeingPresented {
                 return presentedController.supportedInterfaceOrientations
             }
             if let presentedController = presentedViewController, presentedController.isBeingDismissed {
                 return _supportedInterfaceOrientations_ ?? kDefaultSupportedInterfaceOrientations
             }
             if let presentedController = presentedViewController {
                 return presentedController.supportedInterfaceOrientations
             }
             return _supportedInterfaceOrientations_ ?? kDefaultSupportedInterfaceOrientations
         }
             
         override var preferredInterfaceOrientationForPresentation: UIInterfaceOrientation {
             if let presentedController = presentedViewController, presentedController.isBeingPresented {
                 return presentedController.preferredInterfaceOrientationForPresentation
             }
             if let presentedController = presentedViewController, presentedController.isBeingDismissed {
                 return _preferredInterfaceOrientationForPresentation_ ?? kDefaultPreferredInterfaceOrientationForPresentation
             }
             if let presentedController = presentedViewController {
                 return presentedController.preferredInterfaceOrientationForPresentation
             }
             return _preferredInterfaceOrientationForPresentation_ ?? kDefaultPreferredInterfaceOrientationForPresentation
         }
     }
    

    又是一堆代碼... 真的不想貼這么多,但是有些人就知道復制黏貼...怕大家漏寫又來一通問,一通罵,怎么不行呀!片紙!!!!片紙!!!! ...,下面還是說一下處理邏輯:

    1. 如果存在 presentedViewController ,并且正在被 present,則優先使用 presentedViewController 的配置參數。
    2. 如果存在 presentedViewController ,并且正在被 dismiss,則優先使用當前控制器的參數配置,如果子類沒有重寫對應的系列 _xxx_ 方法,則使用默認參數。
    3. 如果存在 presentedViewController (說明它當前正在被顯示),則優先使用 presentedViewController 的配置參數。
    4. 最后,使用子類自定義(如果子類有重寫對應的系列 _xxx_ 方法)或默認配置。
  4. 配置 BaseTableViewController:

     class BaseTableViewController: UITableViewControlelr {
         
         // 和 BaseViewController 中一模一樣的代碼,直接黏貼過來即可。
     
     }
    
  5. 配置 BaseCollectionViewController:

     class BaseTableViewController: UITableViewControlelr {
         
         // 和 BaseViewController 中一模一樣的代碼,直接黏貼過來即可。
     
     }
    
  6. 五大基礎類重寫完畢,在介紹具體的使用場景之前,需要再寫一個類,拿來控制旋轉方向的,其實就是調用 UIDevice.current.setValue(UIInterfaceOrientation.xxx.rawValue: forKey:"orientation") 來設置方向的,因為這個方法涉及到了運行時kvc等黑魔法概念,所以我做了一個包裝,其實最終的結果還是 kvc,只是不那么明顯而已,有點自娛自樂的 style ??,關于 私有API,孫源 大大這他的 這篇文章 中,說過他的理解,感興趣的朋友可以去看看。下面直接貼代碼:

     // MARK: - 專門負責旋轉屏的工具類
     class UIRotateUtils {
     
         static let shared = UIRotateUtils()
             
         private var appOrientation: UIDevice {
             return UIDevice.current
         }
         
         /// 方向枚舉
         enum Orientation {
             
             case portrait
             case portraitUpsideDown
             case landscapeRight
             case landscapeLeft
             case unknown
             
             var mapRawValue: Int {
                 switch self {
                 case .portrait: return UIInterfaceOrientation.portrait.rawValue
                 case .portraitUpsideDown: return UIInterfaceOrientation.portraitUpsideDown.rawValue
                 case .landscapeRight: return UIInterfaceOrientation.landscapeRight.rawValue
                 case .landscapeLeft: return UIInterfaceOrientation.landscapeLeft.rawValue
                 case .unknown: return UIInterfaceOrientation.unknown.rawValue
                 }
             }
             
         }
             
         private let unicodes: [UInt8] =
             [
                 111,// o -> 0
                 105,// i -> 1
                 101,// e -> 2
                 116,// t -> 3
                 114,// r -> 4
                 110,// n -> 5
                 97  // a -> 6
             ]
             
         private lazy var key: String = {
             return [
                 self.unicodes[0],// o
                 self.unicodes[4],// r
                 self.unicodes[1],// i
                 self.unicodes[2],// e
                 self.unicodes[5],// n
                 self.unicodes[3],// t
                 self.unicodes[6],// a
                 self.unicodes[3],// t
                 self.unicodes[1],// i
                 self.unicodes[0],// o
                 self.unicodes[5] // n
                 ].map {
                     return String(Character(Unicode.Scalar ($0)))
                 }.joined(separator: "")
         }()
         
         /// 旋轉到豎屏
         ///
         /// - Parameter orientation: 方向枚舉
         func rotateToPortrait(_ orientation: Orientation = .portrait) {
             rotate(to: orientation)
         }
         
         /// 旋轉到橫屏
         ///
         /// - Parameter orientation: 方向枚舉
         func rotateToLandscape(_ orientation: Orientation = .landscapeRight) {
             rotate(to: orientation)
         }
             
         /// 旋轉到指定方向
         ///
         /// - Parameter orientation: 方向枚舉
         func rotate(to orientation: Orientation) {
             appOrientation.setValue(Orientation.unknown.mapRawValue, forKey: key) // ?? 需要先設置成 unknown 喲
             appOrientation.setValue(orientation.mapRawValue, forKey: key)
         }   
     }
    

    有一點需要注意的是,設置實際所需方向之前,需要先設置一次方向為 unknown, 因為可能會出現意外情況,導致你設置指定方向時,當前的設備方向已經就是這個方向了,UIKit就不會觸發相關事件,并不會重繪界面,進而導致調用無效的情況。

  7. 播放器視圖控制器 PlayerViewController:

    class PlayerViewController: BaseViewController {
    
        // 此參數由外部傳入,并且在要在構造控制器時傳入
        fileprivate var _isLandscape = false
        
        init(isLandscape: Bool = false) {
            ...
            _isLandscape = isLandscape
            ...
        }
        
        override func viewDidLoad() {
            super.viewDidLoad()
            updateOrientationIfNeeded(true)// 剛啟動時,強制執行
        }
        
        override func viewWillAppear(_ animated: Bool) {
            super.viewWillAppear(animated)
            updateOrientationIfNeeded()// 后續的界面間跳轉,不強制執行
        }
        
        // MARK: - 自定義配置
        override var _prefersStatusBarHidden_: Bool? {
            return true
        }
        
        override var _supportedInterfaceOrientations_: UIInterfaceOrientationMask? {
            return _isLandscape ? .landscapeRight: .portrait
        }
        
        override var _preferredInterfaceOrientationForPresentation_: UIInterfaceOrientation? {
            return _isLandscape ? .landscapeRight: .portrait
        }
    
        override var isForbidInteractivePopGesture: Bool {
            return _isLandscape
        }
        
        // MARK: - 控制旋轉
        fileprivate func updateOrientationIfNeeded(_ force: Bool = false) {
            if _isLandscape {
                toLandscapeOrientation(force)
            } else {
                toPortraitOrientation(force)
            }
        }
            
        fileprivate func toLandscapeOrientation(_ force: Bool = false) {
            guard force || !_isLandscape else {
                return
            }
            UIRotateUtils.shared.rotateToLandscape()
        }
            
        fileprivate func toPortraitOrientation(_ force: Bool = false) {
            guard force || _isLandscape else {
                return
            }
            UIRotateUtils.shared.rotateToPortrait()
        }
        
        // 點擊菜單的 “旋轉” 按鈕
        @objc fileprivate func onChangeOrientationBtnTapped(_ any: Any?) {
            ...
            ...
            
            // 核心控制
            _isLandscape = !_isLandscape
            if _isLandscape {
                toLandscapeOrientation(true)
            } else {
                toPortraitOrientation(true)
            }
            
            ...
            ...
        }
    
    }
    

    播放器大概的配置就這些,也很簡單,主要的注意點在于:

    1. 控制好變量 _isLandscape 的傳入時機,一定要在視圖控制器進入之前傳入,建議是構造視圖控制器時就傳入。
    2. viewDidLoadviewWillAppear 都執行 updateOrientationIfNeeded 方法。
    3. 通過 _isLandscape 控制 _supportedInterfaceOrientations__preferredInterfaceOrientationForPresentation_ 的返回值。
  8. 評論輸入框界面 WriteCommentViewController:

    場景案例 中提到過,一般這種界面像是懸浮在上一個界面之上,存在半透明的界面部分,可以看到上一界面的視圖,而且,在不重寫轉場動畫的情況下,一般使用 present 的形式,以模態視圖的形式呈現。更多關于 轉場動畫 的相關知識,請看 唐巧 大大的 這篇文章 ,你一定會收益匪淺。

    class WriteCommentViewController: BaseViewController {
    
        // 此參數由外部傳入,并且在要在構造控制器時傳入
        fileprivate var _isLandscape = false
        
        init(isLandscape: Bool = false) {
            ...
            _isLandscape = isLandscape
           modalPresentationStyle = .overFullScreen
           modalTransitionStyle = .crossDissolve
            ...
        }
        
        override var _supportedInterfaceOrientations_: UIInterfaceOrientationMask? {
            return _isLandscape ? .landscapeRight : .portrait
        }
            
        override var _preferredInterfaceOrientationForPresentation_: UIInterfaceOrientation? {
            return _isLandscape ? .landscapeRight : .portrait
        }
            
        override var _prefersStatusBarHidden_: Bool? {
            return true
        }
    
    }
    

    基礎配置和 PlayerViewController 差不多,需要注意的一點是:

    1. 因為界面是 present 出來的,并且不自定義轉場動畫時,需要配置 modalPresentationStylemodalTransitionStyle,轉場樣式可以自己指定,modalPresentationStyle 目前我沒有使用 .custom 模式,使用 overFullScreen 問題相對少一點。
    2. 如果你的界面中也存在需要半透明或者透明度的部分,則需要把視圖控制器的 viewbackgroundColor 設置成透明,然后自己加一層黑色背景的控件,用一個 alpha 動畫漸變到小于1.0的某個值。
  9. 目錄 CategoryViewController:

    class CategoryViewController: BaseViewController {
    
        // 此參數由外部傳入,并且在要在構造控制器時傳入
        fileprivate var _isLandscape = false
        
        init(isLandscape: Bool = false) {
            ...
            _isLandscape = isLandscape
            ...
        }
        
        override var _supportedInterfaceOrientations_: UIInterfaceOrientationMask? {
            return _isLandscape ? .landscapeRight : .portrait
        }
            
        override var _preferredInterfaceOrientationForPresentation_: UIInterfaceOrientation? {
            return _isLandscape ? .landscapeRight : .portrait
        }
    
    }
    

    基本和上面的兩個類的配置一致。

  10. 登錄 UserLoginViewController:

    場景案例 中描述過,登錄 界面是被 present 出來的,并且還能 push注冊 界面,因此 登錄 界面是被包裹在 導航控制器 中的。

    class UserLoginViewController: BaseTableViewController {
    
        // 標識登錄界面被 present 打開時,上一個界面(播放器)是不是處于橫屏狀態
        fileprivate var _isPreViewControllerAtLandscapeMode = false
        
        filepriate var _loginActionResultBlock: ((Bool) -> Void)? = nil
    
        // 外部調用方式:
        // presentingViewController.present(UserLoginViewController.viewController(_isLandscape, animated: true)
        // 
        class func viewController(_ isPreViewControllerAtLandscapeMode: Bool = false, loginActionResultBlock: ((Bool) -> Void)? = nil, ...) -> BaseNavViewController {
            // 構建登錄視圖控制器的方式,自定,一般都是通過StoryBoard來布局。
            let loginController = UIStoryboard(name: "Login", bundle: nil).instantiateViewController(withIdentifier: "Login_VC") as! UserLoginViewController
            loginController._isPreViewControllerAtLandscapeMode = isPreViewControllerAtLandscapeMode
            loginController._loginActionResultBlock = loginActionResultBlock
            ...
            ...
            // 包裝到BaseNavViewController中去
            let nav = BaseNavViewController(rootViewController: loginController)
            nav.modalPresentationStyle = .fullScreen
            nav.modalTransitionStyle = .coverVertical
            return nav
        }
        
        ...
        ...
        
        override var _supportedInterfaceOrientations_: UIInterfaceOrientationMask? {
            return .portrait // 豎屏
        }
        
        override var _preferredInterfaceOrientationForPresentation_: UIInterfaceOrientation? {
            return .portrait // 豎屏
        }
        
        override var _preferredStatusBarStyle_: UIStatusBarStyle? {
            return .lightContent // 返回你自己需要的狀態欄樣式
        }
        
        // 關閉登錄界面(不管在登錄界面中是否調到了別的界面,注意,一定是返回到登錄界面之后,再統一關閉,因為這里需要額外處理一下)
        fileprivate func closeController(_ isLoginSuccess: Bool) {
            // 關閉界面之前,處理一下旋轉問題
            if _isPreViewControllerAtLandscapeMode {
                UIRotateUtils.shared.rotateToLandscape()
            }
            dismiss(animated: true) { [weak self] _ in
                self?._loginActionResultBlock?(isLoginSuccess)
            }
        }
        
        ...
        ...
    
    }
    

    基本配置就這些,至于 注冊 界面想支持什么類型的方向,可以隨意定制。因為五個基礎類已經做了大部分的工作,如果想支持特定方向,就需要自己重寫幾個 _xxx_ 系列方法來自定義了,默認只支持豎屏。

    需要注意的是包裝 登錄 界面的導航控制器的 modalPresentationStylemodalTransitionStyle 的配置。modalPresentationStyle 一定設置成 .fullScreen, 不過這個是系統默認設置,這里只是保險起見。modalTransitionStyle 一般情況下,登錄 界面都是以 .coverVertical 的形式出現的。


最后

最后的最后,做一個簡單的總結。

  1. 五個跟旋轉屏,狀態欄樣式有關系的屬性,從根視圖控制器一路傳到最頂級視圖。分別是:
    • prefersStatusBarHidden
    • preferredStatusBarStyle
    • shouldAutorotate
    • supportedInterfaceOrientations
    • preferredInterfaceOrientationForPresentation
  2. 確保返回的 supportedInterfaceOrientations 的相關值總類型 包含于 preferredInterfaceOrientationForPresentation 返回的對應類型值。
  3. 處理好 UINavigationController 中的上述五個屬性,理清 topViewController visibleViewController 以及 被 present出來的模態視圖控制器的 isBeingPresentedisBeingDismissed 屬性的含義。
  4. 處理好 基礎視圖控制器 中的 presentedViewController 及 理清其對應的 isBeingPresentedisBeingDismissed 屬性的含義。
  5. 【一個很重要的點忘記提及了】鍵盤彈出的布局方向和視圖控制器返回的supportedInterfaceOrientation是一致的,與你的狀態欄方向無關。

Happy 2018. Happy New Year!

有問題請在簡書中發送私信或者關注我的個人 微博,給我留言。謝謝關注,如果您有更多的想法,請聯系互相交流。


Demo 在此,歡迎star!!!

TODO_List:

  1. 第三方APP調起時的相關配置稍后補上。
最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 228,197評論 6 531
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,415評論 3 415
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事?!?“怎么了?”我有些...
    開封第一講書人閱讀 176,104評論 0 373
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,884評論 1 309
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,647評論 6 408
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,130評論 1 323
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,208評論 3 441
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,366評論 0 288
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,887評論 1 334
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,737評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,939評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,478評論 5 358
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,174評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,586評論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,827評論 1 283
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,608評論 3 390
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,914評論 2 372

推薦閱讀更多精彩內容

  • 第一步 首先保證工程支持橫豎屏 不多說看圖 保證圈紅的地方 打對勾 58F678EC-EABC-4320-9FCB...
    ylgwhyh閱讀 1,825評論 0 1
  • 概述 摘要:從制作一個看圖app和了解關鍵概念開始swift編程。 概念:Constants and variab...
    lbhw閱讀 479評論 0 1
  • SwiftDay011.MySwiftimport UIKitprintln("Hello Swift!")var...
    smile麗語閱讀 3,850評論 0 6
  • Spring Cloud為開發人員提供了快速構建分布式系統中一些常見模式的工具(例如配置管理,服務發現,斷路器,智...
    卡卡羅2017閱讀 134,781評論 18 139
  • 天地未分之時,被稱為混沌狀態。天地乾坤混在一起,日月星辰沒有生成,晝夜寒暑沒有交替出現,上面沒有風雨雷電,下面沒有...
    Alones閱讀 2,613評論 0 3