大家在平常用微信,微博的過程中肯定(對,就是肯定)都有檢視過朋友圈和微博所釋出的照片,當點選九宮格的某一圖片時圖片會慢慢的放大並進入全屏,左右滑動檢視另一張.輕點圖片又會以動畫的方式慢慢縮小回到滑動之後對應的圖片.說了這麼多估計你還是不知道我在講什麼鬼,一張動圖勝過千言萬語.畢竟語言這東西真不是碼農的特長…
上面兩張gif點開時的動畫不是很明顯,你可以在真機上檢視更真實效果.接下來我會通過一個Demo來介紹實現這種效果的具體思路,如果你有更好的思路,請求賜教
Demo 預覽
在開始之前先看一看最終的效果
這個Demo抓取了美麗說的線上圖片,這裡對毫不知情的美麗說表示感謝.
在看下面的部分之前假定你已經撐握了Swift,網路請求,會使用UICollectionView等基礎元件的技能.如若不能撐握建議先了解相關知識
DemoGitHub地址
Demo 結構分析
在Demo中主要包括兩個主要的檢視結構:一 縮圖(主檢視)的瀏覽 二 大圖的瀏覽. 這兩個檢視中所要展示的內容都是有規律的矩形所以都可以用UICollectionView來實現.
兩者的區別在於縮圖是垂直方向的佈局而大圖是水平方向上的佈局方式.兩個UICollectionView的cell的內容只包含一個UIImageView.在大圖瀏覽檢視中有一個
需要注意的細節:為了圖片瀏覽的效果每張圖片之間是有一定間隔的,如果讓每個cell都填充整個螢幕,圖片的寬度等於cell的寬度再去設定cell的間隔來達到間隔的效果會在停止滑動圖片時黑色的間隔會顯現在螢幕中(如下圖),這並不是我們想看到的結果.
出現這個問題的原因是UICollectionView的分頁(pagingEnabled)效果是以UICollectionView的寬來滾動的,也就是說不管你的cell有多大每次滾動總是一個UICollectionView自身的寬.要實現這個效果有個小技巧,相關內容會在大圖瀏覽的實現一節中介紹.
主檢視圖片瀏覽的實現
根據上一節得出的結論,主檢視採用colletionview,這部分實現沒什麼特別的技巧,但在新增collectionview之前需要新增幾個基礎元件.
因為我們所需的圖片是抓取美麗說的網路圖片,所以我們需要一個網路請求元件,另外為展示圖片還需要新增對應的資料模型.但這兩個元件的內容不是本篇博文主要討論的問題
另外這兩個元件相對較基礎,就不廢太多口水.具體實現可以參看GitHub原始碼,每次網路請求這裡設定為30條資料,這裡提到也是為了讓你在下面的章節看到相關部分不至於感到疑惑,
新增完這兩個基礎元件之後,就可以實現縮圖的瀏覽部分了.為方便起見縮圖view的控制器採用UICollectionViewController,在viewDidLoad函式中設定流水佈局樣式,實現collectionview的datasource,delegate.這部分都是一些常規的寫法,這裡要關注的是datasource和delegate的下兩個函式.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
override func collectionView(collectionView: UICollectionView, cellForItemAtIndexPath indexPath: NSIndexPath) -> UICollectionViewCell { // 從快取池中取出重用cell let cell = collectionView.dequeueReusableCellWithReuseIdentifier(reuseIdentifier, forIndexPath: indexPath) as? CollectionViewCell // 從模形陣列中取出相應的模形 let item = shopitems[indexPath.item]; // 設定模形資料為顯示縮圖模式 item.showBigImage = false // 把模形資料賦值給cell,由cell去決定怎樣顯示,顯示什麼內容 cell?.item = item // 當滑動到到最後一個cell時請求載入30個資料 if indexPath.item == shopitems.count - 1 { loadMoreHomePageData(shopitems.count) } return cell! } |
這裡為使Demo不過於複雜,沒有用什麼”上拉載入更多”控制元件,每次滑動到到最後一個cell時請求載入30個資料方式同樣能獲得良好的滑動體驗
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
override func collectionView(collectionView: UICollectionView, didSelectItemAtIndexPath indexPath: NSIndexPath) { // 當點選某個cell時, 建立大圖瀏覽控制器 let photoVC = PhotoBrowseCollectionVC() // 當前點選cell的indexPathw傳給控制器,以使大圖瀏覽器直接顯示對應圖片 photoVC.indexPath = indexPath // 當前模型陣列的內容傳給控制器,以使大圖瀏覽能左右滑動 photoVC.items = shopitems // 先以正常形式modal出大圖瀏覽 presentViewController(photoVC, animated: true, completion: nil) } |
這裡先以正常的樣式(從底部彈出)modal出大圖瀏覽檢視,當縮圖和大圖的邏輯跳轉邏輯完成後再來完善畫動邏輯
大圖瀏覽的實現
與縮圖一樣,大圖瀏覽也是一個collectionView.這裡為大圖瀏覽控制器新增了一個便利構造器,以便在點選縮圖時快速建立固定流水佈局的collectionView.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
convenience init() { let layout = UICollectionViewFlowLayout() layout.itemSize = CGSize(width: UIScreen.mainScreen().bounds.width + cellMargin, height: UIScreen.mainScreen().bounds.height) layout.minimumLineSpacing = 0 layout.minimumInteritemSpacing = 0 layout.scrollDirection = .Horizontal self.init(collectionViewLayout: layout) } |
在Demo 結構分析一節中遺留了一個問題,其實要實現全屏影像間隔效果非常簡單,只要把collectionView和cell的寬設定為屏寬加固定的間距並且cell之間間距為0
而圖片只顯示在螢幕正中間(圖片與屏等寬),這樣在開啟pagingEnabled的情況下每次滑動都是滑動一個(圖片寬度+間距),相當於在cell中留了一個邊距來作間隔而不是在cell
外做間隔,可以參看下圖
上圖中有兩個cell,cell的間距是零.開啟pagingEnabled時,每次移動都是一個cell的寬,這樣停止滑動時間隔就不會出現在螢幕中了.
大圖瀏覽的collectionView的實現程式碼幾乎與縮圖一樣,需要注意的是當modal出大圖的時候collectionView是要直接顯示對應大圖的,這也是為什麼在縮略檢視控制器的didSelectItemAtIndexPath函式中要傳遞indexPath的原因.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
override func viewDidLoad() { super.viewDidLoad() // 大圖colletionview的frame collectionView?.frame = UIScreen.mainScreen().bounds collectionView?.frame.size.width = UIScreen.mainScreen().bounds.size.width + cellMargin // 開啟分頁 collectionView?.pagingEnabled = true // 註冊重用cell collectionView?.registerClass(CollectionViewCell.self, forCellWithReuseIdentifier: cellID) // collectionView顯示時跳轉到應的圖片 collectionView?.scrollToItemAtIndexPath(indexPath!, atScrollPosition: .Left, animated: false) } |
上面程式碼中scrollToItemAtIndexPath函式的atScrollPosition引數的意思是停止滾動時對應的cell與collectionView的位置關係,Left是cell的左邊與colletionview的
左邊對齊.其它的對應關係可依此類推就不廢話了. collectionView的比較重要代理函式的實現如下
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
override func collectionView(collectionView: UICollectionView, cellForItemAtIndexPath indexPath: NSIndexPath) -> UICollectionViewCell { let cell = collectionView.dequeueReusableCellWithReuseIdentifier(cellID, forIndexPath: indexPath) as! CollectionViewCell let item = items![indexPath.item] item.showBigImage = true cell.item = item return cell } override func collectionView(collectionView: UICollectionView, didSelectItemAtIndexPath indexPath: NSIndexPath) { dismissViewControllerAnimated(true, completion: nil) } |
說重要是因為要與縮圖控制器的代理函式對比看,cellForItemAtIndexPath只是常規的設定資料,選中cell直接dismiss當前控制器.
至此縮圖和大圖的跳轉邏輯你已經清楚了,下面的部分才本博文要講的真正內容.其實上面分析那麼多廢話也是因為present和dismiss的動畫與跳轉前後兩個控制器有密切關係
modal出一個View的原理
預設從底部彈出view的modal方式是將要顯式的view新增到一個容器view中,然後對容器view新增動畫效,動畫結束後把跳轉之前控制器的view從window中移除.在window中之前
的view完全被彈出的view替代最終看到如下圖的檢視結構
如你在上圖中看到的,黑色的是window,藍色的為彈出的View,而中間的就是容器View.容器view的型別是UITransitionView
dismiss的過程是present的逆過程,除了從底部彈出的動畫UIKit還提供了多種動畫效果可以通過設定彈出控制器modalTransitionStyle屬性.
這裡有個需要注意點,當設定modalPresentationStyle為Custom時原控制器的view並不會從window中移除.同時如果設定了transitioningDelegate
那麼modalTransitionStyle設定的動畫效果將全部失效,此時動畫全權交給代理來完成. UIViewControllerTransitioningDelegate協議包含五個函式
這裡只需要關注Getting the Transition Animator Objects的兩個函式,這兩個函式都需要返回一個實現UIViewControllerAnimatedTransitioning協議的例項物件,
具體的動畫邏輯將在這個例項物件的方法中完成.
新增點選跳轉到大圖瀏覽動畫
按上一節的分析需要在點選縮圖時把大圖控制器的modalPresentationStyle設為.Custom,並且過渡動畫(transitioningDelegate)設定代理物件,具體程式碼如下
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
override func collectionView(collectionView: UICollectionView, didSelectItemAtIndexPath indexPath: NSIndexPath) { let photoVC = PhotoBrowseCollectionVC() photoVC.indexPath = indexPath photoVC.items = shopitems photoVC.transitioningDelegate = modalDelegate photoVC.modalPresentationStyle = .Custom presentViewController(photoVC, animated: true, completion: nil) } |
modalDelegate是ModalAnimationDelegate的例項物件,其實現了UIViewControllerTransitioningDelegate協議方法,animationControllerForPresentedController
返回本身的例項物件,所以ModalAnimationDelegate也要實現UIViewControllerAnimatedTransitioning協議方法.
1 2 3 4 5 |
func animationControllerForPresentedController(presented: UIViewController, presentingController presenting: UIViewController, sourceController source: UIViewController) -> UIViewControllerAnimatedTransitioning? { return self } |
現在具體的動畫邏輯就轉到了UIViewControllerAnimatedTransitioning協議的animateTransition方法中.要實現從選中的圖片慢慢放大的效果分成如下幾步
- 取出容器view,也就是上一節提到的UITransitionView例項物件
- 取出要彈出的目標view,在這裡就是展示大圖的colletionview,並新增到容器view
- 新建UIImageView物件,得到選中的UIImage對像,及其在window上的frame
- 把新建的UIImageView物件新增到容器view
- 設定新建UIImageView的放大動畫,動畫結果束後從容器view中移除
- 通知系統動畫完成(主動呼叫completeTransition)
把動畫的實現分解開來是不是清晰很多了,具體實現還是得參看程式碼
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 |
func presentViewAnimation(transitionContext: UIViewControllerContextTransitioning) { // 目標view let destinationView = transitionContext.viewForKey(UITransitionContextToViewKey) // 容器view let containerView = transitionContext.containerView() guard let _ = destinationView else { return } // 目標view新增到容器view上 containerView?.addSubview(destinationView!) // 獲取目標控制器 let destinationController = transitionContext.viewControllerForKey(UITransitionContextToViewControllerKey) as? PhotoBrowseCollectionVC let indexPath = destinationController?.indexPath // 跳轉前的控制器 let collectionViewController = ((transitionContext.viewControllerForKey(UITransitionContextFromViewControllerKey)) as! UINavigationController).topViewController as! UICollectionViewController let currentCollectionView = collectionViewController.collectionView // 當前選中的cell let selectctedCell = currentCollectionView?.cellForItemAtIndexPath(indexPath!) as? CollectionViewCell // 新建一個imageview新增到目標view之上,做為動畫view let annimateViwe = UIImageView() annimateViwe.image = selectctedCell?.imageView.image annimateViwe.contentMode = .ScaleAspectFill annimateViwe.clipsToBounds = true // 被選中的cell到目標view上的座標轉換 let originFrame = currentCollectionView!.convertRect(selectctedCell!.frame, toView: UIApplication.sharedApplication().keyWindow) annimateViwe.frame = originFrame containerView?.addSubview(annimateViwe) let endFrame = coverImageFrameToFullScreenFrame(selectctedCell?.imageView.image) destinationView?.alpha = 0 // 過渡動畫執行 UIView.animateWithDuration(1, animations: { annimateViwe.frame = endFrame }) { (finished) in transitionContext.completeTransition(true) UIView.animateWithDuration(0.5, animations: { destinationView?.alpha = 1 }) { (_) in annimateViwe.removeFromSuperview() } } } |
這裡的關鍵是怎樣通過transitionContext拿到兩個控制器.通過UITransitionContextFromViewControllerKey拿到的是轉跳前控制器的父控制器,由於Demo中縮圖控制器內嵌了導航控制器所以在Demo中拿到就是導航控制器,經過一系列的轉換才能拿到選中的圖片.拿到選中的圖片後需要計算動畫開始和結束的frame,開始的frame是將選中的cell座標直接轉換到window上
結束的frame是UIImageView放大到屏寬並居中的frame,具體計算方法參看Demo的coverImageFrameToFullScreenFrame全域性函式.
另外UIViewControllerAnimatedTransitioning協議另一個必須要實現的函式是transitionDuration,這個函式決定了動畫執行的時長.
1 2 3 4 5 |
func transitionDuration(transitionContext: UIViewControllerContextTransitioning?) -> NSTimeInterval { return 1.0 } |
新增輕擊回到小圖瀏覽動畫
輕擊dismiss的過程與上一節彈出正好相反,但仍有所區別.過程如下:
- 取出彈出的大圖colletionview,得到當前輕擊的圖片
- 新建UIImageView作為動畫view,並把上一步得到的image給新建UIImageView
- 得到選中圖片在window上的frame,並設定為新建UIImageView動畫的開始frame
- 得到當前輕擊的大圖對應的縮圖的frame,並將其做為動畫結束frame
- 執行動畫,動畫結束後移除UIImageView
- 通知系統動畫完成(主動呼叫completeTransition)
與present過程不同的是UITransitionContextFromViewControllerKey和UITransitionContextToViewControllerKey兩個key正好相反,present過程的FromVC是縮圖的父控制器,toTV是大圖瀏覽控制器.而dismiss與present是相反的.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 |
func dismissViewAnimation(transitionContext: UIViewControllerContextTransitioning) { let transitionView = transitionContext.viewForKey(UITransitionContextFromViewKey) let contentView = transitionContext.containerView() // 取出modal出的來控制器 let destinationController = transitionContext.viewControllerForKey(UITransitionContextFromViewControllerKey) as! UICollectionViewController // 取出當前顯示的collectionview let presentView = destinationController.collectionView // 取出控制器當前顯示的cell let dismissCell = presentView?.visibleCells().first as? CollectionViewCell // 新建過渡動畫imageview let animateImageView = UIImageView() animateImageView.contentMode = .ScaleAspectFill animateImageView.clipsToBounds = true // 獲取當前顯示的cell的image animateImageView.image = dismissCell?.imageView.image // 獲取當前顯示cell在window中的frame animateImageView.frame = (dismissCell?.imageView.frame)! contentView?.addSubview(animateImageView) // 縮圖對應的indexPath let indexPath = presentView?.indexPathForCell(dismissCell!) // 取出要返回的控制器view let originView = ((transitionContext.viewControllerForKey(UITransitionContextToViewControllerKey) as! UINavigationController).topViewController as! UICollectionViewController).collectionView var originCell = originView!.cellForItemAtIndexPath(indexPath!) // 得到返回後對應cell在window上的frame let originFrame = originView?.convertRect(originCell!.frame, toView: UIApplication.sharedApplication().keyWindow) UIView.animateWithDuration(1, animations: { animateImageView.frame = originFrame! transitionView?.alpha = 0 }) { (_) in animateImageView.removeFromSuperview() transitionContext.completeTransition(true) } } |
present和dismiss時都會呼叫到UIViewControllerAnimatedTransitioning協議的animateTransition方法,為區分dismiss和present的動畫,定義一個屬性isPresentAnimationing表明當前要執行的是dismiss還是present,而當前執行的動畫是由UIViewControllerTransitioningDelegate協議的animationControllerForPresentedController和animationControllerForDismissedController兩個函式決定的.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
func animationControllerForPresentedController(presented: UIViewController, presentingController presenting: UIViewController, sourceController source: UIViewController) -> UIViewControllerAnimatedTransitioning? { isPresentAnimationing = true return self } func animationControllerForDismissedController(dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? { isPresentAnimationing = false return self } func animateTransition(transitionContext: UIViewControllerContextTransitioning) { isPresentAnimationing ? presentViewAnimation(transitionContext) : dismissViewAnimation(transitionContext) } |
要注意的問題
其實上在dismiss動畫邏輯留下了一個坑,dismiss時需要獲取對應縮圖的cell進而得到動畫結束的frame,而獲取這個cell用了cellForItemAtIndexPath方法
` // dismissViewAnimation 函式
…
let originView = ((transitionContext.viewControllerForKey(UITransitionContextToViewControllerKey) as! UINavigationController).topViewController as! UICollectionViewController).collectionView
var originCell = originView!.cellForItemAtIndexPath(indexPath!)
// 得到返回後對應cell在window上的frame
let originFrame = originView?.convertRect(originCell!.frame, toView: UIApplication.sharedApplication().keyWindow)
…
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
而cellForItemAtIndexPath只能返回正在顯示的cell,沒有被顯示的cell將返回nil.所以當大圖對應的縮圖沒有被顯示在colletionview中時強制解包就會丟擲異常.也就是說當選擇檢視當前顯示縮圖的最後一張對應的大圖時就會閃退.解決的辦是若用cellForItemAtIndexPath取不到cell則將應的cell滾動到可視範圍內,由於cellForItemAtIndexPath需要下一個顯示週期才能顯示所以要主動呼叫layoutIfNeeded,實現如下 ``` // dismissViewAnimation 函式 var originCell = originView!.cellForItemAtIndexPath(indexPath!) if originCell == nil { originView?.scrollToItemAtIndexPath(indexPath!, atScrollPosition: .CenteredVertically, animated: false) originView?.layoutIfNeeded() } originCell = originView!.cellForItemAtIndexPath(indexPath!) let originFrame = originView?.convertRect(originCell!.frame, toView: UIApplication.sharedApplication().keyWindow) ... |
總結
上面囉囉嗦嗦寫了很多我認為是廢話的話,其實實現類似微信微博的圖片瀏覽動畫的核心在於dismissViewAnimation和presentViewAnimation函式.本文只是通過一個簡單的demo實現了相同的效果,為大家在自己專案中實現類似效果提供一個可參考的思路.當然本人水平有限,或許你知道更簡單有效的方法希望也告知我.