自定義 push 和 pop 實現有趣的相簿翻開效果(上)

發表於2015-10-30

效果預覽:

蘋果自家應用 Photos 裡點選相簿後的動畫是非常精妙的,而且是可互動的。我有類似的動畫需求,上面是我自己的設計效果。本指南分上下兩篇,分別探討非互動和互動動畫的實現,從入門到深入,並蒐集了實現過程中遇到的一些陷阱,對於想深入的人我想說這兩篇文章不會浪費你的時間。

本文是將三個月前的 Demo 重構後重新寫的,重構後,這個效果可以方便地在你的工程中使用,僅需新增幾行程式碼和幾個簡單的設定。效果適用場景:兩個UICollectionViewController類之間的 push 和 pop 操作。Demo 是個小型的相簿瀏覽器, 這完全是基於我的需求來做的,因此在初期並沒有考慮做成一個手把手教你實現這個效果的教程,不過前面說了,僅需新增幾行程式碼就可在你的工程裡使用,花上幾分鐘搭建一個場景照著做下來也是沒問題的。另外,部分細節比較繁瑣,都放進文章裡就太長了,想了解的話看原始碼,遇到這部分我會提示的。

Demo 地址:SDECollectionViewAlbumTransition。

動畫分析

我把 iOS 裡的動畫分為兩種:趣味動畫和邏輯動畫,前者比如一些載入場景的動畫,用來消磨時間,怎麼炫酷都可以,後者是符合場景變化的動畫,符合邏輯最重要,如果還能很有趣那就更好了。我實現的效果算得上符合邏輯,離有趣或者酷還有點距離。

如上所示,我希望呈現出開啟相簿後照片飛出來的效果,這個設計是行為上的擬物,最好翻開封面時還能發出金光,NO,NO,太浮誇了,簡直跟中華小當家或者國產奇幻劇開寶箱似的。當然,主要是我不知道怎麼做,會做的話我就會做出來給大家看的,不過,我是不會把這種效果放在正常的產品裡的,在遊戲界這種效果比較常見,比如爐石裡新卡牌點開時就帶這種聖光效果。

從技術上講,這個動畫本質上就是個 View Controller Transition 加上多個元素協作進行動畫的過程。總的來說,動畫分為兩個部分,首先是自定義 push 和 pop,其次是各種元素的協作。現在先攻克第一個難點,下面進入科普時間。

View Controller Transition 檢視控制器轉換

對於這個話題,我推薦:1. WWDC13 上的 Custom Transitions Using View Controllers,2.Custom Transitions on iOS,3. Objc.io 的自定義 ViewController 容器轉場。以及一個自定義 transition 效果的庫:VCTransitionsLibrary,可以讀讀程式碼看看這些效果怎麼實現的。

自定義 transition 型別

View Controller Transition 是什麼?其實平時你就一直能看到,在切換或是新增新的檢視控制器來顯示檢視的時候發生的過程就是 ViewController Transition,比如 push 或 pop 一個 View Controller,在 TabBarController 中切換到其他 View Controller,以模態方式顯示另外一個 View Controller。只不過,在 iOS 7 之前我們無法干涉這個過程,從 iOS 7 開始支援自定義 View Controller Transition,目前僅支援以下四種自定義型別:


iOS 支援的的自定義檢視轉換型別 from WWDC13 #218

除了最後一個是佈局轉換,前三種基本囊括了 iOS 中顯示切換檢視的全部方式:
1.Modal 檢視的顯示和消失;
2.TabBar Controller 在子檢視中切換;
3.Navigation Controller 推入和推出檢視。

其中 presentations and dismissals 只支援 UIModalPresentationFullScreen 和 UIModalPresentationCustom 這兩種 Modal 檢視的顯示和消失。在 iOS 8 中推出了 UIPresentationController 類對 Modal 檢視的顯示和消失進行了增強,增加了對自定義 Modal 檢視尺寸的支援,自定義 Modal 檢視尺寸這在以往是很難做到的(反正我還沒有找到好的方法)。

文章開頭的效果是第三種,需要實現自定義 push 和 pop。

Transition Protocol

iOS 提供了幾套 protocol 來滿足自定義 transition 的需求。


WWDC13#218-Custom Transition 的構成

對以上 protocol 的解釋節選自 Objc.io 的自定義 ViewController 容器轉場

iOS 7 自定義檢視控制器轉場的 API 基本上都是以協議的方式提供的,這也使其可以非常靈活的使用,因為你可以很簡單地將它們插入到你的類中。最主要的五個元件如下:
1.動畫控制器 (Animation Controllers) 遵從UIViewControllerAnimatedTransitioning協議,並且負責實際執行動畫。
2.互動控制器 (Interaction Controllers) 通過遵從UIViewControllerInteractiveTransitioning協議來控制可互動式的轉場。
3.轉場代理 (Transitioning Delegates) 根據不同的轉場型別方便的提供需要的動畫控制器和互動控制器。
4.轉場上下文 (Transitioning Contexts) 定義了轉場時需要的後設資料,比如在轉場過程中所參與的檢視控制器和檢視的相關屬性。 轉場上下文物件遵從UIViewControllerContextTransitioning協議,並且這是由系統負責生成和提供的。
5.轉場協調器(Transition Coordinators) 可以在執行轉場動畫時,並行的執行其他動畫。 轉場協調器遵從UIViewControllerTransitionCoordinator協議。
看暈了?沒關係。這五個元件並不是全部都需要你提供,實現一個最簡單的非互動的自定義 transition,只需要實現1和3即可,其實還會用到4,不過大部分情況下這個元件由系統提供給我們,我們只需要實現元件1和3就可以了。

實戰

準備工作

這篇不涉及互動過程,因此我單獨做了個分支:No-Interaction-Transition,是本篇內容的最終版本;或者你還是想自己動手,使用純色塊的 Cell 就好了,幾分鐘就能搞定,又或者不怕再麻煩一點,提取這個分支裡面 Example 資料夾裡的檔案替換到你的工程好了。到這裡還是很簡單的,如果覺得不簡單,那就看看好了,把本文加入待讀列表過一個月後再來學習。

Demo 裡有三個分支,預設分支是能夠自動新增 pinch 手勢支援 pop 操作,還是就是這篇文章的分支 No-Interaction-Transition,還有一種就是同時支援 push 和 pop 操作的 pinch 手勢的分支 Pinch-Push-Pop-Transition

下面需要你配置這樣的一個場景,在此基礎上逐步改造成最終的效果:在 storyboard 裡放置一個UINavigationController和兩個UICollectionViewController,如果你不用 storyboard,相信你也能自己搞定設定。


使用場景

下面使用 fromVC 和 toVC 分別代表 push 和 pop 過程涉及的源和目標UICollectionViewController,animationController 代表動畫控制器,它執行真正的動畫。實現一個最基本的非自定義 push,在你的 fromVC 裡實現以下代理方法:

現在,一個最簡單的場景就搭建完成了。此時,push 和 pop 都是系統替我們完成,執行程式,動畫效果是 Slide。接下來,我們就把這個動畫換成我設計的。

如果你是在 storyboard 裡通過拉 segue 來完成跳轉,那需要你去- prepareForSegue:sender:裡做一些調整了,但先別這麼幹,按照我的節奏來。

接手系統 transition

第一步,為UINavigationController提供遵守UINavigationControllerDelegate協議的物件(元件3)作為代理 delegate,在 push 和 pop 時系統會要求這個 delegate 來提供動畫控制器和互動控制器;沒有提供這個代理時,比如上面的情況裡,系統將會使用預設的 Slide 動畫。該協議的方法名很直白,其中前者必須實現,用於提供元件1來執行實際的動畫,後者提供元件2實現互動動畫,是可選的。

– navigationController:animationControllerForOperation:fromViewController:toViewController:
– navigationController:interactionControllerForAnimationController:

fromVC 也可以作為代理來提供這些方法,但這樣一來不方便其他類使用該效果,這裡單獨提供一個物件來作為代理,俗稱解耦。新建SDENavigationControllerDelegate類,宣告如下:

在 storyboard 裡拖一個 NSObject 下面圖中這一塊區域,然後將其類設定為SDENavigationControllerDelegate。你沒看錯,就是拖一個 NSObject,在你經常拖控制元件的地方輸入 object 就能看到。如果你還不知道,恭喜,現在你又學到新知識了。


在 storyboard 裡為 navigation controller 設定 delegate

小坑預警:如果你想在程式碼裡設定UINavigationController的 delegate,那麼viewDidLoad()並不是一個合適的地方,因為此時 ViewController 尚未被推入UINavigationController的viewControllers棧裡,通過UIViewController.navigationController得到的只是 nil。哪兒合適,在viewDidAppear()後呼叫的方法都可以,這麼說這有點……作為一個UICollectionViewController,push 時在 didSelectCell 那個方法裡最合適了。
本文將只實現非互動的動畫,可互動的動畫在系列下篇討論。在SDENavigationControllerDelegate類裡實現以下方法提供動畫控制器:

第二步,實現上面提供的動畫控制器類SDEPushAndPopAnimationController,該類遵守 UIViewControllerAnimatedTransitioning協議,需要實現以下方法:

– transitionDuration: //提供 transition animation 的持續時間
– animateTransition: //執行動畫的地方,最重要的方法
– animationEnded: //可選方法,動畫完畢後呼叫,大部分時候用不上
SDEPushAndPopAnimationController類的實現:

WT…恩,暫時先這麼處理吧。接下來,再次進入科普時間。

來看看 WWDC13 Session 218 中對 NavigationController push transition 的解釋:


NavigationController Push Transition 圖解

NavigationController 維持的 ViewController 的結構和我們想象的一樣,是個棧,但其對應的 View 的結構卻不是這樣。在 transition 結束時,fromView 被從 containerView 中被移除,如果我們沒有這麼做,系統會替我們完成的。這麼看來,containerView 裡只保留棧頂 ViewController 的檢視,也就是螢幕上我們看到的那個檢視。

圖中的兩個狀態之間的變化就發生在- animateTransition:裡,不過動畫的執行不限於這裡,viewWillXXX, viewDidXXX等這些方法裡都可以執行你想要的動畫,但是,出於解耦的目的,將所有的動畫都放在- animateTransition:裡執行,這樣就能夠也適用於其他UICollectionViewController類了,而如果你需要保證動畫執行的順序,那麼這些方法並不是一個好的選擇,在 WWDC13 Session 218 裡蘋果的工程師提到了不能保證viewDidXXX一定在對應的viewWillXXX後面執行,雖然我在三個月前的實現裡是依賴這些方法而且沒有發現這個問題,那麼,可以繼續這樣做嗎?答案是否,使用- animateTransition:可以從根源上杜絕此類問題;不過,所有動畫放在這裡執行還有一個最最最最最重要的目的,先放結論:你想納入互動化控制過程的動畫必須在- animateTransition:裡執行,而且,必須使用 UIView Animation 來實現,不要使用 Core Animation,在系列下篇裡實現互動動畫時會詳細討論有關細節。科普結束,返回實現過程。

– animateTransition:
該方法原型為:
func animateTransition(_ transitionContext: UIViewControllerContextTransitioning)
該函式的引數由系統提供給我們,同時該引數就是元件4,它提供了 transition 過程中我們需要的絕大部分資訊,包括參與 transition 過程的控制器以及 transition 過程的狀態,最後還要將 transition 的執行結果通知給系統。

在很多文章裡,會給你演示一些簡單的動畫,不過,在這裡我需要你明白,此時的環境是怎樣的以及你能夠做什麼。整理下現在的局面,現在螢幕的內容由當前檢視提供,無論你以何種方式 push 或是 pop,最終會切換到下一屏的畫面,系統會詢問當前 NavigationController 的 delegate 要求提供動畫控制器和互動控制器,如果我們沒有提供動畫控制器,那麼系統就用 Slide 動畫來展示當前畫面和下一屏的畫面的切換。不過,現在我們提供了動畫控制器,系統問我們的動畫控制器怎麼處理這個切換過程。這時候,我們有當前檢視 fromView,當前檢視的容器檢視 containerView,還有下一個螢幕的內容檢視 toView,需要我們做的是將 toView 新增到 containerView 裡用於顯示下一屏的內容,而在 push 或 pop 結束時,fromView 會被從 containerView 裡移除,如果我們沒有這麼做,系統會自動替我們在結束時移除,如果你想幹預這個過程也是可以的,在 push 或 pop 結束之前,我們可以對當前檢視 fromView 和下一屏檢視 toView 做任何你想做的事,這就是我們即將要實現的動畫。

VCTransitionsLibrary 這個庫囊括了大部分對 view 整體之間進行切換的效果,而當 transition 涉及 view 上的元素的話,就需要你針對元素進行定製了,這個庫就不適用這種情況了。比如神奇移動,就是將 fromView 上的元素移動到 toView 上,實現思路有兩種:一是,toView 出現時,將目標元素移動到源元素的位置進行遮擋,然後移動到預定位置,比較簡單;二是將 fromView 和 toView 中相同元素都隱藏,對源元素截圖並加入 toView 中作為偽裝,然後將偽裝的源元素移動到 toView 上的指定位置,最後移除偽裝的元素然後將目標元素恢復顯示。這兩個方法中很重要的一點就是無論是偽裝的元素還是目標元素在開始和結束移動時的位置和大小都要吻合,不然就露餡了。

說教完畢,那麼來實現開頭的效果吧。

動畫技術點

認真看下開頭的效果,以 push 為例:圖片像一本相簿的封面一樣翻開,這是一個可用 transform 實現的 flip 動畫;下一層級的檢視裡的元素也就是相簿裡的照片在封面後出現,這個效果需要縮小照片並按一定規則排列好;封面繼續往左翻動,而照片則移動到預定位置並在這個過程中恢復到原大小。

上面提到,實現互動動畫,一定要使用 UIView Animation 而不是 Core Animation。而且這裡的動畫還涉及多個元素的配合,不同元素的動畫的開始時間與持續時間都不一樣,使用 UIView Animation 是沒法滿足這個要求的,因為常規的延遲執行手段在互動動畫裡沒有作用,只有一個解決辦法:UIView key frame animation,這裡 push 和 pop 過程中的動畫都是採用這種方式實現的。

在回到- animateTransition:執行動畫之前,還有一個問題,從技術角度講,pop 結束後要恢復被隱藏的封面,但這和 push 有什麼關係呢?有關係,大有關係,事前做好準備才不怕事後找麻煩嘛。我們需要在 push 前保留這個被點選的封面的 indexpath 以便在 pop 結束時能夠將之恢復。但又不想在UICollectionViewController新增屬性,因為你讓別人在自己的工程中為這個類新增這個屬性還是挺麻煩的,有辦法:extensition + associated object,這個技巧是從這個庫學來的。為UICollectionViewController新增一個 extension,新建UICollectionViewControllerExtension.swift檔案,為所有的UICollectionViewController類新增下面兩個屬性:

 

然後需要在之前的代理方法裡做新增一行程式碼:

 

準備工作完成了, 動畫過程中包括這麼幾個步驟,同時也是問題:

問題1:封面旋轉。封面的動畫過程本質上和神奇移動有點像,只不過神奇移動裡元素在移動,而這裡元素位置在原來的位置不動,並且繞左側旋轉。不過,神奇移動之所以為神奇移動在於前後的內容裡有相同的元素,但這裡並不是,但依然可以採用神奇移動的思路來實現這個效果。由於 toView 裡並沒有封面這個元素,需要使用偽裝的封面,push 時隱藏原封面的同時在 toView 上新增和原封面內容一樣的檢視來欺騙我們的眼睛,pop 時則將這個偽裝封面翻回去,然後恢復源封面的顯示。封面的第二個問題,如何保證封面在 toView 上依然保持在視覺正確的位置。這個也好解決,無論當前 collectionView 怎麼移動,封面相對於 fromView.superView 和封面相對於 toView.superView 的位置是一樣的,因為這兩個位置都是相對於當前螢幕的位置。UIView 有一套”convertXXX”的方法用於屬於同一個 UIWindow 的檢視之間進行座標的轉換:

 

問題2:調整 visibleCells,這在 pop 時不是問題,但是在 push 時,你會發現在- animateTransition:裡通過 toVC.collectionView?.visibleCells()返回的是空陣列,沒法獲取 visibleCells 意味著我們沒法對即將出現的 visibleCells 進行調整,怎麼辦?這個問題在三個月前將我折磨死了,可以從這篇記錄裡看到當時的歷程,由於無法獲取 visibleCells 而苦苦尋求其他辦法最終卻失敗。解決辦法的關鍵是從這篇教程 How to Create an iOS Book Open Animation 裡得知的,使用toVC.view.snapshotViewAfterScreenUpdates(true)能夠強制檢視立即進行重新整理,此時可以獲取 visibleCells,事實上可以還有方法也可以:- layoutIfNeeded。具體對於這些 visibleCells 根據自身的 indexPath 來設定大小和位置是一件比較繁瑣的事情,這部分程式碼放在setupVisibleCellsBeforePushToVC:裡了,這裡不詳細討論。

 

問題3:調整檢視背景色。這是個很不起眼的小地方,但可能會讓你栽個大跟頭。如果你設定了 toVC 的檢視的背景色,動畫開始時螢幕就會呈現該背景,這時候 fromView 就立刻不可見了,動畫效果是非常糟糕的;這時候你或許會在 storyboard 裡將 toVC 的 collectionView 的背景色調整為透明色來解決這個問題,可惜在動畫結束後,背景色突然變黑,這是因為動畫結束後,fromView 被移除出去了, toView 沒有了背景空無一物,螢幕背景自然就變成黑色了。解決辦法是,在 storyboard 裡將 toVC 的 collectionView 的背景色設定為透明色,然後在 transition 過程中使用動畫來進行過渡到你需要的背景色。

 

實現 Push

在實際的程式碼裡我新增了一些屬性由於定製動畫的某些部分,比如設定封面後面的照片的分佈區別,佈局的間隔,等等,好像用處不大,隨我開心就好。

一切準備就緒,回到動畫控制器,補充剩下的部分:

 

實現 Pop

Pop 過程中的動畫基本上是對 push 過程的逆向,唯一需要注意的地方是由於使用者可能會滑動 collectionView,那麼 pop 時的 visibleCells 可能和 push 時的不一樣,這時候要注意調整有關計算相對位置的演算法,具體可以看程式碼。這裡有個問題,使用者在滑動還沒有結束時點選返回,此時的 pop 動畫就露餡了,因為位置是相對於返回的那一刻在計算的,而介面依然在滑動,封面下面的照片會超出封面的範圍。

這樣就完成了非互動動畫,接下來在這裡討論下如何使用 pinch 手勢來控制 push 和 pop 過程。

說點什麼

這麼一口氣看下來,對剛開始接觸的人來說有點困難,對有過類似經驗的人來說,應該也能找到點新的東西。如果你還沒有試過將這個過程互動化,那麼這篇內容已經規避了大部分互動動畫的陷阱,正如那些加粗顯示的內容提示的那樣,也正因為如此在下篇裡才會顯得如此輕鬆。三個月前的 Demo 也做了和如今大部分都相同的東西,但現在的 Demo 有著更好的解耦性,更方便使用,這也是個進步。

參考資料:
1. WWDC13 Session 218: Custom Transitions Using View Controllers
2.《自定義 ViewController 容器轉場》
3.《Custom Transitions on iOS》,此文是我見過關於 ViewController Custom Transition 的最好文章,強烈推薦。

相關文章