iOS自定義轉場動畫實戰講解

bestswifter發表於2018-01-03

轉場動畫這事,說簡單也簡單,可以通過presentViewController:animated:completion:dismissViewControllerAnimated:completion:這一組函式以模態檢視的方式展現、隱藏檢視。如果用到了navigationController,還可以呼叫pushViewController:animated:popViewController這一組函式將新的檢視控制器壓棧、彈棧。

下圖中所有轉場動畫都是自定義的動畫,這些效果如果不用自定義動畫則很難甚至無法實現:

demo演示

由於錄屏的原因,有些效果無法完全展現,比如它其實還支援橫屏。

自定義轉場動畫的效果實現起來比較複雜,如果僅僅是拷貝一份能夠執行的程式碼卻不懂其中原理,就有可能帶來各種隱藏的bug。本文由淺入深介紹下面幾個知識:

  1. 傳統的基於閉包的實現方式及其缺點
  2. 自定義present轉場動畫
  3. 互動式(Interactive)轉場動畫
  4. 轉場協調器與UIModalPresentationCustom
  5. UINavigationController轉場動畫

我為這篇教程製作了一個demo,您可以去在我的github上clone下來:CustomTransition,如果覺得有幫助還望給個star以示支援。本文以Swift+純程式碼實現,對應的OC+Storyboard版本在demo中也可以找到,那是蘋果的官方示範程式碼,正確性更有保證。demo中用到了CocoaPods,您也許需要執行pod install命令並開啟.xcworkspace檔案。

在開始正式的教程前,您首先需要下載demo,在程式碼面前文字是蒼白的,demo中包含的註釋足以解釋本文所有的知識點。其次,您還得了解這幾個背景知識。

From和To

在程式碼和文字中,經常會出現fromViewtoView。如果錯誤的理解它們的含義會導致動畫邏輯完全錯誤。fromView表示當前檢視,toView表示要跳轉到的檢視。如果是從A檢視控制器present到B,則A是from,B是to。從B檢視控制器dismiss到A時,B變成了from,A是to。用一張圖表示:

from和to

Presented和Presenting

這也是一組相對的概念,它容易與fromViewtoView混淆。簡單來說,它不受present或dismiss的影響,如果是從A檢視控制器present到B,那麼A總是B的presentingViewController,B總是A的presentedViewController

modalPresentationStyle

這是一個列舉型別,表示present時動畫的型別。其中可以自定義動畫效果的只有兩種:FullScreenCustom,兩者的區別在於FullScreen會移除fromView,而Custom不會。比如文章開頭的gif中,第三個動畫效果就是Custom

基於block的動畫

最簡單的轉場動畫是使用transitionFromViewController方法:

傳統的轉場動畫實現

這個方法雖然已經過時,但是對它的分析有助於後面知識的理解。它一共有6個引數,前兩個表示從哪個VC開始,跳轉到哪個VC,中間兩個參數列示動畫的時間和選項。最後兩個參數列示動畫的具體實現細節和回撥閉包。

這六個引數其實就是一次轉場動畫所必備的六個元素。它們可以分為兩組,前兩個引數為一組,表示頁面的跳轉關係,後面四個為一組,表示動畫的執行邏輯。

這個方法的缺點之一是可自定義程度不高(在後面您會發現能自定義的不僅僅是動畫方式),另一個缺點則是重用性不好,也可以說是耦合度比較大。

在最後兩個閉包引數中,可以預見的是fromViewControllertoViewController引數都會被用到,而且他們是動畫的關鍵。假設檢視控制器A可以跳轉到B、C、D、E、F,而且跳轉動畫基本相似,您會發現transitionFromViewController方法要被複制多次,每次只會修改少量內容。

自定義present轉場動畫

出於解耦和提高可自定義程度的考慮,我們來學習轉場動畫的正確使用姿勢。

首先要了解一個關鍵概念:轉場動畫代理,它是一個實現了UIViewControllerTransitioningDelegate協議的物件。我們需要自己實現這個物件,它的作用是為UIKit提供以下幾個物件中的一個或多個:

  1. Animator:

它是實現了UIViewControllerAnimatedTransitioning協議的物件,用於控制動畫的持續時間和動畫展示邏輯,代理可以為present和dismiss過程分別提供Animator,也可以提供同一個Animator。

  1. 互動式Animator:和Animator類似,不過它是互動式的,後面會有詳細介紹
  2. Presentation控制器:

它可以對present過程更加徹底的自定義,比如修改被展示檢視的大小,新增自定義檢視等,後面會有詳細介紹。

轉場動畫代理

在這一小節中,我們首先介紹最簡單的Animator。回顧一下轉場動畫必備的6個元素,它們被分為兩組,彼此之間沒有關聯。Animator的作用等同於第二組的四個元素,也就是說對於同一個Animator,可以適用於A跳轉B,也可以適用於A跳轉C。它表示一種通用的頁面跳轉時的動畫邏輯,不受限於具體的檢視控制器。

如果您讀懂了這段話,整個自定義的轉場動畫邏輯就很清楚了,以檢視控制器A跳轉到B為例:

  1. 建立動畫代理,在事情比較簡單時,A自己就可以作為代理
  2. 設定B的transitioningDelegate為步驟1中建立的代理物件
  3. 呼叫presentViewController:animated:completion:並把引數animated設定為true
  4. 系統會找到代理中提供的Animator,由Animator負責動畫邏輯

用具體的例子解釋就是:

// 這個類相當於A
class CrossDissolveFirstViewController: UIViewController, UIViewControllerTransitioningDelegate {
// 這個物件相當於B
crossDissolveSecondViewController.transitioningDelegate = self

// 點選按鈕觸發的函式
func animationButtonDidClicked() {
self.presentViewController(crossDissolveSecondViewController,
animated: true, completion: nil)
}

// 下面這兩個函式定義在UIViewControllerTransitioningDelegate協議中
// 用於為present和dismiss提供animator
func animationControllerForPresentedController(presented: UIViewController, presentingController presenting: UIViewController, sourceController source: UIViewController) -> UIViewControllerAnimatedTransitioning? {
//        也可以使用CrossDissolveAnimator,動畫效果各有不同
//        return CrossDissolveAnimator()
return HalfWaySpringAnimator()
}

func animationControllerForDismissedController(dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? {
return CrossDissolveAnimator()
}
}
複製程式碼

動畫的關鍵在於animator如何實現,它實現了UIViewControllerAnimatedTransitioning協議,至少需要實現兩個方法,我建議您仔細閱讀animateTransition方法中的註釋,它是整個動畫邏輯的核心:

class HalfWaySpringAnimator: NSObject, UIViewControllerAnimatedTransitioning {
/// 設定動畫的持續時間
func transitionDuration(transitionContext: UIViewControllerContextTransitioning?) -> NSTimeInterval {
return 2
}

/// 設定動畫的進行方式,附有詳細註釋,demo中其他地方的這個方法不再解釋
func animateTransition(transitionContext: UIViewControllerContextTransitioning) {
let fromViewController = transitionContext.viewControllerForKey(UITransitionContextFromViewControllerKey)
let toViewController = transitionContext.viewControllerForKey(UITransitionContextToViewControllerKey)
let containerView = transitionContext.containerView()

// 需要關注一下from/to和presented/presenting的關係
// For a Presentation:
//      fromView = The presenting view.
//      toView   = The presented view.
// For a Dismissal:
//      fromView = The presented view.
//      toView   = The presenting view.

var fromView = fromViewController?.view
var toView = toViewController?.view

// iOS8引入了viewForKey方法,儘可能使用這個方法而不是直接訪問controller的view屬性
// 比如在form sheet樣式中,我們為presentedViewController的view新增陰影或其他decoration,animator會對整個decoration view
// 新增動畫效果,而此時presentedViewController的view只是decoration view的一個子檢視
if transitionContext.respondsToSelector(Selector("viewForKey:")) {
fromView = transitionContext.viewForKey(UITransitionContextFromViewKey)
toView = transitionContext.viewForKey(UITransitionContextToViewKey)
}

// 我們讓toview的origin.y在螢幕的一半處,這樣它從螢幕的中間位置彈起而不是從螢幕底部彈起,彈起過程中逐漸變為不透明
toView?.frame = CGRectMake(fromView!.frame.origin.x, fromView!.frame.maxY / 2, fromView!.frame.width, fromView!.frame.height)
toView?.alpha = 0.0

// 在present和,dismiss時,必須將toview新增到檢視層次中
containerView?.addSubview(toView!)

let transitionDuration = self.transitionDuration(transitionContext)
// 使用spring動畫,有彈簧效果,動畫結束後一定要呼叫completeTransition方法
UIView.animateWithDuration(transitionDuration, delay: 0, usingSpringWithDamping: 0.6, initialSpringVelocity: 0, options: .CurveLinear, animations: { () -> Void in
toView!.alpha = 1.0     // 逐漸變為不透明
toView?.frame = transitionContext.finalFrameForViewController(toViewController!)    // 移動到指定位置
}) { (finished: Bool) -> Void in
let wasCancelled = transitionContext.transitionWasCancelled()
transitionContext.completeTransition(!wasCancelled)
}
}
}
複製程式碼

animateTransition方法的核心則是從轉場動畫上下文獲取必要的資訊以完成動畫。上下文是一個實現了UIViewControllerContextTransitioning的物件,它的作用在於為animateTransition方法提供必備的資訊。您不應該快取任何關於動畫的資訊,而是應該總是從轉場動畫上下文中獲取(比如fromView和toView),這樣可以保證總是獲取到最新的、正確的資訊。

轉場動畫上下文

獲取到足夠資訊後,我們呼叫UIView.animateWithDuration方法把動畫交給Core Animation處理。千萬不要忘記在動畫呼叫結束後,執行completeTransition方法。

本節的知識在Demo的Cross Dissolve資料夾中有詳細的程式碼。其中有兩個animator檔案,這說明我們可以為present和dismiss提供同一個animator,或者分別提供各自對應的animator。如果兩者動畫效果類似,您可以共用同一個animator,惟一的區別在於:

  1. present時,要把toView加入到container的檢視層級。
  2. dismiss時,要把fromView從container的檢視層級中移除。

如果您被前面這一大段程式碼和知識弄暈了,或者暫時用不到這些具體的知識,您至少需要記住自定義動畫的基本原理和流程:

  1. 設定將要跳轉到的檢視控制器(presentedViewController)的transitioningDelegate
  2. 充當代理的物件可以是源檢視控制器(presentingViewController),也可以是自己建立的物件,它需要為轉場動畫提供一個animator物件。
  3. animator物件的animateTransition是整個動畫的核心邏輯。

互動式(Interactive)轉場動畫

剛剛我們說到,設定了toViewControllertransitioningDelegate屬性並且present時,UIKit會從代理處獲取animator,其實這裡還有一個細節:UIKit還會呼叫代理的interactionControllerForPresentation:方法來獲取互動式控制器,如果得到了nil則執行非互動式動畫,這就回到了上一節的內容。

如果獲取到了不是nil的物件,那麼UIKit不會呼叫animator的animateTransition方法,而是呼叫互動式控制器(還記得前面介紹動畫代理的示意圖麼,互動式動畫控制器和animator是平級關係)的startInteractiveTransition:方法。

所謂的互動式動畫,通常是基於手勢驅動,產生一個動畫完成的百分比來控制動畫效果(文章開頭的gif中第二個動畫效果)。整個動畫不再是一次性、連貫的完成,而是在任何時候都可以改變百分比甚至取消。這需要一個實現了UIPercentDrivenInteractiveTransition協議的互動式動畫控制器和animator協同工作。這看上去是一個非常複雜的任務,但UIKit已經封裝了足夠多細節,我們只需要在互動式動畫控制器和中定義一個時間處理函式(比如處理滑動手勢),然後在接收到新的事件時,計算動畫完成的百分比並且呼叫updateInteractiveTransition來更新動畫進度即可。

用下面這段程式碼簡單表示一下整個流程(刪除了部分細節和註釋,請不要以此為正確參考),完整的程式碼請參考demo中的Interactivity資料夾:

// 這個相當於fromViewController
class InteractivityFirstViewController: UIViewController {
// 這個相當於toViewController
lazy var interactivitySecondViewController: InteractivitySecondViewController = InteractivitySecondViewController()
// 定義了一個InteractivityTransitionDelegate類作為代理
lazy var customTransitionDelegate: InteractivityTransitionDelegate = InteractivityTransitionDelegate()

override func viewDidLoad() {
super.viewDidLoad()
setupView() // 主要是一些UI控制元件的佈局,可以無視其實現細節

/// 設定動畫代理,這個代理比較複雜,所以我們新建了一個代理物件而不是讓self作為代理
interactivitySecondViewController.transitioningDelegate = customTransitionDelegate
}

// 觸發手勢時,也會呼叫animationButtonDidClicked方法
func interactiveTransitionRecognizerAction(sender: UIScreenEdgePanGestureRecognizer) {
if sender.state == .Began {
self.animationButtonDidClicked(sender)
}
}

func animationButtonDidClicked(sender: AnyObject) {
self.presentViewController(interactivitySecondViewController, animated: true, completion: nil)
}
}
複製程式碼

非互動式的動畫代理只需要為present和dismiss提供animator即可,但是在互動式的動畫代理中,還需要為present和dismiss提供互動式動畫控制器:

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

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

/// 前兩個函式和淡入淡出demo中的實現一致
/// 後兩個函式用於實現互動式動畫

func interactionControllerForPresentation(animator: UIViewControllerAnimatedTransitioning) -> UIViewControllerInteractiveTransitioning? {
return TransitionInteractionController(gestureRecognizer: gestureRecognizer, edgeForDragging: targetEdge)
}

func interactionControllerForDismissal(animator: UIViewControllerAnimatedTransitioning) -> UIViewControllerInteractiveTransitioning? {
return TransitionInteractionController(gestureRecognizer: gestureRecognizer, edgeForDragging: targetEdge)
}
}
複製程式碼

animator中的程式碼略去,它和非互動式動畫中的animator類似。因為互動式的動畫只是一種錦上添花,它必須支援非互動式的動畫,比如這個例子中,點選按鈕依然出發的是非互動式的動畫,只是手勢滑動才會觸發互動式動畫。

class TransitionInteractionController: UIPercentDrivenInteractiveTransition {
/// 當手勢有滑動時觸發這個函式
func gestureRecognizeDidUpdate(gestureRecognizer: UIScreenEdgePanGestureRecognizer) {
switch gestureRecognizer.state {
case .Began: break
case .Changed: self.updateInteractiveTransition(self.percentForGesture(gestureRecognizer))  //手勢滑動,更新百分比
case .Ended:    // 滑動結束,判斷是否超過一半,如果是則完成剩下的動畫,否則取消動畫
if self.percentForGesture(gestureRecognizer) >= 0.5 {
self.finishInteractiveTransition()
}
else {
self.cancelInteractiveTransition()
}
default: self.cancelInteractiveTransition()
}
}
private func percentForGesture(gesture: UIScreenEdgePanGestureRecognizer) -> CGFloat {
let percent = 根據gesture計算得出
return percent
}
}
複製程式碼

互動式動畫是在非互動式動畫的基礎上實現的,我們需要建立一個繼承自UIPercentDrivenInteractiveTransition型別的子類,並且在動畫代理中返回這個型別的例項物件。

在這個型別中,監聽手勢(或者下載進度等等)的時間變化,然後呼叫percentForGesture方法更新動畫進度即可。

轉場協調器與UIModalPresentationCustom

在進行轉場動畫的同時,您還可以進行一些同步的,額外的動畫,比如文章開頭gif中的第三個例子。presentedViewpresentingView可以更改自身的檢視層級,新增額外的效果(陰影,圓角)。UIKit使用轉成協調器來管理這些額外的動畫。您可以通過需要產生動畫效果的檢視控制器的transitionCoordinator屬性來獲取轉場協調器,轉場協調器只在轉場動畫的執行過程中存在。

轉場動畫協調器

想要完成gif中第三個例子的效果,我們還需要使用UIModalPresentationStyle.Custom來代替.FullScreen。因為後者會移除fromViewController,這顯然不符合需求。

當present的方式為.Custom時,我們還可以使用UIPresentationController更加徹底的控制轉場動畫的效果。一個 presentation controller具備以下幾個功能:

  1. 設定presentedViewController的檢視大小
  2. 新增自定義檢視來改變presentedView的外觀
  3. 為任何自定義的檢視提供轉場動畫效果
  4. 根據size class進行響應式佈局

您可以認為,. FullScreen以及其他present風格都是swift為我們實現提供好的,它們是.Custom的特例。而.Custom允許我們更加自由的定義轉場動畫效果。

UIPresentationController提供了四個函式來定義present和dismiss動畫開始前後的操作:

  1. presentationTransitionWillBegin: present將要執行時
  2. presentationTransitionDidEnd:present執行結束後
  3. dismissalTransitionWillBegin:dismiss將要執行時
  4. dismissalTransitionDidEnd:dismiss執行結束後

下面的程式碼簡要描述了gif中第三個動畫效果的實現原理,您可以在demo的Custom Presentation資料夾下檢視完成程式碼:

// 這個相當於fromViewController
class CustomPresentationFirstViewController: UIViewController {
// 這個相當於toViewController
lazy var customPresentationSecondViewController: CustomPresentationSecondViewController = CustomPresentationSecondViewController()
// 建立PresentationController
lazy var customPresentationController: CustomPresentationController = CustomPresentationController(presentedViewController: self.customPresentationSecondViewController, presentingViewController: self)

override func viewDidLoad() {
super.viewDidLoad()
setupView() // 主要是一些UI控制元件的佈局,可以無視其實現細節

// 設定轉場動畫代理
customPresentationSecondViewController.transitioningDelegate = customPresentationController
}

override func didReceiveMemoryWarning() {
super.didReceiveMemoryWarning()
// Dispose of any resources that can be recreated.
}

func animationButtonDidClicked() {
self.presentViewController(customPresentationSecondViewController, animated: true, completion: nil)
}
}
複製程式碼

重點在於如何實現CustomPresentationController這個類:

class CustomPresentationController: UIPresentationController, UIViewControllerTransitioningDelegate {
var presentationWrappingView: UIView?  // 這個檢視封裝了原檢視,新增了陰影和圓角效果
var dimmingView: UIView? = nil  // alpha為0.5的黑色蒙版

// 告訴UIKit為哪個檢視新增動畫效果
override func presentedView() -> UIView? {
return self.presentationWrappingView
}
}

// 四個方法自定義轉場動畫發生前後的操作
extension CustomPresentationController {
override func presentationTransitionWillBegin() {
// 設定presentationWrappingView和dimmingView的UI效果
let transitionCoordinator = self.presentingViewController.transitionCoordinator()
self.dimmingView?.alpha = 0
// 通過轉場協調器執行同步的動畫效果
transitionCoordinator?.animateAlongsideTransition({ (context: UIViewControllerTransitionCoordinatorContext) -> Void in
self.dimmingView?.alpha = 0.5
}, completion: nil)
}

/// present結束時,把dimmingView和wrappingView都清空,這些臨時檢視用不到了
override func presentationTransitionDidEnd(completed: Bool) {
if !completed {
self.presentationWrappingView = nil
self.dimmingView = nil
}
}

/// dismiss開始時,讓dimmingView完全透明,這個動畫和animator中的動畫同時發生
override func dismissalTransitionWillBegin() {
let transitionCoordinator = self.presentingViewController.transitionCoordinator()
transitionCoordinator?.animateAlongsideTransition({ (context: UIViewControllerTransitionCoordinatorContext) -> Void in
self.dimmingView?.alpha = 0
}, completion: nil)
}

/// dismiss結束時,把dimmingView和wrappingView都清空,這些臨時檢視用不到了
override func dismissalTransitionDidEnd(completed: Bool) {
if completed {
self.presentationWrappingView = nil
self.dimmingView = nil
}
}
}

extension CustomPresentationController {
}
複製程式碼

除此以外,這個類還要處理子檢視佈局相關的邏輯。它作為動畫代理,還需要為動畫提供animator物件,詳細程式碼請在demo的Custom Presentation資料夾下閱讀。

UINavigationController轉場動畫

到目前為止,所有轉場動畫都是適用於present和dismiss的,其實UINavigationController也可以自定義轉場動畫。兩者是平行關係,很多都可以類比過來:

class FromViewController: UIViewController, UINavigationControllerDelegate {
let toViewController: ToViewController = ToViewController()

override func viewDidLoad() {
super.viewDidLoad()
setupView() // 主要是一些UI控制元件的佈局,可以無視其實現細節

self.navigationController.delegate = self
}
}
複製程式碼

與present/dismiss不同的時,現在檢視控制器實現的是UINavigationControllerDelegate協議,讓自己成為navigationController的代理。這個協議類似於此前的UIViewControllerTransitioningDelegate協議。

FromViewController實現UINavigationControllerDelegate協議的具體操作如下:

func navigationController(navigationController: UINavigationController,
animationControllerForOperation operation: UINavigationControllerOperation,
fromViewController fromVC: UIViewController,
toViewController toVC: UIViewController)
-> UIViewControllerAnimatedTransitioning? {
if operation == .Push {
return PushAnimator()
}
if operation == .Pop {
return PopAnimator()
}
return nil;
}
複製程式碼

至於animator,就和此前沒有任何區別了。可見,一個封裝得很好的animator,不僅能在present/dismiss時使用,甚至還可以在push/pop時使用。

UINavigationController也可以新增互動式轉場動畫,原理也和此前類似。

總結

對於非互動式動畫,需要設定presentedViewControllertransitioningDelegate屬性,這個代理需要為present和dismiss提供animator。在animator中規定了動畫的持續時間和表現邏輯。

對於互動式動畫,需要在此前的基礎上,由transitioningDelegate屬性提供互動式動畫控制器。在控制器中進行事件處理,然後更新動畫完成進度。

對於自定義動畫,可以通過UIPresentationController中的四個函式自定義動畫執行前後的效果,可以修改presentedViewController的大小、外觀並同步執行其他的動畫。

自定義動畫的水還是比較深,本文僅適合做入門學習用,歡迎互相交流。

相關文章