大家在平常用微信,微博的過程中肯定(對,就是肯定)都有查看過朋友圈和微博所發布的照片,當點擊九宮格的某一圖片時圖片會慢慢的放大並進入全屏,左右滑動查看另一張。輕點圖片又會以動畫的方式慢慢縮小回到滑動之後對應的圖片。說了這麼多估計你還是不知道我在講什麼鬼,一張動圖勝過千言萬語。畢竟語言這東西真不是碼農的特長…
上面兩張gif點開時的動畫不是很明顯,你可以在真機上查看更真實效果。接下來我會通過一個Demo來介紹實現這種效果的具體思路,如果你有更好的思路,請求賜教
Demo 預覽
在開始之前先看一看最終的效果
這個Demo抓取了美麗說的在線圖片,這裡對毫不知情的美麗說表示感謝。
在看下面的部分之前假定你已經撐握了Swift,網絡請求,會使用UICollectionView等基礎組件的技能(如若不能撐握建議先了解相關知識)。
Demo:GitHub地址
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的下兩個函數。
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個數據方式同樣能獲得良好的滑動體驗
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。
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的原因。
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的比較重要代理函數的實現如下
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替代最終看到如下圖的視圖結構
modal視圖結構
如你在上圖中看到的,黑色的是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)設置代理對象,具體代碼如下
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協議方法.
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)
把動畫的實現分解開來是不是清晰很多了,具體實現還是得參看代碼
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,這個函數決定了動畫執行的時長。
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是相反的。
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兩個函數決定的。
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) ...
而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實現了相同的效果,為大家在自己項目中實現類似效果提供一個可參考的思路。當然本人水平有限,或許你知道更簡單有效的方法希望也告知我。
轉自lemtter的博客