你好,歡迎來到IOS教程網

 Ios教程網 >> IOS編程開發 >> IOS開發基礎 >> iOS 視圖控制器轉場詳解

iOS 視圖控制器轉場詳解

編輯:IOS開發基礎

1 (8).jpg

本文是投稿文章,作者:seedante(簡書,GitHub)


前言

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

視圖控制器中的視圖顯示在屏幕上有兩種方式:內嵌在容器控制器中,比如 UINavigationController,UITabBarController, UISplitController;由另外一個視圖控制器顯示它,這種方式通常被稱為模態顯示。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 解釋

(二)階段一:非交互轉場

1.動畫控制器協議

2.動畫控制器實現

3.特殊的 Modal 轉場

(1)Modal 轉場的差異

(2)Modal 轉場實踐

(3)iOS 8 的改進:UIPresentationController

4.轉場代理

(三)階段二:交互式轉場

1.實現交互化

2.Transition Coordinator

3.封裝交互控制器

4.交互轉場的限制

(四)插曲:UICollectionViewController 布局轉場

(五)進階

1.案例分析

2.自定義容器控制器轉場

(1)實現分析

(2)協議補完

(3)交互控制

  1)動畫控制和 CAMediaTiming 協議

  2)取消轉場

  3)最後的封裝

(六)尾聲:轉場動畫的設計


(一)Transition 解釋

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

The Anatomy of Transition.png

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

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

  • 在 UINavigationController 中 push 和 pop

  • 在 UITabBarController 中切換 Tab

  • Modal 轉場:presentation 和 dismissal,俗稱視圖控制器的模態顯示和消失,僅限於modalPresentationStyle屬性為 UIModalPresentationFullScreen 或 UIModalPresentationCustom 這兩種模式

  • UICollectionViewController 的布局轉場:UICollectionViewController 與 UINavigationController 結合的轉場方式,實現很簡單。

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

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

1.轉場代理(Transition Delegate):

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

[UINavigationControllerDelegate] //UINavigationController 的 delegate 屬性遵守該協議(因識別問題,這裡用方括號替換尖括號)
[UITabBarControllerDelegate] //UITabBarController 的 delegate 屬性該協議
[UIViewControllerTransitioningDelegate] //UIViewController 的 transitioningDelegate 屬性遵守該協議

這裡除了是 iOS 7 新增的協議,其他兩種在 iOS 2 裡就存在了,在 iOS 7 時擴充了這兩種協議來支持自定義轉場。

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

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

最重要的部分,負責添加視圖以及執行動畫;遵守協議;由我們實現。

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

通過交互手段,通常是手勢來驅動動畫控制器實現的動畫,使得用戶能夠控制整個過程;遵守協議;系統已經打包好現成的類供我們使用。

4.轉場環境(Transition Context):

提供轉場中需要的數據;遵守協議;由 UIKit 在轉場開始前生成並提供給我們提交的動畫控制器和交互控制器使用。

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

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

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


(二)階段一:非交互轉場

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

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

1.動畫控制器協議

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

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

最重要的是第一個方法,該方法接受一個遵守協議的轉場環境對象,上一節的 API 解釋裡提到這個協議,它提供了轉場所需要的重要數據:參與轉場的視圖控制器和轉場過程的狀態信息。

UIKit 在轉場開始前生成遵守轉場環境協議的對象 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)

2.動畫控制器實現

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

SlideAnimation.gif

在交互式轉場章節裡我們將在這個基礎上實現文章開頭提到的兩種效果:NavigationController 右滑返回 和 TabBarController 滑動切換。Modal 轉場並沒有比較合乎操作直覺的交互手段,而且和前面兩種容器控制器的轉場在機制上有些不同,我將為 Modal 轉場示范另外一個示例。

盡管對動畫控制器來說,轉場方式並不重要,可以對 fromView 和 toView 進行任何動畫。但上面的動畫和 Modal 轉場風格上有點不配,動畫的方向不對;在轉場中操作是可逆的,返回操作時的動畫應該也是逆向的。對此,Slide 動畫控制器需要針對轉場的操作類型對動畫的方向進行調整。Swift 中 enum 的關聯值可以視作有限數據類型的集合體,在這種場景下極其合適。設定操作類型:

enum SDETransitionType{
    //UINavigationControllerOperation 是 UIKit 中定義的標記操作類型的枚舉常量,有.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 {
    private var transitionType: SDETransitionType
    
    init(type: SDETransitionTye) {
        transitionType = type
        super.init()
    }
    
    ...
    
    func animateTransition(transitionContext: UIViewControllerContextTransitioning) {
        ...
         //1
        containerView.addSubview(toView)
        
        //計算相應的位移 transform,NavigationVC 和 TabBarVC 在水平方向進行動畫,Modal 轉場在豎直方向進行動畫
        var toViewTransform = ...
        var fromViewTransform = ...
        
        toView.transform = toViewTransform
        let duration = transitionDuration(transitionContext)
        UIView.animateWithDuration(duration, animations: {
            fromView.transform = fromViewTransform
            toView.transform = CGAffineTransformIdentity
            }, completion: { finished in
            //考慮到轉場中途可能取消的情況,轉場結束後,恢復視圖狀態
                fromView.transform = CGAffineTransformIdentity
                toView.transform = CGAffineTransformIdentity
                //2
                let isCancelled = transitionContext.transitionWasCancelled()
                transitionContext.completeTransition(!isCancelled)
        })
    }
}

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

  • 將 toView 添加到容器視圖中,使得 toView 在屏幕上顯示( Modal 轉場中此點稍有不同,下一節細述);

  • 正確地結束轉場過程。轉場的結果有兩種:完成或取消。非交互轉場的結果只有完成一種情況,不過交互式轉場需要考慮取消的情況。如何結束取決於轉場的進度,通過transitionWasCancelled()方法來獲取轉場的狀態,使用completeTransition:來完成或取消轉場。

轉場結束後,fromView 會從視圖結構中移除,UIKit 自動替我們做了這事,你也可以手動處理提前將 fromView 移除,這完全取決於你的需求。UIView的類方法transitionFromView:toView:duration:options:completion:也能做同樣的事,我們甚至不需要獲得 containerView 以及手動將 toView 添加到視圖結構中就能實現一個轉場動畫:

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

3.特殊的 Modal 轉場

(1)Modal 轉場的差異

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

ContainerVC VS Modal.png

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 轉場中:

  • UIModalPresentationFullScreen 模式:presentation 後,presentingView 被主動移出視圖結構,在 dismissal 中 presentingView 是 toView 的角色,其將會重新加入 containerView 中,實際上,我們不主動將其加入,UIKit 也會這麼做,前面的兩種容器控制器的轉場裡不是這樣處理的,不過這個差異基本沒什麼影響。

  • 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 為協議添加了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 模式有點不兼容。

(2)Modal 轉場實踐

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

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

ModalTransition.gif

由於需要保持 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(){
            //計算 presentedView 和 dimmingView 的初始位置和尺寸
            let toViewWidth = containerView.frame.width * 2 / 3, toViewHeight = containerView.frame.height * 2 / 3

            //1
            containerView.addSubview(toView)
            toView.center = containerView.center
            toView.bounds = CGRect(x: 0, y: 0, width: 1, height: toViewHeight)

            let dimmingView = UIView()
            //在 presentedView 後面添加背景視圖 dimmingView,注意兩者在 containerView 中的位置
            containerView.insertSubview(dimmingView, belowSubview: toView)
            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)
            })
        }
        //處理 Dismiss 轉場,注意,按照上一小節的結論,.Custom 模式下的 Dismiss 裡不要將 toView 添加到 containerView
        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)
            })
        }
    }
}

(3)iOS 8的改進:UIPresentationController

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

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

  • 定制 presentedView 的外觀:設定 presentedView 的尺寸以及在 containerView 中添加自定義視圖並為這些視圖添加動畫;

  • iOS 8 中的適應性布局

  • 可以在不需要動畫控制器的情況下單獨工作

  • 可以選擇是否移除 presentingView

UIPresentationController類帶來的定制外觀功能在 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。

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

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

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

class OverlayPresentationController: UIPresentationController {
    let dimmingView = UIView()
    
    //presentation 轉場開始前將 dimmingView 添加到 containerView,同時添加尺寸變化的動畫
    override func presentationTransitionWillBegin() {
        containerView?.addSubview(dimmingView)
        let dimmingViewInitailWidth = containerView!.frame.width * 2 / 3, dimmingViewInitailHeight = containerView!.frame.height * 2 / 3
        dimmingView.backgroundColor = UIColor(white: 0.0, alpha: 0.5)
        dimmingView.center = containerView!.center
        dimmingView.bounds = CGRect(x: 0, y: 0, width: dimmingViewInitailWidth , height: dimmingViewInitailHeight)
        //使用 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裡處理 presentation 轉場的部分就需要修改一下,把 dimmingView 的部分刪除:

if toVC.isBeingPresented(){
    containerView.addSubview(toView)    
    toView.center = ...
    toView.bounds = ...

    UIView.animateWithDuration(duration, delay: 0, options: .CurveEaseInOut, animations: {
        toView.bounds = CGRect(x: 0, y: 0, width: toViewWidth, height: toViewHeight)
        }, completion: {_ in
            let isCancelled = transitionContext.transitionWasCancelled()
            transitionContext.completeTransition(!isCancelled)
    })
}

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

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

然後在 presentedVC 的轉場代理屬性transitioningDelegate中提供OverlayPresentationController對象就可以達到上一節裡的效果。

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

除了能與動畫控制器配合,UIPresentationController類也能脫離動畫控制器獨立工作,在轉場代理裡我們僅僅提供後者也能對 presentedView 的外觀進行定制,缺點是無法控制 presentedView 的轉場動畫,因為這是動畫控制器的職責,這種情況下,presentedView 的轉場動畫采用的是默認的動畫效果,轉場協調器實現的動畫則是采用默認的動畫時間。

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

func shouldRemovePresentersView() -> Bool

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

4.轉場代理

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

UINavigationControllerDelegate

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

class SDENavigationControllerDelegate: NSObject, UINavigationControllerDelegate {
    //在對象裡,實現該方法提供動畫控制器,返回 nil 則使用系統默認的效果
    func navigationController(navigationController: UINavigationController, animationControllerForOperation operation: UINavigationControllerOperation, fromViewController fromVC: UIViewController, toViewController toVC: UIViewController) -> UIViewControllerAnimatedTransitioning? {
        //使用上一節實現的 Slide 動畫控制器,需要提供操作類型信息
        let transitionType = SDETransitionTye.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)
self.navigationController?.popViewControllerAnimated(true)

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

UITabBarControllerDelegate

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

class SDETabBarControllerDelegate: NSObject, 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 = SDETransitionTye.TabTransition(tabChangeDirection)
        let slideAnimationController = SlideAnimationController(type: transitionType)
        return slideAnimationController
    }
}

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

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

Demo 地址:ScrollTabBarController。

UIViewControllerTransitioningDelegate

Modal 轉場的代理協議是 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
presentedVC.modalPresentationStyle = .Custom/.FullScreen //與 UIPresentationController 配合時該值必須為.Custom
presentingVC.presentViewController(presentedVC, animated: true, completion: nil)

Demo 地址:CustomModalTransition。


階段二:交互式轉場

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

1.實現交互化

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

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

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

以上兩個條件缺一不可,這使得實現交互轉場時容易犯錯。

正確地提供交互控制器:

如果在轉場代理中提供了交互控制器,而轉場發生時並沒有方法來驅動轉場進程(比如手勢),轉場過程將一直處於開始階段無法結束,應用界面也會失去響應:在 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 提供了各自的交互控制器,也需要注意上面的問題。

問題的根源是交互控制的工作機制導致的,交互過程實際上是由轉場環境對象來管理的,它提供了如下幾個方法來控制轉場的進度:

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

交互控制協議只有一個必須實現的方法:

func startInteractiveTransition(_ transitionContext: UIViewControllerContextTransitioning)

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

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

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

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

交互手段的配合:

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

func handleEdgePanGesture(gesture: UIScreenEdgePanGestureRecognizer){
    let translationX =  gesture.translationInView(view).x
    let translationBase: CGFloat = view.frame.width / 3
    let translationAbs = translationX > 0 ? translationX : -translationX
    let percent = translationAbs > translationBase ? 1.0 : translationAbs / translationBase
    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 頁面,代碼是類似的,就不占篇幅了。

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

一種情況是,控制器的系統屬性,比如,在 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)再次出場了。

2.Transition Coordinator

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

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

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

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 中的適應性布局中擔任重要角色,可以查看協議中的方法,其中響應尺寸和屏幕旋轉事件的方法都包含一個轉場協調器對象,視圖的這種變化也被系統視為廣義上的 transition,參數中的轉場協調器也由 UIKit 提供。這個話題有點超出本文的范圍,就不深入了,有需要的話可以查看文檔和相關 session。

3.封裝交互控制器

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 這兩者在先天上有著諸多不足需要花費更多的精力去完善,本文就不在這方面深入了,上面提及的幾個開源項目都做得比較好,推薦學習。

4.交互轉場的限制

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

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

  • 在動畫控制器的animateTransition:中提交動畫。問題和第1點類似,在viewWillDisappear:這樣的方法中提交的動畫也能被交互控制,但交互停止時,立即跳轉到最終狀態。

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

class func animateKeyframesWithDuration(_ duration: NSTimeInterval, delay delay: NSTimeInterval, options options: UIViewKeyframeAnimationOptions, animations animations: () -> Void, completion completion: ((Bool) -> Void)?)
class func addKeyframeWithRelativeStartTime(_ frameStartTime: Double, relativeDuration frameDuration: Double, animations animations: () -> Void)

我實現過一個這樣的多階段轉場動畫,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 時,應用會崩潰。可否共享數據源保持同步來克服這個缺點?測試表明,這樣做可能會造成畫面上的殘缺,以及不穩定。建議不要這麼做。

布局轉場不支持交互控制。Demo 地址:CollectionViewControllerLayoutTransition

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


(五)進階

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

本文前面的目的是幫助你熟悉轉場的整個過程,你也看到了,轉場動畫其實很簡單。那學習了前面的內容能夠立馬實現 Github 上那些熱門的轉場動畫嗎?不能。因為那些轉場動畫在轉場這堆料裡還加了些佐料,正是這些佐料才讓它們成為熱門,而且大部分涉及視圖動畫的其他方面,與轉場本身關系不大,但它們與轉場結合後就有了神奇的力量。那學習了作為進階的本章能立馬實現那些熱門的轉場效果嗎?有可能,有些效果其實很簡單,一點就透,還有一些效果涉及的技術屬於本文主題之外的內容,我會給出相關的提示就不深入了。

本章的進階分為兩個部分:

  • 案例分析:動畫的方式非常多,有些並不常見,有些只是簡單到令人驚訝的組合,只是你不曾了解過所以不知道如何實現,一旦了解了就不再是難事。盡管這些動畫本身並不屬於轉場技術這個主題,但與轉場動畫組合後往往有著驚艷的視覺效果,這部分將提供一些實現此類轉場動畫的思路,技巧和工具來擴展視野。

  • 自定義容器轉場:官方支持四種方式的轉場,而且這些也足以應付絕大多數需求了,但依然有些地方無法顧及。本文一直通過探索轉場的邊界的方式來總結使用方法以及陷阱,在本文的壓軸部分,我們將掙脫系統的束縛來實現自定義容器控制器的轉場效果。

1.案例分析

動畫的持續時間一般不超過0.5秒,稍縱即逝,有時候看到一個復雜的轉場動畫也不容易知道實現的方式,我一般是通過逐幀解析的手法來分析實現的手段:開源的就運行一下,使用系統自帶的 QuickPlayer 對 iOS 設備進行錄屏,再使用 QuickPlayer 打開視頻,按下 cmd+T 打開剪輯功能,這時候就能查看每一幀了;Gif 等格式的原型動畫的動圖就直接使用系統自帶的 Preview 打開看中間幀。

子元素動畫

當轉場動畫涉及視圖中的子視圖時,往往無法依賴第三方的動畫庫來實現,你必須為這種效果單獨定制,神奇移動就是一個典型的例子。神奇移動是 Keynote 中的一個動畫效果,如果某個元素在連續的兩頁 Keynote 同時存在,在頁面切換時,該元素從上一頁的位置移動到下一頁的位置,非常神奇。在轉場中怎麼實現這個效果呢?最簡單的方法是截圖配合移動動畫:偽造那個元素的視圖添加到 containerView 中,從 fromView 中的位置移動到 toView 中的位置,這期間 fromView 和 toView 中的該元素視圖隱藏,等到移動結束恢復 toView 中該元素的顯示,並將偽造的元素視圖從 containerView 中移除。

UIView 有幾個convert方法用於在不同的視圖之間轉換坐標:

func convertPoint(_ point: CGPoint, toView view: UIView?) -> CGPoint
func convertPoint(_ point: CGPoint, fromView view: UIView?) -> CGPoint
func convertPoint(_ point: CGPoint, fromView view: UIView?) -> CGPoint
func convertPoint(_ point: CGPoint, fromView view: UIView?) -> CGPoint

對截圖這個需求,iOS 7 提供了趁手的工具,UIView Snapshot API:

func snapshotViewAfterScreenUpdates(_ afterUpdates: Bool) -> UIView
//獲取視圖的部分內容
func resizableSnapshotViewFromRect(_ rect: CGRect, afterScreenUpdates afterUpdates: Bool, withCapInsets capInsets: UIEdgeInsets) -> UIView

當afterScreenUpdates參數值為true時,這兩個方法能夠強制視圖立刻更新內容,同時返回更新後的視圖內容。在 push 或 presentation 中,如果 toVC 是 CollectionViewController 並且需要對 visibleCells 進行動畫,此時動畫控制器裡是無法獲取到的,因為此時 collectionView 還未向數據源詢問內容,執行此方法後能夠達成目的。UIView 的layoutIfNeeded()也能要求立即刷新布局達到同樣的效果。

Mask 動畫

MaskAnimtion.gif

左邊的動畫教程:How To Make A View Controller Transition Animation Like in the Ping App;右邊動畫的開源地址:BubbleTransition

Mask 動畫往往在視覺上令人印象深刻,這種動畫通過使用一種特定形狀的圖形作為 mask 截取當前視圖內容,使得當前視圖只表現出 mask 圖形部分的內容,在 PS 界俗稱「遮罩」。UIView 有個屬性maskView可以用來遮擋部分內容,但這裡的效果並不是對maskView的利用;CALayer 有個對應的屬性mask,而 CAShapeLayer 這個子類搭配 UIBezierPath 類可以實現各種不規則圖形。這種動畫一般就是 mask + CAShapeLayer + UIBezierPath 的組合拳搞定的,實際上實現這種圓形的形變是很簡單的,只要發揮你的想象力,可以實現任何形狀的形變動畫。

這類轉場動畫在轉場過程中對 toView 使用 mask 動畫,不過,右邊的這個動畫實際上並不是上面的組合來完成的,它的真相是這樣:

Truth behind BubbleTransition.gif

這個開發者實在是太天才了,這個手法本身就是對 mask 概念的應用,效果卓越,但方法卻簡單到難以置信。關於使用 mask + CAShapeLayer + UIBezierPath 這種方法實現 mask 動畫的方法請看我的這篇文章。

高性能動畫框架

有些動畫使用 UIView 的動畫 API 難以實現,或者難以達到較好的性能,又或者兩者皆有,幸好我們還有其他選擇。StartWar 使用更底層的 OpenGL 框架來解決性能問題以及 Objc.io 在探討轉場這個話題時使用 GPUImage 定制動畫都是這類的典范。在交互控制器章節中提到過,官方只能對 UIView 動畫 API 實現的轉場動畫實施完美的交互控制,這也不是絕對的,接下來我們就來挑戰這個難題。

2.自定義容器控制器轉場

壓軸環節我們將實現這樣一個效果:

31.gif

Demo 地址:CustomContainerVCTransition

分析一下思路,這個控制器和 UITabBarController 在行為上比較相似,只是 TabBar 由下面跑到了上面。我們可以使用 UITabBarController 子類,然後打造一個偽 TabBar 放在頂部,原來的 TabBar 則隱藏,行為上完全一致,使用 UITabBarController 子類的好處是可以減輕實現轉場的負擔,不過,有時候這樣的子類不是你想要的,UIViewController 子類能夠提供更多的自由度,好吧,一個完全模仿 UITabBarController 行為的 UIViewController 子類,實際上我沒有想到非得這樣做的原因,但我想肯定有需要定制自己的容器控制器的場景,這正是本節要探討的。Objc.io 也討論過這個話題,文章的末尾把實現交互控制當做作業留了下來。珠玉在前,我就站在大牛的肩上繼續這個話題吧。Objc.io 的這篇文章寫得較早使用了 Objective-C 語言,如果要讀者先去讀這篇文章再繼續讀本節的內容,難免割裂,所以本節還是從頭討論這個話題吧,最終效果如上面所示,在自定義的容器控制器中實現交互控制切換子視圖,也可以通過填充了 UIButton 的 ButtonTabBar 來實現 TabBar 一樣行為的 Tab 切換,在通過手勢切換頁面時 ButtonTabBar 會實現漸變色動畫。ButtonTabBar 有很大擴展性,改造一下還是有很多應用場景的。

(1)實現分析

既然這個自定義容器控制器和 UITabBarController 行為類似,我便實現了一套類似的 API:viewControllers數組是容器 VC 維護的子 VC 數組,初始化時提供要顯示的子 VC,更改selectedIndex的值便可跳轉到對應的子視圖。利用 Swift 的屬性觀察器實現修改selectedIndex時自動執行子控制器轉場。下面是實現子 VC 轉場的核心代碼,轉場結束後遵循系統的慣例將 fromView 移除:

class SDEContainerViewController: UIViewController{
    ...
    //發生轉場的容器視圖,是 root view 的子視圖
    private let privateContainerView = UIView()
    var selectedIndex: Int = NSNotFound{
        willSet{
            transitionViewControllerFromIndex(selectedIndex, toIndex: newValue)
        }
    }
    //實現 selectedVC 轉場
    private func transitionViewControllerFromIndex(fromIndex: Int, toIndex: Int){
        //添加 toVC 和 toView
        let newSelectedVC = viewControllers![toIndex]
        self.addChildViewController(newSelectedVC)
        privateContainerView.addSubview(newSelectedVC.view)
        newSelectedVC.didMoveToParentViewController(self)
        
        UIView.animateWithDuration(transitionDuration, animations: {
            /*轉場動畫*/
            }, completion: { finished in
                //移除 fromVC 和 fromView
                let priorSelectedVC = viewControllers![fromIndex]
                priorSelectedVC.willMoveToParentViewController(nil)
                priorSelectedVC.view.removeFromSuperview()
                priorSelectedVC.removeFromParentViewController()
        })
    }
}

與 UIView 類似,UIViewController 提供了以下方法來實現子控制器的轉場並預置了一些動畫:

transitionFromViewController:toViewController:duration:options:animations:completion:

實現轉場動畫就是這麼十幾行代碼而已,其他容器 VC 轉場過程做了類似的事情。回憶下我們在動畫控制器裡做的事情,實際上只是上面代碼中的一部分。轉場協議這套 API 將這個過程分割為五個組件,這套復雜的結構帶來了可高度自定義的動畫效果和交互控制。我們溫習下轉場協議,來看看如何在既有的轉場協議框架下實現自定義容器控制器的轉場動畫以及交互控制:

  • 轉場代理:既有的轉場代理協議並沒有直接支持我們這種轉場方式,沒關系,我們自定義一套代理協議來提供動畫控制器和交互控制器;

  • 動畫控制器:動畫控制器是可復用的,這裡采用動畫控制器章節封裝的 Slide 動畫控制器,可以拿來直接使用而不用修改;

  • 交互控制器:官方封裝了一個現成的交互控制器類,但這個類是與 UIKit 提供的轉場環境對象配合使用的,而這裡的轉場顯然需要我們來提供轉場環境對象,因此UIPercentDrivenInteractiveTransition無法在這裡使用,需要我們來實現這個協議;

  • 轉場環境:在官方支持的轉場方式中,轉場環境是由 UIKit 主動提供給我們的,既然現在的轉場方式不是官方支持的,顯然需要我們自己提供這個對象以供動畫控制器和交互控制器使用;

  • 轉場協調器:在前面的章節中我提到過,轉場協調器(Transition Coordinator)的使用場景有限而關鍵,也是由系統提供,我們也可以重寫相關方法來提供。這個部分我留給讀者當作是本文的一道作業吧。

下面我們來將上面的十幾行代碼(不包括實際的動畫代碼)使用協議封裝成本文前半部分裡熟悉的樣子。

(2)協議補完

模仿 UITabBarControllerDelegate 協議的 ContainerViewControllerDelegate 協議:

@objc protocol ContainerViewControllerDelegate{
    func containerController(containerController: SDEContainerViewController, animationControllerForTransitionFromViewController fromVC: UIViewController, toViewController toVC: UIViewController) -> UIViewControllerAnimatedTransitioning?
    optional func containerController(containerController: SDEContainerViewController, interactionControllerForAnimation animationController: UIViewControllerAnimatedTransitioning) -> UIViewControllerInteractiveTransitioning?
}

在容器控制器SDEContainerViewController類中,添加轉場代理屬性:

weak var containerTransitionDelegate: ContainerViewControllerDelegate?

代理的定位就是提供動畫控制器和交互控制器,系統打包的UIPercentDrivenInteractiveTransition類只是調用了轉場環境對象的對應方法而已,執行navigationController.pushViewController(toVC, animated: true)這類語句觸發轉場後 UIKit 就接管了剩下的事情,再綜合文檔的描述,可知轉場環境便是實現這一切的核心。

在文章前面的部分裡轉場環境對象的作用只是提供涉及轉場過程的信息和狀態,現在需要我們實現該協議,並且實現隱藏的那部分職責。協議裡的絕大部分方法都是必須實現的,不過現在我們先實現非交互轉場的部分,實現這個是很簡單的,主要是調用動畫控制器執行轉場動畫。在「實現分析」一節裡我們看到實現轉場的代碼只有十幾行而已,動畫控制器需要做的只是處理視圖和動畫的部分,轉場環境對象則要負責管理子 VC,通過SDEContainerViewController提供 containerView 以及 fromVC 和 toVC,實現並不是難事。顯然由我們實現的自定義容器 VC 來提供轉場環境對象是最合適的,並且轉場環境對象應該是私有的,其初始化方法極其啟動轉場的方法如下:

class ContainerTransitionContext: NSObject, UIViewControllerContextTransitioning{
    init(containerViewController: SDEContainerViewController, containerView: UIView, fromViewController fromVC: UIViewController, toViewController toVC: UIViewController){...}
    //非協議方法,是啟動非交互式轉場的便捷方法
    func startNonInteractiveTransitionWith(delegate: ContainerViewControllerDelegate){
        animationController = delegate.containerController(privateContainerViewController, animationControllerForTransitionFromViewController: privateFromViewController, toViewController: privateToViewController)
        //開始轉場前添加 toVC,轉場動畫結束後會調用 completeTransition: 方法,在該方法裡完成後續的操作
        privateContainerViewController.addChildViewController(privateToViewController)
        animationController.animateTransition(self)
    }
    //協議方法,動畫控制器在動畫結束後調用該方法,完成管理子 VC 的後續操作,並且考慮交互式轉場可能取消的情況撤銷添加的子 VC
    func completeTransition(didComplete: Bool) {
        if didComplete{
            //轉場完成,完成添加 toVC 的工作,並且移除 fromVC 和 fromView
            privateToViewController.didMoveToParentViewController(privateContainerViewController)
            privateFromViewController.willMoveToParentViewController(nil)
            privateFromViewController.view.removeFromSuperview()
            privateFromViewController.removeFromParentViewController()
        }else{
            //轉場取消,移除 toVC 和 toView
            privateToViewController.didMoveToParentViewController(privateContainerViewController)
            privateToViewController.willMoveToParentViewController(nil)
            privateToViewController.view.removeFromSuperview()
            privateToViewController.removeFromParentViewController()
        }
        //非協議方法,處理收尾工作:如果動畫控制器實現了 animationEnded: 方法則執行;如果轉場取消了則恢復數據。
        transitionEnd()
    }
}

在SDEContainerViewController類中,添加轉場環境屬性:

private var containerTransitionContext: ContainerTransitionContext?

並修改transitionViewControllerFromIndex:toIndex方法實現自定義容器 VC 轉場動畫:

func transitionViewControllerFromIndex(fromIndex: Int, toIndex: Int){
    if containerTransitionDelegate != nil{
        let fromVC = viewControllers![fromIndex]
        let toVC = viewControllers![toIndex]
        containerTransitionContext = ContainerTransitionContext(containerViewController: self, containerView: privateContainerView, fromViewController: fromVC, toViewController: toVC)
        containerTransitionContext?.startNonInteractiveTransitionWith(containerTransitionDelegate!)
    }else{/*沒有提供轉場代理的話,則使用最初沒有動畫的轉場代碼,或者提供默認的轉場動畫*/}
}

這樣我們就利用協議實現了自定義容器控制器的轉場動畫,可以使用第三方的動畫控制器來實現不同的效果。

不過要注意這幾個對象之間錯綜復雜的引用關系避免引用循環,關系圖如下:

Reference in Transition.png

(3)交互控制

交互控制器的協議僅僅要求實現一個必須的方法:

func startInteractiveTransition(_ transitionContext: UIViewControllerContextTransitioning)

根據文檔的描述,該方法用於配置以及啟動交互轉場。我們前面使用的UIPercentDrivenInteractiveTransition類提供的更新進度的方法只是調用了轉場環境對象的相關方法。所以,是轉場環境對象替交互控制器把髒活累活干了,我們的實現還是維持這種關系好了。正如前面說的,「交互手段只是表現形式,本質是驅動轉場進程」,讓我們回到轉場環境對象裡實現對動畫進度的控制吧。

怎麼控制動畫的進度?這個問題的本質是怎麼實現對 UIView 的 animateWithDuration:animations:completion:這類方法生成的動畫的控制。能夠控制嗎?能。

1)動畫控制和 CAMediaTiming 協議

這個協議定義了一套時間系統,是控制動畫進度的關鍵。UIView Animation 是使用 Core Animation 框架實現的,也就是使用 UIView 的 CALayer 對象實現的動畫,而 CALayer 對象遵守該協議。

在交互控制器的小節裡我打了一個比方,交互控制器就像一個視頻播放器一樣控制著轉場動畫這個視頻的進度。依靠 CAMediaTiming 這套協議,我們可以在 CALayer 對象上對添加的動畫實現控制。官方的實現很有可能也是采用了同樣的手法。CAMediaTiming 協議中有以下幾個屬性:

var speed: Float //作用類似於播放器上控制加速/減速播放,默認為1,以正常速度播放動畫,為0時,動畫將暫停
//修改該屬性類似於拖動進度條,2秒的動畫,timeOffset 為1的話,動畫將跳到中間的部分。
//但當動畫從中間播放到預定的末尾時,會續上0秒到1秒的動畫部分。
var timeOffset: CFTimeInterval 
var beginTime: CFTimeInterval  //動畫相對於父 layer 延遲開始的時間,這是一個實際作用比字面意義復雜的屬性

Core Animation 的文檔中提供了如何暫停和恢復動畫的示例:How to pause the animation of a layer tree。我們將之利用實現對進度的控制,這種方法對其中的子視圖上添加的動畫也能夠實現控制,這正是我們需要的。假設在 containerView 中的 toView 上執行一個簡單的沿著 X 軸方向移動 100 單位的位移動畫,由executeAnimation()方法執行。下面是使用手勢控制該動畫進度的核心代碼:

func handlePan(gesture: UIPanGestureRecognizer){
    switch gesture.state{
    case .Began:
        //開始動畫前將 speed 設為0,然後執行動畫,動畫將停留在開始的時候
        containerView.layer.speed = 0
        executeAnimation() //在transitionContext裡,這裡替換為 animator.animateTransition(transitionContext)
    case .Changed:
        let percent = ...
        //此時 speed 依然為0,調整 timeOffset 可以直接調整動畫的整體進度,這裡的進度控制以時間計算,而不是比例
        containerView.layer.timeOffset = percent * duration
    case .Ended, .Cancelled:
        if progress > 0.5{
            //恢復動畫的運行不能簡單地僅僅將 speed 恢復為1,這是一套比較復雜的機制。
            let pausedTime = view.layer.timeOffset
            containerView.layer.speed = 1.0 
            containerView.layer.timeOffset = 0.0
            containerView.layer.beginTime = 0.0
            let timeSincePause = view.layer.convertTime(CACurrentMediaTime(), fromLayer: nil) - pausedTime
            containerView.layer.beginTime = timeSincePause
        }else{/*逆轉動畫*/}
        default:break
    }
}

2)取消轉場

交互控制動畫時有可能被取消,這往往帶來兩個問題:恢復數據和逆轉動畫。

這裡需要恢復的數據是selectedIndex,我們在交互轉場開始前備份當前的selectedIndex,如果轉場取消了就使用這個備份數據恢復。逆轉動畫反而看起來比較難以解決。

在上面的 pan 手勢處理方法中,我們如何逆轉動畫的運行呢?既然speed為0時動畫靜止不動,調整為負數是否可以實現逆播放呢?不能,效果是視圖消失不見。不過我們還可以調整timeOffset屬性,從當前值一直恢復到0。問題是如何產生動畫的效果?動畫的本質是視圖屬性在某段時間內的連續變化,當然這個連續變化並不是絕對的連續,只要時間間隔夠短,變化的效果就會流暢得看上去是連續變化,在這裡讓這個變化頻率和屏幕的刷新同步即可,CADisplayLink可以幫助我們實現這點,它可以在屏幕刷新時的每一幀執行綁定的方法:

//在上面的/*逆轉動畫*/處添加以下兩行代碼
let displayLink = CADisplayLink(target: self, selector: "reverseAnimation:")
displayLink.addToRunLoop(NSRunLoop.mainRunLoop(), forMode: NSDefaultRunLoopMode)
func reverseAnimation(displayLink: CADisplayLink){
    //displayLink.duration表示每一幀的持續時間,屏幕的刷新頻率為60,duration = 1/60
    //這行代碼計算的是,屏幕刷新一幀後,timeOffset 應該回退一幀的時間。
    let timeOffset = view.layer.timeOffset - displayLink.duration
    if timeOffset > 0{
        containerView.layer.timeOffset = timeOffset
    }else{
        //讓 displayLink 失效,停止對當前方法的調用
        displayLink.invalidate()
        //回到最初的狀態
        containerView.layer.timeOffset = 0
        //speed 恢復為1後,視圖立刻跳轉到動畫的最終狀態
        containerView.layer.speed = 1
    }
}

最後一句代碼會令人疑惑,為何讓視圖恢復為最終狀態,與我們的初衷相悖。speed必須恢復為1,不然後續發起的轉場動畫無法順利執行,視圖也無法響應觸摸事件,直接原因未知。但speed恢復為1後會出現一個問題:由於在原來的動畫裡 fromView 最終會被移出屏幕,盡管 Slide 動畫控制器 UIView 動畫裡的 completion handle 裡會恢復 fromView 和 toView 的狀態,這種狀態的突變會造成閃屏現象。怎麼解決?添加一個假的 fromView 到 containerView替代已經被移出屏幕外的真正的 fromView,然後在很短的時間間隔後將之移除,因為此時 fromView 已經歸位。在恢復speed後添加以下代碼:

let fakeFromView = privateFromViewController.view.snapshotViewAfterScreenUpdates(false)
containerView.addSubview(fakeFromView)
performSelector("removeFakeFromView:", withObject: fakeFromView, afterDelay: 1/60)
//在 Swift 中動態調用私有方法會出現無法識別的選擇器錯誤,解決辦法是將私有方法設置為與 objc 兼容,通過添加 @objc 關鍵字實現
@objc private func removeFakeFromView(fakeView: UIView){
    fakeView.removeFromSuperview()
}

經過試驗,上面用來控制和取消 UIView 動畫的方法也適用於用 Core Animation 實現的動畫,畢竟 UIView 動畫是用 Core Animation 實現的。不過,我們在前面提到過,官方對 Core Animation 實現的交互轉場動畫的支持有缺陷,估計官方鼓勵使用更高級的接口吧,因為轉場動畫結束後需要調用transitionContext.completeTransition(!isCancelled),而使用 Core Animation 完成這一步需要進行恰當的配置,實現的途徑有兩種且實現並不簡單,相比之下 UIView 動畫使用 completion block 對此進行了封裝,使用非常方便。轉場協議的結構已經比較復雜了,選擇 UIView 動畫能夠顯著降低實現成本。

上面的實現忽略了一個細節:時間曲線。逆轉動畫時每一幀都回退相同的時間,也就是說,逆轉動畫的時間曲線是線性的。交互控制器的協議還有兩個可選方法:

optional func completionCurve() -> UIViewAnimationCurve
optional func completionSpeed() -> CGFloat

這兩個方法記錄了動畫采用的動畫曲線和速度,在逆轉動畫時如果能夠根據這兩者計算出當前幀應該回退的時間,那麼就能實現完美的逆轉,顯然這是一個數學問題。恩,我們跳過這個細節吧,因為我數學不好,討論這個問題很吃力。推薦閱讀 Objc.io 的交互式動畫一文,該文探討了如何打造自然真實的交互式動畫。

3)最後的封裝

接下來要做的事情就是將上述代碼封裝在轉場環境協議要求實現的三個方法裡:

func updateInteractiveTransition(percentComplete: CGFloat)
func finishInteractiveTransition()
func cancelInteractiveTransition()

正如系統打包的UIPercentDrivenInteractiveTransition類只是調用了 UIKit 提供的轉場環境對象裡的同名方法,我實現的SDEPercentDrivenInteractiveTransition類也采用了同樣的方式調用我們實現的ContainerTransitionContext類的同名方法。

引入交互控制器後的轉場引用關系圖:

QQ截圖20160308142619.png

回到SDEContainerViewController類裡修改轉場過程的入口處:

private func transitionViewControllerFromIndex(fromIndex: Int, toIndex: Int){
    ...
    if containerTransitionDelegate != nil{
        let fromVC = viewControllers![fromIndex]
        let toVC = viewControllers![toIndex]
        containerTransitionContext = ContainerTransitionContext(containerViewController: self, containerView: privateContainerView, fromViewController: fromVC, toViewController: toVC)
        //interactive 屬性標記是否進入交互狀態,由手勢來更新該屬性的狀態
        if interactive{
            priorSelectedIndex = fromIndex //備份數據,以備取消轉場時使用
            containerTransitionContext?.startInteractiveTranstionWith(containerTransitionDelegate!)
        }else{
            containerTransitionContext?.startNonInteractiveTransitionWith(containerTransitionDelegate!)
        }
    }else{/*沒有提供轉場代理的話,則使用最初沒有動畫的轉場代碼,或者提供默認的轉場動畫*/}
}

實現手勢控制的部分就如前面的交互控制器章節裡的那樣,完整的代碼請看 Demo。

順便說下 ButtonTabButton 在交互切換頁面時的漸變色動畫,這裡我只是隨著轉場的進度更改了 Button 的字體顏色而已。那麼當交互結束時如何繼續剩下的動畫或者取消漸變色動畫呢,就像交互轉場動畫的那樣。答案是CADidplayLink,前面我使用它在交互取消時逆轉動畫,這裡使用了同樣的手法。

關於轉場協調器,文檔表明在轉場發生時transitionCoordinator()返回一個有效對象,但系統並不支持當前的轉場方式,測試表明在當前的轉場過程中這個方法返回的是 nil,需要重寫該方法來提供。該對象只需要實現前面提到三個方法,其中在交互中止時執行綁定的閉包的方法可以通過通知機制來實現,有點困難的是兩個與動畫控制器同步執行動畫的方法,其需要精准地與動畫控制器中的動畫保持同步,這兩個方法都要接受一個遵守協議的參數,該協議與轉場環境協議非常相似,這個對象可以由我們實現的轉場環境對象來提供。不過既然現在由我們實現了轉場環境對象,也就知道了執行動畫的時機,提交並行的動畫似乎並不是難事。這部分就留給讀者來挑戰了。


(六)尾聲:轉場動畫的設計

雖然我不是設計師,但還是想在結束之前聊一聊我對轉場動畫設計的看法。動畫的使用無疑能夠提升應用的體驗,但僅限於使用了合適的動畫。

除了一些加載動畫可以炫酷華麗極盡炫技之能事,絕大部分的日常操作並不適合使用過於炫酷或復雜的動畫,比如 VCTransitionsLibrary 這個庫裡的大部分效果。該庫提供了多達10種轉場效果,從技術上講,大部分效果都是針對 transform 進行動畫,如果你對這些感興趣或是恰好有這方面的使用需求,可以學習這些效果的實現,從代碼角度看,封裝技巧也很值得學習,這個庫是學習轉場動畫的極佳范例;不過從使用效果上看,這個庫提供的效果像 PPT 裡提供的動畫效果一樣,絕大部分都應該避免在日常操作中使用。不過作為開發者,我們應該知道技術實現的手段,即使這些效果並不適合在絕大部分場景中使用。

場景轉換的目的是過渡到下一個場景,在操作頻繁的日常場景中使用復雜的過場動畫容易造成視覺疲勞,這種情景下使用簡單的動畫即可,實現起來非常簡單,更多的工作往往是怎麼把它們與其他特性更好地結合起來,正如 FDFullscreenPopGesture 做的那樣。除了日常操作,也會遇到一些特殊的場景需要定制復雜的動畫,這需要對轉場過程十分熟悉,甚至還需要其他的動畫知識。從這點來看,轉場動畫在實際使用中走向兩個極端:日常場景中的轉場動畫十分簡單,特定場景的動畫可能非常復雜。比如 StarWars,這個轉場動畫有幾處在視覺上極其驚艷,一出場便獲得上千星星的青睐;而在我看來,這個效果的設計是非常切合場景的需求的,它做到了貼合星戰內涵的創意設計和驚艷的視覺表現,以及優秀的性能優化更是讓人佩服,如果要評選年度轉場動畫甚至是史上最佳,我會投票給它。

希望本文能幫助你。

  1. 上一頁:
  2. 下一頁:
蘋果刷機越獄教程| IOS教程問題解答| IOS技巧綜合| IOS7技巧| IOS8教程
Copyright © Ios教程網 All Rights Reserved