屏幕左邊緣右滑返回,TabBar 滑動切換,你是否喜歡並十分依賴這兩個操作,甚至覺得 App 不支持這類操作的話簡直反人類?這兩個操作在大屏時代極大提升了操作效率,其背後的技術便是今天的主題:視圖控制器轉換(View Controller Transition)。 視圖控制器中的視圖顯示在屏幕上有兩
前言
屏幕左邊緣右滑返回,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 整理的,解釋了轉場時視圖控制器和其對應的視圖在結構上的變化:
轉場過程中,作為容器的父 VC 維護著多個子 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 屬性遵守該協議。
這裡除了<UIViewControllerTransitioningDelegate>
是 iOS 7 新增的協議,其他兩種在 iOS 2 里就存在了,在 iOS 7 時擴充了這兩種協議來支持自定義轉場。
轉場發生時,UIKit 將要求轉場代理將提供轉場動畫的核心構件:動畫控制器和交互控制器(可選的);由我們實現。
2.動畫控制器(Animation Controller):
最重要的部分,負責添加視圖以及執行動畫;遵守<UIViewControllerAnimatedTransitioning>
協議;由我們實現。
3.交互控制器(Interaction Controller):
通過交互手段,通常是手勢來驅動動畫控制器實現的動畫,使得用戶能夠控制整個過程;遵守<UIViewControllerInteractiveTransitioning>
協議;系統已經打包好現成的類供我們使用。
4.轉場環境(Transition Context):
提供轉場中需要的數據;遵守<UIViewControllerContextTransitioning>
協議;由 UIKit 在轉場開始前生成並提供給我們提交的動畫控制器和交互控制器使用。
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,而且該動畫控制器在三種轉場方式中是通用的,不必修改就可以直接在工程中使用。效果示意圖:
在互動式轉場章節里我們將在這個基礎上實現文章開頭提到的兩種效果: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處標記,是動畫控制器必須完成的:
- 將 toView 添加到容器視圖中,使得 toView 在屏幕上顯示( Modal 轉場中此點稍有不同,下一節細述);
- 正確地結束轉場過程。轉場的結果有兩種:完成或取消。非交互轉場的結果只有完成一種情況,不過互動式轉場需要考慮取消的情況。如何結束取決於轉場的進度,通過
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 的轉場一樣,但在細節上有些差異。
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 為<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。
由於需要保持 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 轉場帶來了以下幾點變化:
- 定製 presentedView 的外觀:設定 presentedView 的尺寸以及在 containerView 中添加自定義視圖併為這些視圖添加動畫;
- 可以選擇是否移除 presentingView;
- 可以在不需要動畫控制器的情況下單獨工作;
- 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 = ...
Demo 地址:ScrollTabBarController。
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)
Demo 地址:CustomModalTransition。
階段二:互動式轉場
激動人心的部分來了,好消息是交互轉場的實現難度比你想象的要低。
實現交互化
在非交互轉場的基礎上將之交互化需要兩個條件:
-
由轉場代理提供交互控制器,這是一個遵守
<UIViewControllerInteractiveTransitioning>
協議的對象,不過系統已經打包好了現成的類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 提供了各自的交互控制器,也需要註意上面的問題。
問題的根源是交互控制的工作機制導致的,交互過程實際上是由轉場環境對象<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個隱性條件:
- 使用 UIView 動畫的 API。你當然也可以使用 Core Animation 來實現動畫,甚至,這種動畫可以被交互控制,但是當交互中止時,會出現一些意外情況:如果你正確地用 Core Animation 的方式復現了 UIView 動畫的效果(不僅僅是動畫,還包括動畫結束後的處理),那麼手勢結束後,動畫將直接跳轉到最終狀態;而更多的一種狀況是,你並沒有正確地復現 UIView 動畫的效果,手勢結束後動畫會停留在手勢中止時的狀態,界面失去響應。所以,如果你需要完美的交互轉場動畫,必須使用 UIView 動畫。
- 在動畫控制器的
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.popVie