原文刊載在:jmStack的個人主頁 轉載請說明出處
大家在平常用微信,微博的過程中肯定(對,就是肯定)都有查看過朋友圈和微博所發(fā)布的照片,當點擊九宮格的某一圖片時圖片會慢慢的放大并進入全屏,左右滑動查看另一張.輕點圖片又會以動畫的方式慢慢縮小回到滑動之后對應的圖片.說了這么多估計你還是不知道我在講什么鬼,一張動圖勝過千言萬語.畢竟語言這東西真不是碼農的特長...
上面兩張gif點開時的動畫不是很明顯,你可以在真機上查看更真實效果.接下來我會通過一個Demo來介紹實現(xiàn)這種效果的具體思路,如果你有更好的思路,請求賜教
Demo 預覽
在開始之前先看一看最終的效果
這個Demo抓取了美麗說的在線圖片,這里對毫不知情的美麗說表示感謝.
在看下面的部分之前假定你已經(jīng)撐握了Swift,網(wǎng)絡請求,會使用UICollectionView等基礎組件的技能.如若不能撐握建議先了解相關知識
DemoGitHub地址
Demo 結構分析
在Demo中主要包括兩個主要的視圖結構:一 縮略圖(主視圖)的瀏覽 二 大圖的瀏覽. 這兩個視圖中所要展示的內容都是有規(guī)律的矩形所以都可以用UICollectionView來實現(xiàn).
兩者的區(qū)別在于縮略圖是垂直方向的布局而大圖是水平方向上的布局方式.兩個UICollectionView的cell的內容只包含一個UIImageView.在大圖瀏覽視圖中有一個
需要注意的細節(jié):為了圖片瀏覽的效果每張圖片之間是有一定間隔的,如果讓每個cell都填充整個屏幕,圖片的寬度等于cell的寬度再去設置cell的間隔來達到間隔的效果會在停止滑動圖片時黑色的間隔會顯現(xiàn)在屏幕中(如下圖),這并不是我們想看到的結果.
出現(xiàn)這個問題的原因是UICollectionView的分頁(pagingEnabled)效果是以UICollectionView的寬來滾動的,也就是說不管你的cell有多大每次滾動總是一個UICollectionView自身的寬.要實現(xiàn)這個效果有個小技巧,相關內容會在大圖瀏覽的實現(xiàn)一節(jié)中介紹.
主視圖圖片瀏覽的實現(xiàn)
根據(jù)上一節(jié)得出的結論,主視圖采用colletionview,這部分實現(xiàn)沒什么特別的技巧,但在添加collectionview之前需要添加幾個基礎組件.
因為我們所需的圖片是抓取美麗說的網(wǎng)絡圖片,所以我們需要一個網(wǎng)絡請求組件,另外為展示圖片還需要添加對應的數(shù)據(jù)模型.但這兩個組件的內容不是本篇博文主要討論的問題
另外這兩個組件相對較基礎,就不廢太多口水.具體實現(xiàn)可以參看GitHub源碼,每次網(wǎng)絡請求這里設置為30條數(shù)據(jù),這里提到也是為了讓你在下面的章節(jié)看到相關部分不至于感到疑惑,
添加完這兩個基礎組件之后,就可以實現(xiàn)縮略圖的瀏覽部分了.為方便起見縮略圖view的控制器采用UICollectionViewController,在viewDidLoad函數(shù)中設置流水布局樣式,實現(xiàn)collectionview的datasource,delegate.這部分都是一些常規(guī)的寫法,這里要關注的是datasource和delegate的下兩個函數(shù).
override func collectionView(collectionView: UICollectionView, cellForItemAtIndexPath indexPath: NSIndexPath) -> UICollectionViewCell {
// 從緩存池中取出重用cell
let cell = collectionView.dequeueReusableCellWithReuseIdentifier(reuseIdentifier, forIndexPath: indexPath) as? CollectionViewCell
// 從模形數(shù)組中取出相應的模形
let item = shopitems[indexPath.item];
// 設置模形數(shù)據(jù)為顯示縮略圖模式
item.showBigImage = false
// 把模形數(shù)據(jù)賦值給cell,由cell去決定怎樣顯示,顯示什么內容
cell?.item = item
// 當滑動到到最后一個cell時請求加載30個數(shù)據(jù)
if indexPath.item == shopitems.count - 1 {
loadMoreHomePageData(shopitems.count)
}
return cell!
}
這里為使Demo不過于復雜,沒有用什么"上拉加載更多"控件,每次滑動到到最后一個cell時請求加載30個數(shù)據(jù)方式同樣能獲得良好的滑動體驗
override func collectionView(collectionView: UICollectionView, didSelectItemAtIndexPath indexPath: NSIndexPath) {
// 當點擊某個cell時, 創(chuàng)建大圖瀏覽控制器
let photoVC = PhotoBrowseCollectionVC()
// 當前點擊cell的indexPathw傳給控制器,以使大圖瀏覽器直接顯示對應圖片
photoVC.indexPath = indexPath
// 當前模型數(shù)組的內容傳給控制器,以使大圖瀏覽能左右滑動
photoVC.items = shopitems
// 先以正常形式modal出大圖瀏覽
presentViewController(photoVC, animated: true, completion: nil)
}
這里先以正常的樣式(從底部彈出)modal出大圖瀏覽視圖,當縮略圖和大圖的邏輯跳轉邏輯完成后再來完善畫動邏輯
大圖瀏覽的實現(xiàn)
與縮略圖一樣,大圖瀏覽也是一個collectionView.這里為大圖瀏覽控制器添加了一個便利構造器,以便在點擊縮略圖時快速創(chuàng)建固定流水布局的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 結構分析一節(jié)中遺留了一個問題,其實要實現(xiàn)全屏圖像間隔效果非常簡單,只要把collectionView和cell的寬設置為屏寬加固定的間距并且cell之間間距為0
而圖片只顯示在屏幕正中間(圖片與屏等寬),這樣在開啟pagingEnabled的情況下每次滑動都是滑動一個(圖片寬度+間距),相當于在cell中留了一個邊距來作間隔而不是在cell
外做間隔,可以參看下圖
上圖中有兩個cell,cell的間距是零.開啟pagingEnabled時,每次移動都是一個cell的寬,這樣停止滑動時間隔就不會出現(xiàn)在屏幕中了.
大圖瀏覽的collectionView的實現(xiàn)代碼幾乎與縮略圖一樣,需要注意的是當modal出大圖的時候collectionView是要直接顯示對應大圖的,這也是為什么在縮略視圖控制器的didSelectItemAtIndexPath函數(shù)中要傳遞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函數(shù)的atScrollPosition參數(shù)的意思是停止?jié)L動時對應的cell與collectionView的位置關系,Left是cell的左邊與colletionview的
左邊對齊.其它的對應關系可依此類推就不廢話了. collectionView的比較重要代理函數(shù)的實現(xiàn)如下
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)
}
說重要是因為要與縮略圖控制器的代理函數(shù)對比看,cellForItemAtIndexPath只是常規(guī)的設置數(shù)據(jù),選中cell直接dismiss當前控制器.
至此縮略圖和大圖的跳轉邏輯你已經(jīng)清楚了,下面的部分才本博文要講的真正內容.其實上面分析那么多廢話也是因為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協(xié)議包含五個函數(shù)
這里只需要關注Getting the Transition Animator Objects的兩個函數(shù),這兩個函數(shù)都需要返回一個實現(xiàn)UIViewControllerAnimatedTransitioning協(xié)議的實例對象,
具體的動畫邏輯將在這個實例對象的方法中完成.
添加點擊跳轉到大圖瀏覽動畫
按上一節(jié)的分析需要在點擊縮略圖時把大圖控制器的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的實例對象,其實現(xiàn)了UIViewControllerTransitioningDelegate協(xié)議方法,animationControllerForPresentedController
返回本身的實例對象,所以ModalAnimationDelegate也要實現(xiàn)UIViewControllerAnimatedTransitioning協(xié)議方法.
func animationControllerForPresentedController(presented: UIViewController, presentingController presenting: UIViewController, sourceController source: UIViewController) -> UIViewControllerAnimatedTransitioning? {
return self
}
現(xiàn)在具體的動畫邏輯就轉到了UIViewControllerAnimatedTransitioning協(xié)議的animateTransition方法中.要實現(xiàn)從選中的圖片慢慢放大的效果分成如下幾步
取出容器view,也就是上一節(jié)提到的UITransitionView實例對象
取出要彈出的目標view,在這里就是展示大圖的colletionview,并添加到容器view
新建UIImageView對象,得到選中的UIImage對像,及其在window上的frame
把新建的UIImageView對象添加到容器view
設置新建UIImageView的放大動畫,動畫結果束后從容器view中移除
通知系統(tǒng)動畫完成(主動調用completeTransition)
把動畫的實現(xiàn)分解開來是不是清晰很多了,具體實現(xiàn)還是得參看代碼
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
// 過渡動畫執(zhí)行
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中拿到就是導航控制器,經(jīng)過一系列的轉換才能拿到選中的圖片.拿到選中的圖片后需要計算動畫開始和結束的frame,開始的frame是將選中的cell座標直接轉換到window上
結束的frame是UIImageView放大到屏寬并居中的frame,具體計算方法參看Demo的coverImageFrameToFullScreenFrame全局函數(shù).
另外UIViewControllerAnimatedTransitioning協(xié)議另一個必須要實現(xiàn)的函數(shù)是transitionDuration,這個函數(shù)決定了動畫執(zhí)行的時長.
func transitionDuration(transitionContext: UIViewControllerContextTransitioning?) -> NSTimeInterval {
return 1.0
}
添加輕擊回到小圖瀏覽動畫
輕擊dismiss的過程與上一節(jié)彈出正好相反,但仍有所區(qū)別.過程如下:
取出彈出的大圖colletionview,得到當前輕擊的圖片
新建UIImageView作為動畫view,并把上一步得到的image給新建UIImageView
得到選中圖片在window上的frame,并設置為新建UIImageView動畫的開始frame
得到當前輕擊的大圖對應的縮略圖的frame,并將其做為動畫結束frame
執(zhí)行動畫,動畫結束后移除UIImageView
通知系統(tǒng)動畫完成(主動調用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協(xié)議的animateTransition方法,為區(qū)分dismiss和present的動畫,定義一個屬性isPresentAnimationing表明當前要執(zhí)行的是dismiss還是present,而當前執(zhí)行的動畫是由UIViewControllerTransitioningDelegate協(xié)議的animationControllerForPresentedController和animationControllerForDismissedController兩個函數(shù)決定的.
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方法
...
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,實現(xiàn)如下
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)
...
總結
上面啰啰嗦嗦寫了很多我認為是廢話的話,其實實現(xiàn)類似微信微博的圖片瀏覽動畫的核心在于dismissViewAnimation和presentViewAnimation函數(shù).本文只是通過一個簡單的demo實現(xiàn)了相同的效果,為大家在自己項目中實現(xiàn)類似效果提供一個可參考的思路.當然本人水平有限,或許你知道更簡單有效的方法希望也告知我.