向 UINavigationController 的傳統動畫說”再見” — 自定義過場動畫(一)

題外話
看了一眼最近寫的一篇文章, 發現居然已是兩個多月之前的了, 猛然間警覺到自己近期的產能下降幅度很大啊!(話外音: 咳咳, 話說, 之前也只不過是寫了3篇文章而已, 裝什么職業寫手>_<) 于是乎, 我決定”改過自新, 重新做人”, 再次執起擱置已久的筆, 分享自己的心得!!! 好了, 吐槽到此結束, 進入正式話題!
我作為純正的半路出家的 iOS 開發者, 特別希望能夠給同樣處境的朋友們獻上一些自己的所知所學, 同時也作為自己對知識的一種沉淀和總結. 于是乎, 我異想天開的決定開一個超大的坑, 那就是不定期以實戰的形式(所謂實戰, 就是以實際開發項目的形式, 當然, 這里的所謂”項目”都是一些很簡單的項目)來分享一些自己在工作和學習中了解到的知識, 可能分享的東西談不上”高端”, 更多的是為了知識的傳播.
在走上 iOS 開發的道路上后, 前前后后也讀了不少相關的書籍, 看過很多大神和”所謂的”大神的博客, 其中印象最深的就是 https://www.raywenderlich.com, 這里幾乎以一種手把手的形式去教授你各種 iOS 開發中能夠用到的知識點. 于是, 我也決定嘗試著以這種形式來寫一寫, 如果大家覺得讀了我的文章之后有那么一丁點兒的收獲, 我的付出就算值得了.
由于這類文章本質上是通過一個小 demo 去分享某個知識點, 那么我不希望大家完全從一張白紙開始. 我會為大家提供了相應的工程起始文件, 這里面會有已經寫好的一些代碼和相關的素材, 大家可以直接下載來使用.
文章使用的環境為 Xcode 7.3.1, 語言為 Swift 2.2. 好了, 閑話不多說了, 下面正式開始!


項目準備

想必大家對 UINavigationController 的過場動畫再熟悉不過了. 沒錯! 就是那萬年不變的 push 和 pop 動畫: 在屏幕右側以從右至左的姿態滑入, 再從右側以從左至右的姿態消失于黑暗中… 當然, 我對此并沒有任何貶義和不滿, 畢竟 Apple 延續下來的東西是有必然的道理在里面的, 而且我們手機上系統自帶的有導航欄的應用都延續了這種風格, 因此這也算是 Apple 血液里的一種基因了吧.
好了, 為了再次一睹這種動畫的風采, 我準備好了一份項目的起始文件, 可以從 https://github.com/magiclee203/NavAnimator 下載. 這里我制作了一個非常簡易的圖片瀏覽器, 是通過 UINavigationController 的 push 和 pop 來實現瀏覽功能的.


嗯, 沒錯, 對于上面的動畫, 總結一下就是: ”沒有任何亮點”… 非常傳統的過場動畫, that’s it.
當當當當, 今天的主題終于來了!!! 少年啊, 你不會天真滴以為 UINavigationController 的過場動畫僅能如此而已吧! 如果你確實這么認為, 那么很抱歉, Apple 令你失望了! (話外音: 咦? 怎么莫名滴感覺這種”失望”反而是件好事兒O)
是的, Apple 賦予了我們強大的自定義能力來重新改寫過場動畫. 那么一定會有小伙伴問了: 我可以自定義到什么程度呢? 我可以將過場動畫制作成多么炫酷呢? 答案就是: 能制約你的過場動畫炫酷程度的因素, 只有你的想象力而已!
So, 小伙伴們, 放飛你們的想象力, 讓自定義來的更猛烈一些吧!!!

項目目標

由于本文的目的在于向大家介紹如何自定義過場動畫, 因此并沒有制作復雜和華麗的過場動畫, 僅僅是將系統原生的 push 和 pop 效果進行了改動, 最終效果如下:


雖然改動之后的過場動畫完全談不上”驚艷”, 但是我們確實改變了系統原生的東西. 那么, 我們就開始嘍~~

基本概念

既然要自定義過場動畫, 那么首先就要清楚到底何時會出現過場動畫. 能夠出現過場動畫的場合有如下 3 種:

  1. 本文要講到的 navigation controller 在 push 和 pop 其內部的 view controller 時, 會有過場動畫.
  2. tabbar controller 在切換其內部的 view controller 時, 會有過場動畫. What!!! 意想不到吧, 當你在 tabbar 上點來點去選擇 view controller 時, 其實是有過場動畫的! 只不過... 額... 系統原生的效果也能叫”動畫”? 還是算了吧...
  3. 當你 present 和 dismiss 一個 view controller 時, 會有過場動畫. 這個就很明顯了吧, 系統原生的效果是: present 時從屏幕下方跳出來一個 view controller, dismiss 時這個 view controller 再從下方退出.

以上 3 種情況下出現的過場動畫都是可以自定義的.
那么過場動畫又有幾種類型呢? 有 2 種 (注意, 這里所謂的動畫”類型”與實現出來的動畫”效果”沒有任何聯系!!)

  1. 無交互效果的過場動畫. 顧名思義, 這種過場動畫就是你無法控制的. 回想一下系統 navigation controller 在 push 時, 你什么都不能做, 只能等待這個過場動畫結束, 然后才能操作 push 出的頁面.
  2. 有交互效果的過場動畫. 再次回想系統原生的 navigation controller. 我想大家都應該知道 pop 一個頁面的方法不只有點擊導航欄左上角的返回按鈕吧, 當你按住屏幕的左側, 然后向右滑動時, 這個頁面依然會被 pop, 而且整個過程完全在你的掌控之下, 想滑到哪里就可以滑到哪里. (什么? 莫非有人還不知道這件事兒? 那趕緊打開設備去試一下吧!) 這就是有交互效果的過場動畫.

有交互效果的過場動畫在某種程度上是依賴于無交互效果的過場動畫的(這種說法可能不太嚴謹, 目前可以這么認為), 因此本文先從無交互效果的過場動畫說起, 我個人覺得也更好理解一些.

無交互效果過場動畫的具體實現

要實現無交互效果的過場動畫, 只需要做 3 件事兒!

  1. 你需要讓 view controller 知道接下來要進行過場動畫了. 以本文為例, 你需要讓 navigation controller 知道 push 或 pop 即將發生. 因此, 你首先需要為對應的 view controller 設置代理來感知這件事兒.
  2. 當代理知道過場動畫即將開始時, 它會去尋找一個動畫控制器. 這個動畫控制器就是一個遵守了 UIViewControllerAnimatedTransitioning 協議的東西, 因此, 你可以令任何東西擔負起變為動畫控制器的職責, 只要其遵循 UIViewControllerAnimatedTransitioning 協議即可. 一旦代理找到了動畫控制器, 那么就執行動畫控制器定義的過場動畫, 反之如果沒有找到動畫控制器, 那么系統默認的過場動畫就會被執行.
  3. 去動畫控制器中實現具體的動畫. 由于動畫控制器遵守了 UIViewControllerAnimatedTransitioning 協議, 那么就需要實現該協議中的兩個 required 方法, 分別為:
    (1) transitionDuration: 方法. 這個方法返回了自定義動畫的執行時間.
    (2) animateTransition: 方法. 整個自定義動畫的核心, 到底要執行什么樣的動畫均在該方法中定義.

好了, 了解了自定義過場動畫的整體步驟后, 我們就直接擼代碼吧!


先大致說明一下工程起始文件的結構.(我是個偏執的代碼黨, 所以幾乎不使用 storyboard 和 xib, 望大家諒解>_<)
> 由于要自定義 navigation controller 的過場動畫, 后續對其會進行一些操作, 所以沒有直接使用 UINavigationController, 而是自定義了一個 DTNavController, 繼承自UINavigationController.
> DTViewController 就是實際展示圖片的 view controller.


Step 1

首先要讓 DTNavController 知道要執行過場動畫了, 因此, 我們需要為其設置代理. 讓 DTNavController 自己通知自己最好不過了, 所以我們將其自身設置為代理. 別忘了在設置代理前, 要先遵守 UINavigationControllerDelegate 協議.
我們直接在 DTNavController 的 viewDidLoad 方法中來操作.

class DTNavController: UINavigationController, UINavigationControllerDelegate {
      override func viewDidLoad() {
          super.viewDidLoad()
          self.delegate = self
      }
}

Step 2

設置了代理后, DTNavController 的代理會在即將進行過場動畫時去尋找動畫控制器, 因此我們要提供一個動畫控制器.
一旦 DTNavController 要執行過場動畫, 它的代理(目前就是其自身)就會收到如下消息:
navigationController:animationControllerForOperation:fromViewController:toViewController:.
可以看到, 這個消息有返回值, 并且返回值是一個遵守了 UIViewControllerAnimatedTransitioning協議 的東西, 這正是我們需要的動畫控制器.
因此在 DTNavController 類中實現 UINavigationControllerDelegate 協議 里的方法, 并返回一個動畫控制器.

func navigationController(navigationController: UINavigationController, animationControllerForOperation operation: UINavigationControllerOperation, fromViewController fromVC: UIViewController, toViewController toVC: UIViewController) -> UIViewControllerAnimatedTransitioning? {
     // 這里要 return 一個遵守了 UIViewControllerAnimatedTransitioning 協議的東西
}

好了, 我們接下來要做的就是去制造一個動畫控制器.

Step 3

動畫控制器是任何遵循了 UIViewControllerAnimatedTransitioning協議 的東西, 所以我們完全可以讓 DTNavController 自己來做這件事兒. 但考慮到代碼結構的合理性, 單獨創建一個動畫控制器類來做這件事兒是更合理的.
于是乎, 我們的動畫控制器 DTAnimationController 就這樣誕生了, 而且不要忘了實現 2 個重要的方法

class DTAnimationController: NSObject, UIViewControllerAnimatedTransitioning {     
      func transitionDuration(transitionContext: UIViewControllerContextTransitioning?) -> NSTimeInterval {
       // 1
         return 0.4
      }
 
      func animateTransition(transitionContext: UIViewControllerContextTransitioning) {
       // 2
      }
 } 
  1. 返回過場動畫的持續時間
  2. 具體執行的過場動畫

最終 BOSS 戰 — 過場動畫的具體實現

首先要介紹一個非常重要的小伙伴. 大家可能已經看到了, transitionDuration:animateTransition: 這兩個方法都有一個參數 transitionContext, 這是個遵守了 UIViewControllerContextTransitioning 協議 的東西(如果一定要翻譯過來的話應該是叫”過場上下文”, 總感覺好拗口, 下文中就直接用英文名稱 transitionContext 了).
transitionContext 這個東西非常給力, 它能提供給你本次過場動畫涉及到的方方面面的東西, 包括:

  1. 一個容器 view(containerView). 你可以把這個容器 view 想象成一張大大的畫板, 你將要執行的過場動畫就是在這張畫板上展現出來的.
  2. 要消失的 view controller 和要顯現的 view controller.
  3. 要消失的 view 和要顯現的 view. 通常情況下, 這兩個 view 就是對應的 view controller 的 view, 但以防萬一, transitionContext 直接將這兩個 view 提供給了我們, 多么貼心啊!
  4. 要消失的 view 的起始 frame 和要顯現的 view 的終止 frame.
  5. 要消失的 view 已經被添加到容器 view 上了.

怎么樣? 是不是有點兒蒙圈了, 這都什么跟什么啊… >_<
來, 希望通過下面的一系列圖示為你理清上述內容的關系. 就以實現這個過場動畫的效果為例:



對這個過場動畫而言, 要消失的 view 和 view controller 是路飛(本質上來說他們并沒有消失, 還是在原地, 只不過被覆蓋住了, 因此所謂的”要消失”是指視覺上的看不到了), 要顯現的 view 和 view controller 是索隆.



所以, 所謂的自定義過場動畫, 就是由我們來填補這兩個狀態之間的空白, 僅此而已!
如何填補呢? 做下面兩件事兒就足夠了:
  1. 將要顯現的 view 添加到容器 view 的正確起始位置上
  2. 對要顯現的 view 做動畫. 當然了, 如果你想對要消失的 view 做動畫也是完全可以的.

對于我們的這個效果而言, 只要對要顯現的 view (索隆)做動畫就行了, 要消失的 view 可以不動, 見下圖:



沒錯, 就是這樣, 看似很厲害的自定義過場動畫就這么搞定了!! 其實并沒有你想象的那么難! 來, 讓我們歡快滴擼一會兒代碼吧!

func animateTransition(transitionContext: UIViewControllerContextTransitioning) {
    // 1
    let toViewController = transitionContext.viewControllerForKey(UITransitionContextToViewControllerKey)!
    let toView = transitionContext.viewForKey(UITransitionContextToViewKey)!

    // 2     
    let toViewEndFrame = transitionContext.finalFrameForViewController(toViewController)
    var toViewStartFrame = toViewEndFrame
    toViewStartFrame.origin.y -= toViewEndFrame.size.height
     
    let containerView = transitionContext.containerView()!
    containerView.addSubview(toView)
    toView.frame = toViewStartFrame
   
    // 3  
    UIView.animateWithDuration(self.transitionDuration(transitionContext), animations: {
        toView.frame = toViewEndFrame
    }, completion: { _ in
       // 4
        transitionContext.completeTransition(true)
    })
}
  1. 首先通過 transitionContext 拿到了要顯現的 view controller 和 view.
  2. 通過要顯現的 view controller 拿到要顯現的 view 的終止位置, 進而計算出要顯現的 view 的起始位置, 并將要顯現的 view 加到了容器 view 上.
  3. 執行動畫, 動畫的效果就是使要顯現的 view 出現在其終止位置上.

咦? 第 4 步是怎么回事兒? 細心的你應該發現了, 在動畫結束的時候還做了一步事情. 千萬注意!!! 這一步非常非常非常的重要!!! 即是沒有說三遍, 這件事兒也是相當重要的!!!
當自定義的過場動畫結束后, 你一定不要忘記通知 transitionContext, 告訴它過場動畫已經執行完了, 向其發送 completeTransition: 消息即可.
最后一步, 我們只要回到 DTNavController 中, 將上述我們制造的動畫控制器作為其代理方法的返回值即可.

func navigationController(navigationController: UINavigationController, animationControllerForOperation operation: UINavigationControllerOperation, fromViewController fromVC: UIViewController, toViewController toVC: UIViewController) -> UIViewControllerAnimatedTransitioning? {
     let animationController = DTAnimationController()
     return animationController
}

至此, 終極 BOSS 已經被我們征服. 恭喜你勇士, 你又 get 了一個新技能 O 趕快來看看我們自定義的過場動畫吧.


程序的完善

你應該發現了, 盡管上述程序已經可以實現自定義的過場動畫, 但還是有些缺陷. 當我們點擊導航欄上左上角的返回按鈕時, pop 的過場動畫居然也是”從天而降”的, 顯然不太合理. 既然 push 的方式是從天而降, 那么 pop 應該是”一飛沖天”的方式才對!
為此, 我們需要修改一部分代碼.

1. navigation controller 的代理方法

navigationController:animationControllerForOperation:fromViewController:toViewController: 有一個參數 operation, 這是個枚舉值, 它可以告訴你當前要執行的是 push 操作還是 pop 操作. 因此, 我們可以通過這個值來執行不同的過場動畫.
你可能想到再寫一個 pop 操作的動畫控制器, 將這個動畫控制器和之前我們完成的 DTAnimationController 區分開. 這完全可以, 但只要稍加處理, 我們還是可以靠一個 DTAnimationController 來同時完成 push 和 pop 的動畫的.

2. 為 DTAnimationController 添加屬性 operation

這個屬性對應著 navigation controller 的 push 和 pop 操作.
var operation: UINavigationControllerOperation = .None
回到 DTNavController 中, 修改代理方法如下:

func navigationController(navigationController: UINavigationController, animationControllerForOperation operation: UINavigationControllerOperation, fromViewController fromVC: UIViewController, toViewController toVC: UIViewController) -> UIViewControllerAnimatedTransitioning? {
     let animationController = DTAnimationController()
     animationController.operation = operation
     return animationController
}

這樣, 我們的動畫控制器就知道該執行何種動畫效果了.

3. 修改 DTAnimationController 的動畫效果

有了寫 push 動畫的經驗, 再寫一個 pop 動畫應該不在話下了吧.
借此回顧一下: 只要我們將要顯現的 view 加到容器 view 的正確起始位置上, 然后再根據實際的需求對要顯現的 view 和/或 要消失的 view 執行動畫就可以了.
根據我們的需求, 寫 pop 動畫時, 要顯現的 view 和 要消失的 view 要執行的事情如下:

  1. 要顯現的 view 擺放在終止位置不動, 對要消失的 view 做”一飛沖天”的動畫即可.
  2. 由于要消失的 view 已經在容器 view 上了, 那么在容器 view 上添加了要顯現的 view 之后, 要顯現的 view 就會覆蓋在要消失的 view 上面, 這種情況下, 你是看不到要消失的 view 在執行 pop 動畫. 為了解決這個問題, 在執行動畫之前, 需要調整要顯現的 view 和 要消失的 view 在容器 view 中的層級關系, 即應該將要顯現的 view 放到要消失的 view 的下面.

不多說了, 擼段代碼瞧瞧就知道了.

func animateTransition(transitionContext: UIViewControllerContextTransitioning) {
     // 1
     let fromViewController = transitionContext.viewControllerForKey(UITransitionContextFromViewControllerKey)!
     let fromView = transitionContext.viewForKey(UITransitionContextFromViewKey)!
     let toViewController = transitionContext.viewControllerForKey(UITransitionContextToViewControllerKey)!
     let toView = transitionContext.viewForKey(UITransitionContextToViewKey)!
     
     let containerView = transitionContext.containerView()!
     containerView.addSubview(toView)
     
     let fromViewStartFrame = transitionContext.initialFrameForViewController(fromViewController)
     let toViewEndFrame = transitionContext.finalFrameForViewController(toViewController)
     var fromViewEndFrame = fromViewStartFrame
     var toViewStartFrame = toViewEndFrame
 
     // 2   
     if operation == .Push {
         toViewStartFrame.origin.y -= toViewEndFrame.size.height
     } else if operation == .Pop {
         fromViewEndFrame.origin.y -= fromViewStartFrame.size.height
         containerView.sendSubviewToBack(toView)
     }
     
     fromView.frame = fromViewStartFrame
     toView.frame = toViewStartFrame
    
     // 3 
     UIView.animateWithDuration(self.transitionDuration(transitionContext), animations: {
         fromView.frame = fromViewEndFrame
         toView.frame = toViewEndFrame
     }, completion: { _ in
        // 4
         transitionContext.completeTransition(true)
     })
}
  1. 還是老套路, 將需要的參數先提取出來. 不過這一次因為要應對 push 和 pop 的兩種情況, 因此要將要顯現的 view 和要消失的 view 的信息全部取出.
  2. 根據 push 和 pop 來配置相應 view 的 frame. 要注意, 在執行 pop 動畫操作前, 不要忘記將要顯現的 view 放到底層, 否則就會覆蓋在要消失的 view 上面.
  3. 執行動畫.
  4. 千萬別忘了通知 transitionContext, 過場動畫已經執行完畢!!

好了, 至此, 徹底的大功告成!!!
如果對代碼部分有疑惑, 可以去 https://github.com/magiclee203/NavAnimator 下載工程結束時的代碼.

小作業

如果你有興趣, 你可以嘗試如何為 tabbar controller 添加能明顯看到的過場動畫. 當你在不同的 view controller 之間切換時, 展現一些酷炫的視覺效果吧!

下期預告

不要大意, 這里只是一小步! 我們實現了無交互效果的自定義過場動畫, 動畫執行的整個過程我們都無法參與. 如果你想依靠手勢來實現可交互的過場動畫, 就像系統原生的 navigation controller 可以依靠拖拽來實現 pop 效果那樣, 那么敬請期待下一期吧!!!

(話外音: 喂喂! 下一期到底什么時候來啊?)
額... 盡量不太晚吧…
(話外音: 真是個靠不住的作者啊…)
(話外音2: 有收獲嗎? 有收獲就打個賞吧, 哈哈哈哈!!!)

開玩笑啦, 只要你們有收獲, 就是對我最大的支持. 不過由于作者還有班要上, 所以更新時間的問題嘛, 大家就不要太苛刻了, 吼吼吼吼!!!!

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

推薦閱讀更多精彩內容