1. 程式人生 > >iOS 檢視控制器轉場詳解

iOS 檢視控制器轉場詳解

作者:seedante,神祕人士,他的 GitHub
感謝投稿,原文連結

前言

螢幕左邊緣右滑返回,TabBar 滑動切換,你是否喜歡並十分依賴這兩個操作,甚至覺得 App 不支援這類操作的話簡直反人類?這兩個操作在大屏時代極大提升了操作效率,其背後的技術便是今天的主題:檢視控制器轉換(View Controller Transition)。

檢視控制器中的檢視顯示在螢幕上有兩種方式:最主要的方式是內嵌在容器控制器中,比如 UINavigationController,UITabBarController, UISplitController;由另外一個檢視控制器顯示它,這種方式通常被稱為模態(Modal)顯示。View Controller Transition 是什麼?在 NavigationController 裡 push 或 pop 一個 View Controller,在 TabBarController 中切換到其他 View Controller,以 Modal 方式顯示另外一個 View Controller,這些都是 View Controller Transition。在 storyboard 裡,每個 View Controller 是一個 Scene,View Controller Transition 便是從一個 Scene 轉換到另外一個 Scene;為方便,以下對 View Controller Transition 的中文稱呼採用 Objccn.io 中的翻譯「轉場」。

在 iOS 7 之前,我們只能使用系統提供的轉場效果,大部分時候夠用,但僅僅是夠用而已,總歸會有各種不如意的小地方,但我們卻無力改變;iOS 7 開放了相關 API 允許我們對轉場效果進行全面定製,這太棒了,自定義轉場動畫以及對互動手段的支援帶來了無限可能。

本文並非華麗的轉場動畫教程,相反,文中的轉場動畫效果都十分簡單,但本文的內容並不簡單,我將帶你探索轉場背後的機制,缺陷以及實現過程中的技巧與陷阱。閱讀本文需要讀者至少要對 ViewController 和 View 的結構以及協議有基本的瞭解,最好自己親手實現過一兩種轉場動畫。如果你對此感覺沒有信心,推薦觀看官方文件:View Controller Programming Guide for iOS

,學習此文件將會讓你更容易理解本文的內容。對你想學習的小節,我希望你自己親手寫下這些程式碼,一步步地看著效果是如何實現的,至少對我而言,看各種相關資料時只有字面意義上的理解,正是一步步的試驗才能讓我理解每一個步驟。本文涉及的內容較多,為了避免篇幅過長,我只給出關鍵程式碼而不是從新建工程開始教你每一個步驟。本文基於 Xcode 7 以及 Swift 2,Demo 合集地址:iOS-ViewController-Transition-Demo

Transition 解釋

前言裡從行為上解釋了轉場,那在轉場時發生了什麼?下圖是從 WWDC 2013 Session 218 整理的,解釋了轉場時檢視控制器和其對應的檢視在結構上的變化:

The Anatomy of Transition

轉場過程中,作為容器的父 VC 維護著多個子 VC,但在檢視結構上,只保留一個子 VC 的檢視,所以轉場的本質是下一場景(子 VC)的檢視替換當前場景(子 VC)的檢視以及相應的控制器(子 VC)的替換,表現為當前檢視消失和下一檢視出現,基於此進行動畫,動畫的方式非常多,所以限制最終呈現的效果就只有你的想象力了。圖中的 Parent VC 可替換為 UIViewController, UITabbarController 或 UINavigationController 中的任何一種。

目前為止,官方支援以下幾種方式的自定義轉場:

  1. 在 UINavigationController 中 push 和 pop;
  2. 在 UITabBarController 中切換 Tab;
  3. Modal 轉場:presentation 和 dismissal,俗稱檢視控制器的模態顯示和消失,僅限於modalPresentationStyle屬性為 UIModalPresentationFullScreen 或 UIModalPresentationCustom 這兩種模式;
  4. UICollectionViewController 的佈局轉場:UICollectionViewController 與 UINavigationController 結合的轉場方式,實現很簡單。

官方的支援包含了 iOS 中的大部分轉場方式,還有一種自定義容器中的轉場並沒有得到系統的直接支援,不過藉助協議這種靈活的方式,我們依然能夠實現對自定義容器控制器轉場的定製,在壓軸環節我們將實現這一點。

iOS 7 以協議的方式開放了自定義轉場的 API,協議的好處是不再拘泥於具體的某個類,只要是遵守該協議的物件都能參與轉場,非常靈活。轉場協議由5種協議組成,在實際中只需要我們提供其中的兩個或三個便能實現絕大部分的轉場動畫:

1.轉場代理(Transition Delegate):

自定義轉場的第一步便是提供轉場代理,告訴系統使用我們提供的代理而不是系統的預設代理來執行轉場。有如下三種轉場代理,對應上面三種類型的轉場:

<UINavigationControllerDelegate> //UINavigationController 的 delegate 屬性遵守該協議。
<UITabBarControllerDelegate> //UITabBarController 的 delegate 屬性遵守該協議。
<UIViewControllerTransitioningDelegate> //UIViewController 的 transitioningDelegate 屬性遵守該協議。

轉場發生時,UIKit 將要求轉場代理將提供轉場動畫的核心構件:動畫控制器和互動控制器(可選的);由我們實現。

2.動畫控制器(Animation Controller):

3.互動控制器(Interaction Controller):

4.轉場環境(Transition Context):

5.轉場協調器(Transition Coordinator):

可在轉場動畫發生的同時並行執行其他的動畫,其作用與其說協調不如說輔助,主要在 Modal 轉場和互動轉場取消時使用,其他時候很少用到;遵守<UIViewControllerTransitionCoordinator>協議;由 UIKit 在轉場時生成,UIViewController 在 iOS 7 中新增了方法transitionCoordinator()返回一個遵守該協議的物件,且該方法只在該控制器處於轉場過程中才返回一個此類物件,不參與轉場時返回 nil。

總結下,5個協議只需要我們操心3個;實現一個最低限度可用的轉場動畫,我們只需要提供上面五個元件裡的兩個:轉場代理和動畫控制器即可,還有一個轉場環境是必需的,不過這由系統提供;當進一步實現互動轉場時,還需要我們提供互動控制器,也有現成的類供我們使用。

階段一:非互動轉場

這個階段要做兩件事,提供轉場代理並由代理提供動畫控制器。在轉場代理協議裡動畫控制器和互動控制器都是可選實現的,沒有實現或者返回 nil 的話則使用預設的轉場效果。動畫控制器是表現轉場效果的核心部分,代理部分非常簡單,我們先搞定動畫控制器吧。

動畫控制器協議

動畫控制器負責新增檢視以及執行動畫,遵守UIViewControllerAnimatedTransitioning協議,該協議要求實現以下方法:

//執行動畫的地方,最核心的方法。
(Required)func animateTransition(_ transitionContext: UIViewControllerContextTransitioning)
//返回動畫時間,"return 0.5" 已足夠,非常簡單,出於篇幅考慮不貼出這個方法的程式碼實現。
(Required)func transitionDuration(_ transitionContext: UIViewControllerContextTransitioning?) -> NSTimeInterval
//如果實現了,會在轉場動畫結束後呼叫,可以執行一些收尾工作。
(Optional)func animationEnded(_ transitionCompleted: Bool)

最重要的是第一個方法,該方法接受一個遵守<UIViewControllerContextTransitioning>協議的轉場環境物件,上一節的 API 解釋裡提到這個協議,它提供了轉場所需要的重要資料:參與轉場的檢視控制器和轉場過程的狀態資訊。

UIKit 在轉場開始前生成遵守轉場環境協議<UIViewControllerContextTransitioning>的物件 transitionContext,它有以下幾個方法來提供動畫控制器需要的資訊:

//返回容器檢視,轉場動畫發生的地方。
func containerView() -> UIView?
//獲取參與轉場的檢視控制器,有 UITransitionContextFromViewControllerKey 和 UITransitionContextToViewControllerKey 兩個 Key。 
func viewControllerForKey(_ key: String) -> UIViewController?
//iOS 8新增 API 用於方便獲取參與參與轉場的檢視,有 UITransitionContextFromViewKey 和 UITransitionContextToViewKey 兩個 Key。
func viewForKey(_ key: String) -> UIView? AVAILABLE_IOS(8_0)

通過viewForKey:獲取的檢視是viewControllerForKey:返回的控制器的根檢視,或者 nil。viewForKey:方法返回 nil 只有一種情況: UIModalPresentationCustom 模式下的 Modal 轉場 ,通過此方法獲取 presentingView 時得到的將是 nil,在後面的 Modal 轉場裡會詳細解釋。

前面提到轉場的本質是下一個場景的檢視替換當前場景的檢視,從當前場景過渡下一個場景。下面稱即將消失的場景的檢視為 fromView,對應的檢視控制器為 fromVC,即將出現的檢視為 toView,對應的檢視控制器稱之為 toVC。幾種轉場方式的轉場操作都是可逆的,一種操作裡的 fromView 和 toView 在逆向操作裡的角色互換成對方,fromVC 和 toVC 也是如此。在動畫控制器裡,參與轉場的檢視只有 fromView 和 toView 之分,與轉場方式無關。轉場動畫的最終效果只限制於你的想象力。這也是動畫控制器在封裝後可以被第三方使用的重要原因。

在 iOS 8 中可通過以下方法來獲取參與轉場的三個重要檢視,在 iOS 7 中則需要通過對應的檢視控制器來獲取,為避免 API 差異導致程式碼過長,示例程式碼中直接使用下面的檢視變數:

let containerView = transitionContext.containerView()
let fromView = transitionContext.viewForKey(UITransitionContextFromViewKey)
let toView = transitionContext.viewForKey(UITransitionContextToViewKey)

動畫控制器實現

轉場 API 是協議的好處是不限制具體的類,只要物件實現該協議便能參與轉場過程,這也帶來另外一個好處:封裝便於複用,儘管三大轉場代理協議的方法不盡相同,但它們返回的動畫控制器遵守的是同一個協議,因此可以將動畫控制器封裝作為第三方動畫控制器在其他控制器的轉場過程中使用。

三種轉場方式都有一對可逆的轉場操作,你可以為了每一種操作實現單獨的動畫控制器,也可以實現通用的動畫控制器。處於篇幅的考慮,本文示範一個比較簡單的 Slide 動畫控制器:Slide left and right,而且該動畫控制器在三種轉場方式中是通用的,不必修改就可以直接在工程中使用。效果示意圖:

SlideAnimation

在互動式轉場章節裡我們將在這個基礎上實現文章開頭提到的兩種效果:NavigationController 右滑返回 和 TabBarController 滑動切換。儘管對動畫控制器來說,轉場方式並不重要,可以對 fromView 和 toView 進行任何動畫,但上面的動畫和 Modal 轉場風格上有點不配,主要動畫的方向不對,不過我在這個 Slide 動畫控制器裡為 Modal 轉場適配了和系統的風格類似的豎直移動動畫效果;另外 Modal 轉場並沒有比較合乎操作直覺的互動手段,而且和前面兩種容器控制器的轉場在機制上有些不同,所以我將為 Modal 轉場示範另外一個動畫。

在轉場中操作是可逆的,返回操作時的動畫應該也是逆向的。對此,Slide 動畫控制器需要針對轉場的操作型別對動畫的方向進行調整。Swift 中 enum 的關聯值可以視作有限資料型別的集合體,在這種場景下極其合適。設定轉場型別:

enum SDETransitionType{
    //UINavigationControllerOperation 是列舉型別,有 None, Push, Pop 三種值。
    case NavigationTransition(UINavigationControllerOperation) 
    case TabTransition(TabOperationDirection)
    case ModalTransition(ModalOperation)
}

enum TabOperationDirection{
    case Left, Right
}

enum ModalOperation{
    case Presentation, Dismissal
}

使用示例:在 TabBarController 中切換到左邊的頁面。

let transitionType = SDETransitionType.TabTransition(.Left)

Slide 動畫控制器的核心程式碼:

class SlideAnimationController: NSObject, UIViewControllerAnimatedTransitioning {
    init(type: SDETransitionType) {...}

    func animateTransition(transitionContext: UIViewControllerContextTransitioning) {
        ...
         //1
        containerView.addSubview(toView)

        //計算位移 transform,NavigationVC 和 TabBarVC 在水平方向進行動畫,Modal 轉場在豎直方向進行動畫。
        var toViewTransform = ...
        var fromViewTransform = ...
        toView.transform = toViewTransform

        //根據協議中的方法獲取動畫的時間。
        let duration = self.transitionDuration(transitionContext)
        UIView.animateWithDuration(duration, animations: {
            fromView.transform = fromViewTransform
            toView.transform = CGAffineTransformIdentity
            }, completion: { _ in
                //考慮到轉場中途可能取消的情況,轉場結束後,恢復檢視狀態。
                fromView.transform = CGAffineTransformIdentity
                toView.transform = CGAffineTransformIdentity
                //2
                let isCancelled = transitionContext.transitionWasCancelled()
                transitionContext.completeTransition(!isCancelled)
        })
    }
}

注意上面的程式碼有2處標記,是動畫控制器必須完成的:

  1. 將 toView 新增到容器檢視中,使得 toView 在螢幕上顯示( Modal 轉場中此點稍有不同,下一節細述);
  2. 正確地結束轉場過程。轉場的結果有兩種:完成或取消。非互動轉場的結果只有完成一種情況,不過互動式轉場需要考慮取消的情況。如何結束取決於轉場的進度,通過transitionWasCancelled()方法來獲取轉場的狀態,使用completeTransition:來完成或取消轉場。

實際上,這裡示範的簡單的轉場動畫和那些很複雜的轉場動畫在轉場的部分要做的事情都是上面提到的這兩點,它們的區別主要在於動畫的部分。

轉場結束後,fromView 會從檢視結構中移除,UIKit 自動替我們做了這事,你也可以手動處理提前將 fromView 移除,這完全取決於你。UIView的類方法transitionFromView:toView:duration:options:completion:也能做同樣的事,使用下面的程式碼替換上面的程式碼,甚至不需要獲取 containerView 以及手動新增 toView 就能實現一個類似的轉場動畫:

UIView.transitionFromView(fromView, toView: toView, duration: durantion, options: .TransitionCurlDown, completion: { _ in
    let isCancelled = transitionContext.transitionWasCancelled()
    transitionContext.completeTransition(!isCancelled)
})

特殊的 Modal 轉場

Modal 轉場的差異

Modal 轉場中需要做的事情和兩種容器 VC 的轉場一樣,但在細節上有些差異。

ContainerVC VS Modal

UINavigationController 和 UITabBarController 這兩個容器 VC 的根檢視在螢幕上是不可見的(或者說是透明的),可見的只是內嵌在這兩者中的子 VC 中的檢視,轉場是從子 VC 的檢視轉換到另外一個子 VC 的檢視,其根檢視並未參與轉場;而 Modal 轉場,以 presentation 為例,是從 presentingView 轉換到 presentedView,根檢視 presentingView 也就是 fromView 參與了轉場。而且 NavigationController 和 TabBarController 轉場中的 containerView 也並非這兩者的根檢視。

Modal 轉場與兩種容器 VC 的轉場的另外一個不同是:Modal 轉場結束後 presentingView 可能依然可見,UIModalPresentationPageSheet 模式就是這樣。這種不同導致了 Modal 轉場和容器 VC 的轉場對 fromView 的處理差異:容器 VC 的轉場結束後 fromView 會被主動移出檢視結構,這是可預見的結果,我們也可以在轉場結束前手動移除;而 Modal 轉場中,presentation 結束後 presentingView(fromView) 並未主動被從檢視結構中移除。準確來說,是 UIModalPresentationCustom 這種模式下的 Modal 轉場結束時 fromView 並未從檢視結構中移除;UIModalPresentationFullScreen 模式的 Modal 轉場結束後 fromView 依然主動被從檢視結構中移除了。這種差異導致在處理 dismissal 轉場的時候很容易出現問題,沒有意識到這個不同點的話出錯時就會毫無頭緒。下面來看看 dismissal 轉場時的場景。

ContainerView 在轉場期間作為 fromView 和 toView 的父檢視。三種轉場過程中的 containerView 是 UIView 的私有子類,不過我們並不需要關心 containerView 具體是什麼。在 dismissal 轉場中:

  1. UIModalPresentationFullScreen 模式:presentation 後,presentingView 被主動移出檢視結構,在 dismissal 中 presentingView 是 toView 的角色,其將會重新加入 containerView 中,實際上,我們不主動將其加入,UIKit 也會這麼做,前面的兩種容器控制器的轉場裡不是這樣處理的,不過這個差異基本沒什麼影響。
  2. UIModalPresentationCustom 模式:轉場時 containerView 並不擔任 presentingView 的父檢視,後者由 UIKit 另行管理。在 presentation 後,fromView(presentingView) 未被移出檢視結構,在 dismissal 中,注意不要像其他轉場中那樣將 toView(presentingView) 加入 containerView 中,否則本來可見的 presentingView 將會被移除出自身所處的檢視結構消失不見。如果你在使用 Custom 模式時沒有注意到這點,就很容易掉進這個陷阱而很難察覺問題所在,這個問題曾困擾了我一天。

對於 Custom 模式,我們可以參照其他轉場裡的處理規則來打理:presentation 轉場結束後主動將 fromView(presentingView) 移出它的檢視結構,並用一個變數來維護 presentingView 的父檢視,以便在 dismissal 轉場中恢復;在 dismissal 轉場中,presentingView 的角色由原來的 fromView 切換成了 toView,我們再將其重新恢復它原來的檢視結構中。測試表明這樣做是可行的。但是這樣一來,在實現上,需要在轉場代理中維護一個動畫控制器並且這個動畫控制器要維護 presentingView 的父檢視,第三方的動畫控制器必須為此改造。顯然,這樣的代價是無法接受的。

小結:經過上面的嘗試,建議是,不要干涉官方對 Modal 轉場的處理,我們去適應它。在 Custom 模式下,由於 presentingView 不受 containerView 管理,在 dismissal 轉場中不要像其他的轉場那樣將 toView(presentingView) 加入 containerView,否則 presentingView 將消失不見,而應用則也很可能假死;而在 presentation 轉場中,切記不要手動將 fromView(presentingView) 移出其父檢視。

iOS 8 為<UIViewControllerContextTransitioning>協議添加了viewForKey:方法以方便獲取 fromView 和 toView,但是在 Modal 轉場裡要注意,從上面可以知道,Custom 模式下,presentingView 並不受 containerView 管理,這時通過viewForKey:方法來獲取 presentingView 得到的是 nil,必須通過viewControllerForKey:得到 presentingVC 後來獲取。因此在 Modal 轉場中,較穩妥的方法是從 fromVC 和 toVC 中獲取 fromView 和 toView。

順帶一提,前面提到的UIView的類方法transitionFromView:toView:duration:options:completion:能在 Custom 模式下工作,卻與 FullScreen 模式有點不相容。

Modal 轉場實踐

UIKit 已經為 Modal 轉場實現了多種效果,當 UIViewController 的modalPresentationStyle屬性為.Custom.FullScreen時,我們就有機會定製轉場效果,此時modalTransitionStyle指定的轉場動畫將會被忽略。

Modal 轉場開放自定義功能後最令人感興趣的是定製 presentedView 的尺寸,下面來我們來實現一個帶暗色調背景的小視窗效果。Demo 地址:CustomModalTransition

ModalTransition

由於需要保持 presentingView 可見,這裡的 Modal 轉場應該採用 UIModalPresentationCustom 模式,此時 presentedVC 的modalPresentationStyle屬性值應設定為.Custom。而且與容器 VC 的轉場的代理由容器 VC 自身的代理提供不同,Modal 轉場的代理由 presentedVC 提供。動畫控制器的核心程式碼:

class OverlayAnimationController: NSobject, UIViewControllerAnimatedTransitioning{
    ... 
    func animateTransition(transitionContext: UIViewControllerContextTransitioning) {            
        ...
        //不像容器 VC 轉場裡需要額外的變數來標記操作型別,UIViewController 自身就有方法跟蹤 Modal 狀態。
        //處理 Presentation 轉場:
        if toVC.isBeingPresented(){
            //1
            containerView.addSubview(toView)
            //在 presentedView 後面新增暗背景檢視 dimmingView,注意兩者在 containerView 中的位置。
            let dimmingView = UIView()
            containerView.insertSubview(dimmingView, belowSubview: toView)

            //設定 presentedView 和 暗背景檢視 dimmingView 的初始位置和尺寸。
            let toViewWidth = containerView.frame.width * 2 / 3
            let toViewHeight = containerView.frame.height * 2 / 3
            toView.center = containerView.center
            toView.bounds = CGRect(x: 0, y: 0, width: 1, height: toViewHeight)

            dimmingView.backgroundColor = UIColor(white: 0.0, alpha: 0.5)
            dimmingView.center = containerView.center
            dimmingView.bounds = CGRect(x: 0, y: 0, width: toViewWidth, height: toViewHeight)

            //實現出現時的尺寸變化的動畫:
            UIView.animateWithDuration(duration, delay: 0, options: .CurveEaseInOut, animations: {
                toView.bounds = CGRect(x: 0, y: 0, width: toViewWidth, height: toViewHeight)
                dimmingView.bounds = containerView.bounds
                }, completion: {_ in
                    //2
                    let isCancelled = transitionContext.transitionWasCancelled()
                    transitionContext.completeTransition(!isCancelled)
            })
        }
        //處理 Dismissal 轉場,按照上一小節的結論,.Custom 模式下不要將 toView 新增到 containerView,省去了上面標記1處的操作。
        if fromVC.isBeingDismissed(){
            let fromViewHeight = fromView.frame.height
            UIView.animateWithDuration(duration, animations: {
                fromView.bounds = CGRect(x: 0, y: 0, width: 1, height: fromViewHeight)
                }, completion: { _ in
                    //2
                    let isCancelled = transitionContext.transitionWasCancelled()
                    transitionContext.completeTransition(!isCancelled)
            })
        }
    }
}

iOS 8的改進:UIPresentationController

iOS 8 針對解析度日益分裂的 iOS 裝置帶來了新的適應性佈局方案,以往有些專為在 iPad 上設計的控制器也能在 iPhone 上使用了,一個大變化是在檢視控制器的(模態)顯示過程,包括轉場過程,引入了UIPresentationController類,該類接管了 UIViewController 的顯示過程,為其提供轉場和檢視管理支援。當 UIViewController 的modalPresentationStyle屬性為.Custom時(不支援.FullScreen),我們有機會通過控制器的轉場代理提供UIPresentationController的子類對 Modal 轉場進行進一步的定製。官方對該類參與轉場的流程和使用方法有非常詳細的說明:Creating Custom Presentations

UIPresentationController類主要給 Modal 轉場帶來了以下幾點變化:

  1. 定製 presentedView 的外觀:設定 presentedView 的尺寸以及在 containerView 中新增自定義檢視併為這些檢視新增動畫;
  2. 可以選擇是否移除 presentingView;
  3. 可以在不需要動畫控制器的情況下單獨工作;
  4. iOS 8 中的適應性佈局。

以上變化中第1點 iOS 7 中也能做到,3和4是 iOS 8 帶來的新特性,只有第2點才真正解決了 iOS 7 中的痛點。在 iOS 7 中定製外觀時,動畫控制器需要負責管理額外新增的的檢視,UIPresentationController類將該功能剝離了出來獨立負責,其提供瞭如下的方法參與轉場,對轉場過程實現了更加細緻的控制,從命名便可以看出與動畫控制器裡的animateTransition:的關係:

func presentationTransitionWillBegin()
func presentationTransitionDidEnd(_ completed: Bool)
func dismissalTransitionWillBegin()
func dismissalTransitionDidEnd(_ completed: Bool)

除了 presentingView,UIPresentationController類擁有轉場過程中剩下的角色:

//指定初始化方法。
init(presentedViewController presentedViewController: UIViewController, presentingViewController presentingViewController: UIViewController)
var presentingViewController: UIViewController { get }
var presentedViewController: UIViewController { get }
var containerView: UIView? { get }
//提供給動畫控制器使用的檢視,預設返回 presentedVC.view,通過重寫該方法返回其他檢視,但一定要是 presentedVC.view 的上層檢視。
func presentedView() -> UIView?     

沒有 presentingView 是因為 Custom 模式下 presentingView 不受 containerView 管理,UIPresentationController類並沒有改變這一點。iOS 8 擴充了轉場環境協議,可以通過viewForKey:方便獲取轉場的檢視,而該方法在 Modal 轉場中獲取的是presentedView()返回的檢視。因此我們可以在子類中將 presentedView 包裝在其他檢視後重寫該方法返回包裝後的檢視當做 presentedView 在動畫控制器中使用。

接下來,我用UIPresentationController子類實現上一節「Modal 轉場實踐」裡的效果,presentingView 和 presentedView 的動畫由動畫控制器負責,剩下的事情可以交給我們實現的子類來完成。

參與角色都準備好了,但有個問題,無法直接訪問動畫控制器,不知道轉場的持續時間,怎麼與轉場過程同步?這時候前面提到的用處甚少的轉場協調器(Transition Coordinator)將在這裡派上用場。該物件可通過 UIViewController 的transitionCoordinator()方法獲取,這是 iOS 7 為自定義轉場新增的 API,該方法只在控制器處於轉場過程中才返回一個與當前轉場有關的有效物件,其他時候返回 nil。

轉場協調器遵守<UIViewControllerTransitionCoordinator>協議,它含有以下幾個方法:

//與動畫控制器中的轉場動畫同步,執行其他動畫
animateAlongsideTransition:completion:
//與動畫控制器中的轉場動畫同步,在指定的檢視內執行動畫
animateAlongsideTransitionInView:animation:completion:

由於轉場協調器的這種特性,動畫的同步問題解決了。

class OverlayPresentationController: UIPresentationController {
    let dimmingView = UIView()

    //Presentation 轉場開始前該方法被呼叫。
    override func presentationTransitionWillBegin() {
        self.containerView?.addSubview(dimmingView)

        let initialWidth = containerView!.frame.width*2/3, initialHeight = containerView!.frame.height*2/3
        self.dimmingView.backgroundColor = UIColor(white: 0.0, alpha: 0.5)
        self.dimmingView.center = containerView!.center
        self.dimmingView.bounds = CGRect(x: 0, y: 0, width: initialWidth , height: initialHeight)
        //使用 transitionCoordinator 與轉場動畫並行執行 dimmingView 的動畫。
        presentedViewController.transitionCoordinator()?.animateAlongsideTransition({ _ in
            self.dimmingView.bounds = self.containerView!.bounds
        }, completion: nil)
    }
    //Dismissal 轉場開始前該方法被呼叫。添加了 dimmingView 消失的動畫,在上一節中並沒有新增這個動畫,
    //實際上由於 presentedView 的形變動畫,這個動畫根本不會被注意到,此處只為示範。
    override func dismissalTransitionWillBegin() {
        presentedViewController.transitionCoordinator()?.animateAlongsideTransition({ _ in
            self.dimmingView.alpha = 0.0
            }, completion: nil)
    }    
}

OverlayPresentationController類接手了 dimmingView 的工作後,需要回到上一節OverlayAnimationController裡把涉及 dimmingView 的部分刪除,然後在 presentedVC 的轉場代理屬性transitioningDelegate中提供該類例項就可以實現和上一節同樣的效果。

func presentationControllerForPresentedViewController(_ presented: UIViewController, 
                              presentingViewController presenting: UIViewController, 
                                      sourceViewController source: UIViewController) -> UIPresentationController?{
    return OverlayPresentationController(presentedViewController: presented, presentingViewController: presenting)
}

在 iOS 7 中,Custom 模式的 Modal 轉場裡,presentingView 不會被移除,如果我們要移除它並妥善恢復會破壞動畫控制器的獨立性使得第三方動畫控制器無法直接使用;在 iOS 8 中,UIPresentationController解決了這點,給予了我們選擇的權力,通過重寫下面的方法來決定 presentingView 是否在 presentation 轉場結束後被移除:

func shouldRemovePresentersView() -> Bool

返回 true 時,presentation 結束後 presentingView 被移除,在 dimissal 結束後 UIKit 會自動將 presentingView 恢復到原來的檢視結構中。通過UIPresentationController的參與,Custom 模式完全實現了 FullScreen 模式下的全部特性。

你可能會疑惑,除了解決了 iOS 7中無法干涉 presentingView 這個痛點外,還有什麼理由值得我們使用UIPresentationController類?除了能與動畫控制器配合,UIPresentationController類也能脫離動畫控制器獨立工作,在轉場代理裡我們僅僅提供後者也能對 presentedView 的外觀進行定製,缺點是無法控制 presentedView 的轉場動畫,因為這是動畫控制器的職責,這種情況下,presentedView 的轉場動畫採用的是預設的動畫效果,轉場協調器實現的動畫則是採用預設的動畫時間。

iOS 8 帶來了適應性佈局,<UIContentContainer>協議用於響應檢視尺寸變化和螢幕旋轉事件,之前用於處理螢幕旋轉的方法都被廢棄了。UIViewController 和 UIPresentationController 類都遵守該協議,在 Modal 轉場中如果提供了後者,則由後者負責前者的尺寸變化和螢幕旋轉,最終的佈局機會也在後者裡。在OverlayPresentationController中重寫以下方法來調整檢視佈局以及應對螢幕旋轉:

override func containerViewWillLayoutSubviews() {
    self.dimmingView.center = self.containerView!.center
    self.dimmingView.bounds = self.containerView!.bounds

    let width = self.containerView!.frame.width * 2 / 3, height = self.containerView!.frame.height * 2 / 3
    self.presentedView()?.center = self.containerView!.center
    self.presentedView()?.bounds = CGRect(x: 0, y: 0, width: width, height: height)
}

轉場代理

完成動畫控制器後,只需要在轉場前設定好轉場代理便能實現動畫控制器中提供的效果。轉場代理的實現很簡單,但是在設定代理時有不少陷阱,需要注意。

UINavigationControllerDelegate

定製 UINavigationController 這種容器控制器的轉場時,很適合實現一個子類,自身集轉場代理,動畫控制器於一身,也方便使用,不過這樣做有時候又限制了它的使用範圍,別人也實現了自己的子類時便不能方便使用你的效果,這裡採取的是將轉場代理封裝成一個類。

class SDENavigationControllerDelegate: NSObject, UINavigationControllerDelegate {
    //在<UINavigationControllerDelegate>物件裡,實現該方法提供動畫控制器,返回 nil 則使用系統預設的效果。
    func navigationController(navigationController: UINavigationController, 
         animationControllerForOperation operation: UINavigationControllerOperation, 
                         fromViewController fromVC: UIViewController, 
                             toViewController toVC: UIViewController) -> UIViewControllerAnimatedTransitioning? {
        //使用上一節實現的 Slide 動畫控制器,需要提供操作型別資訊。
        let transitionType = SDETransitionType.NavigationTransition(operation)
        return SlideAnimationController(type: transitionType)
    }
}

如果你在程式碼裡為你的控制器裡這樣設定代理:

//錯誤的做法,delegate 是弱引用,在離開這行程式碼所處的方法範圍後,delegate 將重新變為 nil,然後什麼都不會發生。
self.navigationController?.delegate = SDENavigationControllerDelegate()

可以使用強引用的變數來引用新例項,且不能使用本地變數,在控制器中新增一個變數來維持新例項就可以了。

self.navigationController?.delegate = strongReferenceDelegate

解決了弱引用的問題,這行程式碼應該放在哪裡執行呢?很多人喜歡在viewDidLoad()做一些配置工作,但在這裡設定無法保證是有效的,因為這時候控制器可能尚未進入 NavigationController 的控制器棧,self.navigationController返回的可能是 nil;如果是通過程式碼 push 其他控制器,在 push 前設定即可;prepareForSegue:sender:方法是轉場前更改設定的最後一次機會,可以在這裡設定;保險點,使用UINavigationController子類,自己作為代理,省去到處設定的麻煩。

不過,通過程式碼設定終究顯得很繁瑣且不安全,在 storyboard 裡設定一勞永逸:在控制元件庫裡拖拽一個 NSObject 物件到相關的 UINavigationControler 上,在控制面板裡將其類別設定為SDENavigationControllerDelegate,然後拖拽滑鼠將其設定為代理。

最後一步,像往常一樣觸發轉場:

self.navigationController?.pushViewController(toVC, animated: true)//or
self.navigationController?.popViewControllerAnimated(true)

在 storyboard 中通過設定 segue 時開啟動畫也將看到同樣的 Slide 動畫。Demo 地址:NavigationControllerTransition

UITabBarControllerDelegate

同樣作為容器控制器,UITabBarController 的轉場代理和 UINavigationController 類似,通過類似的方法提供動畫控制器,不過<UINavigationControllerDelegate>的代理方法裡提供了操作型別,但<UITabBarControllerDelegate>的代理方法沒有提供滑動的方向資訊,需要我們來獲取滑動的方向。

class SDETabBarControllerDelegate: NSObject, UITabBarControllerDelegate {
    //在<UITabBarControllerDelegate>物件裡,實現該方法提供動畫控制器,返回 nil 則沒有動畫效果。
    func tabBarController(tabBarController: UITabBarController, animationControllerForTransitionFromViewController 
                                    fromVC: UIViewController, 
                     toViewController toVC: UIViewController) -> UIViewControllerAnimatedTransitioning?{
        let fromIndex = tabBarController.viewControllers!.indexOf(fromVC)!
        let toIndex = tabBarController.viewControllers!.indexOf(toVC)!

        let tabChangeDirection: TabOperationDirection = toIndex < fromIndex ? .Left : .Right
        let transitionType = SDETransitionType.TabTransition(tabChangeDirection)
        let slideAnimationController = SlideAnimationController(type: transitionType)
        return slideAnimationController
    }
}

為 UITabBarController 設定代理的方法和陷阱與上面的 UINavigationController 類似,注意delegate屬性的弱引用問題。點選 TabBar 的相鄰頁面進行切換時,將會看到 Slide 動畫;通過以下程式碼觸發轉場時也將看到同樣的效果:

tabBarVC.selectedIndex = ...//or
tabBarVC.selectedViewController = ...

UIViewControllerTransitioningDelegate

Modal 轉場的代理協議<UIViewControllerTransitioningDelegate>是 iOS 7 新增的,其為 presentation 和 dismissal 轉場分別提供了動畫控制器。在「特殊的 Modal 轉場」裡實現的OverlayAnimationController類可同時處理 presentation 和 dismissal 轉場。UIPresentationController只在 iOS 8中可用,通過available關鍵字可以解決 API 的版本差異。

class SDEModalTransitionDelegate: NSObject, UIViewControllerTransitioningDelegate {
    func animationControllerForPresentedController(presented: UIViewController, 
                             presentingController presenting: UIViewController, 
                                     sourceController source: UIViewController) -> UIViewControllerAnimatedTransitioning? {
        return OverlayAnimationController()
    }

    func animationControllerForDismissedController(dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? {
        return OverlayAnimationController()
    }

    @available(iOS 8.0, *)
    func presentationControllerForPresentedViewController(presented: UIViewController, 
                                presentingViewController presenting: UIViewController, 
                                        sourceViewController source: UIViewController) -> UIPresentationController? {
        return OverlayPresentationController(presentedViewController: presented, presentingViewController: presenting)
    }
}

Modal 轉場的代理由 presentedVC 的transitioningDelegate屬性來提供,這與前兩種容器控制器的轉場不一樣,不過該屬性作為代理同樣是弱引用,記得和前面一樣需要有強引用的變數來維護該代理,而 Modal 轉場需要 presentedVC 來提供轉場代理的特性使得 presentedVC 自身非常適合作為自己的轉場代理。另外,需要將 presentedVC 的modalPresentationStyle屬性設定為.Custom.FullScreen,只有這兩種模式下才支援自定義轉場,該屬性預設值為.FullScreen。自定義轉場時,決定轉場動畫效果的modalTransitionStyle屬性將被忽略。

開啟轉場動畫的方式依然是兩種:在 storyboard 裡設定 segue 並開啟動畫,但這裡並不支援.Custom模式,不過還有機會挽救,轉場前的最後一個環節prepareForSegue:sender:方法裡可以動態修改modalPresentationStyle屬性;或者全部在程式碼裡設定,示例如下:

let presentedVC = ...
presentedVC.transitioningDelegate = strongReferenceSDEModalTransitionDelegate
//當與 UIPresentationController 配合時該屬性必須為.Custom。
presentedVC.modalPresentationStyle = .Custom/.FullScreen      
presentingVC.presentViewController(presentedVC, animated: true, completion: nil)

階段二:互動式轉場

激動人心的部分來了,好訊息是互動轉場的實現難度比你想象的要低。

實現互動化

在非互動轉場的基礎上將之互動化需要兩個條件:

  1. 由轉場代理提供互動控制器,這是一個遵守<UIViewControllerInteractiveTransitioning>協議的物件,不過系統已經打包好了現成的類UIPercentDrivenInteractiveTransition供我們使用。我們不需要做任何配置,僅僅在轉場代理的相應方法中提供一個該類例項便能工作。另外互動控制器必須有動畫控制器才能工作。

  2. 互動控制器還需要互動手段的配合,最常見的是使用手勢,或是其他事件,來驅動整個轉場程序。

滿足以上兩個條件很簡單,但是很容易犯錯誤。

正確地提供互動控制器

如果在轉場代理中提供了互動控制器,而轉場發生時並沒有方法來驅動轉場程序(比如手勢),轉場過程將一直處於開始階段無法結束,應用介面也會失去響應:在 NavigationController 中點選 NavigationBar 也能實現 pop 返回操作,但此時沒有了互動手段的支援,轉場過程卡殼;在 TabBarController 的代理裡提供互動控制器存在同樣的問題,點選 TabBar 切換頁面時也沒有實現互動控制。因此僅在確實處於互動狀態時才提供互動控制器,可以使用一個變數來標記互動狀態,該變數由互動手勢來更新狀態。

以為 NavigationController 提供互動控制器為例:

class SDENavigationDelegate: NSObject, UINavigationControllerDelegate {
    var interactive = false
    let interactionController = UIPercentDrivenInteractiveTransition()
    ...

    func navigationController(navigationController: UINavigationController, interactionControllerForAnimationController 
                               animationController: UIViewControllerAnimatedTransitioning) -> UIViewControllerInteractiveTransitioning? {
        return interactive ? self.interactionController : nil
    }
}

TabBarController 的實現類似,Modal 轉場代理分別為 presentation 和 dismissal 提供了各自的互動控制器,也需要注意上面的問題。

問題的根源是互動控制的工作機制導致的,互動過程實際上是由轉場環境物件<UIViewControllerContextTransitioning>來管理的,它提供瞭如下幾個方法來控制轉場的進度:

func updateInteractiveTransition(_ percentComplete: CGFloat)//更新轉場進度,進度數值範圍為0.0~1.0。
func cancelInteractiveTransition()//取消轉場,轉場動畫從當前狀態返回至轉場發生前的狀態。
func finishInteractiveTransition()//完成轉場,轉場動畫從當前狀態繼續直至結束。

互動控制協議<UIViewControllerInteractiveTransitioning>只有一個必須實現的方法:

func startInteractiveTransition(_ transitionContext: UIViewControllerContextTransitioning)

在轉場代理裡提供了互動控制器後,轉場開始時,該方法自動被 UIKit 呼叫對轉場環境進行配置。

系統打包好的UIPercentDrivenInteractiveTransition中的控制轉場進度的方法與轉場環境物件提供的三個方法同名,實際上只是前者呼叫了後者的方法而已。系統以一種解耦的方式使得動畫控制器,互動控制器,轉場環境物件互相協作,我們只需要使用UIPercentDrivenInteractiveTransition的三個同名方法來控制進度就夠了。如果你要實現自己的互動控制器,而不是UIPercentDrivenInteractiveTransition的子類,就需要呼叫轉場環境的三個方法來控制進度,壓軸環節我們將示範如何做。

互動控制器控制轉場的過程就像將動畫控制器實現的動畫製作成一部視訊,我們使用手勢或是其他方法來控制轉場動畫的播放,可以前進,後退,繼續或者停止。finishInteractiveTransition()方法被呼叫後,轉場動畫從當前的狀態將繼續進行直到動畫結束,轉場完成;cancelInteractiveTransition()被呼叫後,轉場動畫從當前的狀態回撥到初始狀態,轉場取消。

在 NavigationController 中點選 NavigationBar 的 backBarButtomItem 執行 pop 操作時,由於我們無法介入 backBarButtomItem 的內部流程,就失去控制進度的手段,於是轉場過程只有一個開始,永遠不會結束。其實我們只需要有能夠執行上述幾個方法的手段就可以對轉場動畫進行控制,使用者與螢幕的互動手段裡,手勢是實現這個控制過程的天然手段,我猜這是其被稱為互動控制器的原因。

互動手段的配合

下面使用演示如何利用螢幕邊緣滑動手勢UIScreenEdgePanGestureRecognizer在 NavigationController 中控制 Slide 動畫控制器提供的動畫來實現右滑返回的效果,該手勢繫結的動作方法如下:

func handleEdgePanGesture(gesture: UIScreenEdgePanGestureRecognizer){
    //根據移動距離計算互動過程的進度。
    let percent = ...
    switch gesture.state{
    case .Began:
        //轉場開始前獲取代理,一旦轉場開始,VC 將脫離控制器棧,此後 self.navigationController 返回的是 nil。
        self.navigationDelegate = self.navigationController?.delegate as? SDENavigationDelegate
        //更新互動狀態
        self.navigationDelegate?.interactive = true
        //1.互動控制器沒有 start 之類的方法,當下面這行程式碼執行後,轉場開始;
        //如果轉場代理提供了互動控制器,它將從這時候開始接管轉場過程。
        self.navigationController?.popViewControllerAnimated(true)
    case .Changed:
        //2.更新進度:
        self.navigationDelegate?.interactionController.updateInteractiveTransition(percent)
    case .Cancelled, .Ended:
        //3.結束轉場:
        if percent > 0.5{
            //完成轉場。
            self.navigationDelegate?.interactionController.finishInteractiveTransition()
        }else{
            //或者,取消轉場。
            self.navigationDelegate?.interactionController.cancelInteractiveTransition()
        }
        //無論轉場的結果如何,恢復為非互動狀態。
        self.navigationDelegate?.interactive = false
    default: self.navigationDelegate?.interactive = false
    }
}

互動轉場的流程就是三處數字標記的程式碼。不管是什麼互動方式,使用什麼轉場方式,都是在使用這三個方法控制轉場的進度。對於互動式轉場,互動手段只是表現形式,本質是驅動轉場程序。很希望能夠看到更新穎的互動手法,比如通過點選頁面不同區域來控制一套複雜的流程動畫。TabBarController 的 Demo 中也實現了滑動切換 Tab 頁面,程式碼是類似的,就不佔篇幅了;示範的 Modal 轉場我沒有為之實現互動控制,原因也提到過了,沒有比較合乎操作直覺的互動手段,不過真要為其新增互動控制,程式碼和上面是類似的。

轉場互動化後結果有兩種:完成和取消。取消後動畫將會原路返回到初始狀態,但已經變化了的資料怎麼恢復?

一種情況是,控制器的系統屬性,比如,在 TabBarController 裡使用上面的方法實現滑動切換 Tab 頁面,中途取消的話,已經變化的selectedIndex屬性該怎麼恢復為原值;上面的程式碼裡,取消轉場的程式碼執行後,self.navigationController返回的依然還是是 nil,怎麼讓控制器回到 NavigationController 的控制器棧頂。對於這種情況,UIKit 自動替我們恢復了,不需要我們操心(可能你都沒有意識到這回事);

另外一種就是,轉場發生的過程中,你可能想實現某些效果,一般是在下面的事件中執行,轉場中途取消的話可能需要取消這些效果。

func viewWillAppear(_ animated: Bool)
func viewDidAppear(_ animated: Bool)
func viewWillDisappear(_ animated: Bool)
func viewDidDisappear(_ animated: Bool)

互動轉場介入後,檢視在這些狀態間的轉換變得複雜,WWDC 上蘋果的工程師還表示轉場過程中 view 的Will系方法和Did系方法的執行順序並不能得到保證,雖然機率很小,但如果你依賴於這些方法執行的順序的話就可能需要注意這點。而且,Did系方法呼叫時並不意味著轉場過程真的結束了。另外,fromView 和 toView 之間的這幾種方法的相對順序更加混亂,具體的案例可以參考這裡:The Inconsistent Order of View Transition Events

如何在轉場過程中的任意階段中斷時取消不需要的效果?這時候該轉場協調器(Transition Coordinator)再次出場了。

Transition Coordinator

轉場協調器(Transition Coordinator)的出場機會不多,但卻是關鍵先生。Modal
轉場中,UIPresentationController類只能通過轉場協調器來與動畫控制器同步,並行執行其他動畫;這裡它可以在互動式轉場結束時執行一個閉包:

func notifyWhenInteractionEndsUsingBlock(_ handler: (UIViewControllerTransitionCoordinatorContext) -> Void)

當轉場由互動狀態轉變為非互動狀態(在手勢互動過程中則為手勢結束時),無論轉場的結果是完成還是被取消,該方法都會被呼叫;得益於閉包,轉場協調器可以在轉場過程中的任意階段蒐集動作並在互動中止後執行。閉包中的引數是一個遵守<UIViewControllerTransitionCoordinatorContext>協議的物件,該物件由 UIKit 提供,和前面的轉場環境物件<UIViewControllerContextTransitioning>作用類似,它提供了互動轉場的狀態資訊。

override func viewWillAppear(animated: Bool) {
    super.viewWillDisappear(animated)
    self.doSomeSideEffectsAssumingViewDidAppearIsGoingToBeCalled()
    //只在處於互動轉場過程中才可能取消效果。
    if let coordinator = self.transitionCoordinator() where coordinator.initiallyInteractive() == true{
        coordinator.notifyWhenInteractionEndsUsingBlock({
            interactionContext in
            if interactionContext.isCancelled(){
                self.undoSideEffects()
            }
        })
    }
}

不過互動狀態結束時並非轉場過程的終點(此後動畫控制器提供的轉場動畫根據互動結束時的狀態繼續或是返回到初始狀態),而是由動畫控制器來結束這一切:

optional func animationEnded(_ transitionCompleted: Bool)

如果實現了該方法,將在轉場動畫結束後呼叫。

UIViewController 可以通過transitionCoordinator()獲取轉場協調器,該方法的文件中說只有在 Modal 轉場過程中,該方法才返回一個與當前轉場相關的有效物件。實際上,NavigationController 的轉場中 fromVC 和 toVC 也能返回一個有效物件,TabBarController 有點特殊,fromVC 和 toVC 在轉場中返回的是 nil,但是作為容器的 TabBarController 可以使用該方法返回一個有效物件。

轉場協調器除了上面的兩種關鍵作用外,也在 iOS 8 中的適應性佈局中擔任重要角色,可以檢視<UIContentContainer>協議中的方法,其中響應尺寸和螢幕旋轉事件的方法都包含一個轉場協調器物件,檢視的這種變化也被系統視為廣義上的 transition,引數中的轉場協調器也由 UIKit 提供。這個話題有點超出本文的範圍,就不深入了,有需要的話可以檢視文件和相關 session。

封裝互動控制器

UIPercentDrivenInteractiveTransition類是一個系統提供的互動控制器,在轉場代理的相關方法裡提供一個該類例項就夠了,還有其他需求的話可以實現其子類來完成,那這裡的封裝是指什麼?系統把互動控制器打包好了,但是互動控制器工作還需要其他的配置。程式設計師向來很懶,能夠自動完成的事絕不肯寫一行程式碼,寫一行程式碼就能搞定的事絕不寫第二行,所謂少寫一行是一行。能不能順便把互動控制器的配置也打包好省得寫程式碼啊?當然可以。

熱門轉場動畫庫 VCTransitionsLibrary 封裝好了多種動畫效果,並且自動支援 pop, dismissal 和 tab change 等操作的手勢互動,其手法是在轉場代理裡為 toVC 新增手勢並繫結相應的處理方法。

為何沒有支援 push 和 presentation 這兩種轉場?因為 push 和 presentation 這兩種轉場需要提供 toVC,而庫並沒有 toVC 的資訊,這需要作為使用者的開發者來提供;對於逆操作的 pop 和 dismiss,toVC 的資訊已經存在了,所以能夠實現自動支援。而 TabBarController 則是個例外,它是在已知的子 VC 之間切換,不存在這個問題。需要注意的是,庫這樣封裝了互動控制器後,那麼你將無法再讓同一種手勢支援 push 或 presentation,要麼只支援單向的轉場,要麼你自己實現雙向的轉場。當然,如果知道 toVC 是什麼類的話,你可以改寫這個庫讓 push 和 present 得到支援。不過,對於在初始化時需要配置額外資訊的類,這種簡單的封裝可能不起作用。VCTransitionsLibrary 庫還支援新增自定義的簡化版的動畫控制器和互動控制器,在封裝和靈活之間的平衡控制得很好,程式碼非常值得學習。

只要願意,我們還可以變得更懶,不,是效率更高。FDFullscreenPopGesture 通過 category 的方法讓所有的 UINavigationController 都支援右滑返回,而且,一行程式碼都不用寫,這是配套的部落格:一個絲滑的全屏滑動返回手勢。那麼也可以實現一個類似的 FullScreenTabScrollGesture 讓所有的 UITabBarController 都支援滑動切換,不過,UITabBar 上的 icon 漸變動畫有點麻煩,因為其中的 UITabBarItem 並非 UIView 子類,無法進行動畫。WXTabBarController 這個專案完整地實現了微信介面的滑動互動以及 TabBar 的漸變動畫。不過,它的滑動互動並不是使用轉場的方式完成的,而是使用 UIScrollView,好處是相容性更好。相容性這方面國內的環境比較差,iOS 9 都出來了,可能還需要相容 iOS 6,而自定義轉場需要至少 iOS 7 的系統。該專案實現的 TabBar 漸變動畫是基於 TabBar 的內部結構實時更新相關檢視的 alpha 值來實現的(不是UIView 動畫),這點非常難得,而且使用 UIScrollView 還可以實現自動控制 TabBar 漸變動畫,相比之下,使用轉場的方式來實現這個效果會麻煩一點。

一個較好的轉場方式需要顧及更多方面的細節,NavigationController 的 NavigationBar 和 TabBarController 的 TabBar 這兩者在先天上有著諸多不足需要花費更多的精力去完善,本文就不在這方面深入了,上面提及的幾個開源專案都做得比較好,推薦學習。

互動轉場的限制

如果希望轉場中的動畫能完美地被互動控制,必須滿足2個隱性條件:

  1. 使用 UIView 動畫的 API。你當然也可以使用 Core Animation 來實現動畫,甚至,這種動畫可以被互動控制,但是當互動中止時,會出現一些意外情況:如果你正確地用 Core Animation 的方式復現了 UIView 動畫的效果(不僅僅是動畫,還包括動畫結束後的處理),那麼手勢結束後,動畫將直接跳轉到最終狀態;而更多的一種狀況是,你並沒有正確地復現 UIView 動畫的效果,手勢結束後動畫會停留在手勢中止時的狀態,介面失去響應。所以,如果你需要完美的互動轉場動畫,必須使用 UIView 動畫。
  2. 在動畫控制器的animateTransition:中提交動畫。問題和第1點類似,在viewWillDisappear:這樣的方法中提交的動畫也能被互動控制,但互動停止時,立即跳轉到最終狀態。

如果你希望製作多階段動畫,在某個動畫結束後再執行另外一段動畫,可以通過 UIView Block Animation 的 completion 閉包來實現動畫鏈,或者是通過設定動畫執行的延遲時間使得不同動畫錯分開來,但是互動轉場不支援這兩種形式。UIView 的 keyFrame Animation API 可以幫助你,通過在動畫過程的不同時間節點新增關鍵幀動畫就可以實現多階段動畫。我實現過一個這樣的多階段轉場動畫,Demo 在此:CollectionViewAlbumTransition

插曲:UICollectionViewController 佈局轉場

前面一直沒有提到這種轉場方式,與三大主流轉場不同,佈局轉場只針對 CollectionViewController 搭配 NavigationController 的組合,且是作用於佈局,而非檢視。採用這種佈局轉場時,NavigationController 將會用佈局變化的動畫來替代 push 和 pop 的預設動畫。蘋果自家的照片應用中的「照片」Tab 頁面使用了這個技術:在「年度-精選-時刻」幾個時間模式間切換時,CollectionViewController 在 push 或 pop 時盡力維持在同一個元素的位置同時進行佈局轉換。

佈局轉場的實現比三大主流轉場要簡單得多,只需要滿足四個條件:NavigationController + CollectionViewController, 且要求後者都擁有相同資料來源, 並且開啟useLayoutToLayoutNavigationTransitions屬性為真。

let cvc0 = UICollectionViewController(collectionViewLayout: layout0)
//作為 root VC 的 cvc0 的該屬性必須為 false,該屬性預設為 false。
cvc0.useLayoutToLayoutNavigationTransitions = false
let nav = UINavigationController(rootViewController: cvc0)
//cvc0, cvc1, cvc2 必須具有相同的資料,如果在某個時刻修改了其中的一個數據源,其他的資料來源必須同步,不然會出錯。
let cvc1 = UICollectionViewController(collectionViewLayout: layout1)
cvc1.useLayoutToLayoutNavigationTransitions = true
nav.pushViewController(cvc1, animated: true)

let cvc2 = UICollectionViewController(collectionViewLayout: layout2)
cvc2.useLayoutToLayoutNavigationTransitions = true
nav.pushViewController(cvc2, animated: true)

nav.popViewControllerAnimated(true)
nav.popViewControllerAnimated(true)

Push 進入控制器棧後,不能更改useLayoutToLayoutNavigationTransitions的值,否則應用會崩潰。當 CollectionView 的資料來源(section 和 cell 的數量)不完全一致時,push 和 pop 時依然會有佈局轉場動畫,但是當 pop 回到 rootVC 時,應用會崩潰。可否共享資料來源保持同步來克服這個缺點?測試表明,這樣做可能會造成畫面上的殘缺,以及不穩定,建議不要這麼做。

此外,iOS 7 支援 UICollectionView 佈局的互動轉換(Layout Interactive Transition),過程與控制器的互動轉場(ViewController Interactive Transition)類似,這個功能和佈局轉場(CollectionViewController Layout Transition)容易混淆,前者是在自身佈局轉換的基礎上實現了互動控制,後者是 CollectionViewController 與 NavigationController 結合後在轉場的同時進行佈局轉換。感興趣的話可以看這個功能的文件

進階

是否覺得本文中實現的例子的動畫效果太過簡單?的確很簡單,與 VCTransitionsLibrary 這樣的轉場動畫庫提供的十種動畫效果相比是很簡單的,不過就動畫而言,與本文示例的本質是一樣的,它們都是針對 fromView 和 toView 的整體進行的動畫,但在效果上更加複雜。我在本文中多次強調轉場動畫的本質是是對即將消失的當前檢視和即將出現的下一螢幕的內容進行動畫,「在動畫控制器裡,參與轉場的檢視只有 fromView 和 toView 之分,與轉場方式無關。轉場動畫的最終效果只限制於你的想象力」,當然,還有你的實現能力。

本文前面的目的是幫助你熟悉轉場的整個過程,你也看到了,轉場動畫裡轉場部分的