如何寫出一個絲滑的圖片瀏覽器
緣起
那時,我想要一個這樣的圖片瀏覽器:
- 從小圖進入大圖瀏覽時,使用轉場動畫
- 可加載網絡圖片,且過渡自然,不阻塞操作
- 可各種姿勢玩弄圖片,且過渡自然,不阻塞操作
- 可以在往下拉時,給我縮小,背景變半透明,我要看見底下的東西
- 總之就是語言無法描述的狂拽炫酷x炸天的效果...(那是啥效果...)
PhotoBrowser.png
很遺憾,久尋無果,于是我決定自己造一個。
如何調起圖片瀏覽器
由于我們打算使用轉場動畫,所以在容器的選擇上,只能使用UIViewController,那就讓我們的類繼承它吧:
public class PhotoBrowser: UIViewController
這樣的話,有個方法是躲不開的,必須用它調起我們的圖片瀏覽器:
open func present(_ viewControllerToPresent: UIViewController, animated flag: Bool, completion: (() -> Swift.Void)? = nil)
寫一個庫,提供給別人用時,我們總希望對外接口設計得越簡單明了越好,當然最好能做到傻瓜式操作。
稟承這一原則,我們把 present 方法的調用,以及各種屬性賦值都對外隱藏起來,讓用戶少操心。
所以,提供個方法給用戶 show 一下吧:
public func show() {
self.transitioningDelegate = self
self.modalPresentationStyle = .custom
presentingVC.present(self, animated: true, completion: nil)
}
但是,想要在我們的 PhotoBrowser 類內部 present 出自己的實例,需要一個 ViewController 作為動作執行者,它就是上面 show 方法里面的 presentingVC 。
考慮到這位執行者是不會變化的,只需要告訴我們一次,是誰,就可以了,所以這里可以設計成在 init 創建實例時,就進行綁定:
public init(showByViewController presentingVC: UIViewController) {
self.presentingVC = presentingVC
}
如何向圖片瀏覽器傳遞數據
作為一個圖片瀏覽器,它需要知道哪些關鍵信息?
- 一共有多少張圖片
- 第n張圖片它的縮略圖,或者說占位圖,是什么
- 第n張圖片它的大圖,或者URL是什么
- 打開圖片瀏覽器時,顯示哪一張圖片
我們大概有這么些辦法,可以讓圖片瀏覽器拿到需要展示的圖片信息:
- 在調起瀏覽器之前,向瀏覽器正向傳入它需要的所有數據
- 預先設置回調block,或者代理,在瀏覽器需要用到某個數據時,回調block或者向代理反向取數據
對于圖片瀏覽器來說,并不需要保存一份從用戶傳過來的數據,而是希望在用到的時候再取,這里我們就為它設計代理協議吧。
public protocol PhotoBrowserDelegate {
/// 實現本方法以返回圖片數量
func numberOfPhotos(in photoBrowser: PhotoBrowser) -> Int
/// 實現本方法以返回默認圖片,縮略圖或占位圖
func photoBrowser(_ photoBrowser: PhotoBrowser, thumbnailImageForIndex index: Int) -> UIImage
/// 實現本方法以返回高質量圖片。可選
func photoBrowser(_ photoBrowser: PhotoBrowser, highQualityImageForIndex index: Int) -> UIImage?
/// 實現本方法以返回高質量圖片的url。可選
func photoBrowser(_ photoBrowser: PhotoBrowser, highQualityUrlStringForIndex index: Int) -> URL?
}</code></pre>
然后在 init 方法綁定代理對象,變成這樣:
public init(showByViewController presentingVC: UIViewController, delegate: PhotoBrowserDelegate) {
self.presentingVC = presentingVC
self.photoBrowserDelegate = delegate
super.init(nibName: nil, bundle: nil)
}
但是有一項關鍵信息例外,它就是"打開圖片瀏覽器時,顯示哪一張圖片"。
這一項數據與用戶的 show 動作關聯性更大,從用戶的角度來說,適合在show的同時正向傳遞給圖片瀏覽器。
從圖片瀏覽器來說,它內部也需要維護一個變量,用來記錄當前正在顯示哪一張圖片,所以這一項數據適合讓圖片瀏覽器保存下來。
我們把 show 方法改一下,接收一個參數,并保存在屬性 currentIndex 中。
/// 展示,傳入圖片序號,從0開始
public func show(index: Int) {
currentIndex = index
self.transitioningDelegate = self
self.modalPresentationStyle = .custom
self.modalPresentationCapturesStatusBarAppearance = true
presentingVC.present(self, animated: true, completion: nil)
}
讓用戶傻瓜式操作!
現在我們調起圖片瀏覽器的姿勢是這樣的:
let browser = PhotoBrowser(showByViewController: self, , delegate: self)
browser.show(index: index)
還需要寫兩行代碼,不爽,弄成一行:
/// 便利的展示方法,合并init和show兩個步驟
public class func show(byViewController presentingVC: UIViewController, delegate: PhotoBrowserDelegate, index: Int) {
let browser = PhotoBrowser(showByViewController: presentingVC, delegate: delegate)
browser.show(index: index)
}
現在,我們調起圖片瀏覽器的姿勢是這樣的:
PhotoBrowser.show(byViewController: self, delegate: self, index: indexPath.item)
隱藏狀態欄
圖片瀏覽過程中并不需要狀態欄StatusBar,應當隱藏。
iOS7后,能控制狀態欄的類有兩個, UIApplication 和 UIViewController ,兩者只能取其一,默認情況下,由各 UIViewController 獨立控制自己的狀態欄。
于是,隱藏狀態欄就有兩種辦法:
- 重寫UIViewController的 prefersStatusBarHidden 屬性/方法,并返回 true 來隱藏狀態欄
- 在 info.plist 中取消 UIViewController 的控制權,即設置 View controller-based status bar appearance 為 NO ,然后再設置 UIApplication.shared.isStatusBarHidden = false
作為一個框架,不應該設置全局屬性,不應該操作UIApplication,而且從解耦角度來說就更不應該了。所以我們只負責自己Controller視圖的狀態欄:
public override var prefersStatusBarHidden: Bool {
return true
}
橫向滑動布局
嗯,這是個橫向的 TableView ,我們用 UICollectionView 來做吧。
/// 容器
fileprivate let collectionView: UICollectionView
override public func viewDidLoad() {
super.viewDidLoad()
collectionView.frame = view.bounds
collectionView.backgroundColor = UIColor.clear
collectionView.showsVerticalScrollIndicator = false
collectionView.showsHorizontalScrollIndicator = false
collectionView.dataSource = self
collectionView.delegate = self
collectionView.register(PhotoBrowserCell.self, forCellWithReuseIdentifier: NSStringFromClass(PhotoBrowserCell.self))
view.addSubview(collectionView)
}</code></pre>
布局類繼承自UICollectionViewFlowLayout,設置為橫向滑動:
/// 容器layout
private let flowLayout: PhotoBrowserLayout
public class PhotoBrowserLayout: UICollectionViewFlowLayout {
override init() {
super.init()
scrollDirection = .horizontal
}
}</code></pre>

CollectionView布局.gif
圖間空隙與邊緣吸附
注意左右兩張圖片之間是有空隙的,這是個難點。
先讓空隙數值可配置:
public class PhotoBrowser: UIViewController {
/// 左右兩張圖之間的間隙
public var photoSpacing: CGFloat = 30
}
現在考慮一個問題:圖片是一頁一頁左右滑的,那么究竟要怎樣實現一頁?
已經確定不變的,是每張圖片的寬度必須占滿屏幕,每頁的寬度必須是屏寬+間隙
就有這些可能性:
每個CollectionViewCell的寬度是一個屏寬呢?還是屏寬+間隙?間隙是做為cell的一部分嵌進cell里呢?還是作為layout類的屬性?
考慮到手指滑動,離開屏幕后,需要讓圖片對齊邊緣,即吸附,很自然就想到使用collectionView.isPagingEnabled = true。
如果使用這個屬性,意味著頁寬x頁數要剛剛好等于collectionView的contentSize.width,只有這樣,collectionView.isPagingEnabled才能正常工作。
- 假如給layout類設置spacing作為圖間隙,則collectionView的contentSize.width值為圖片數量x屏寬+(圖片數量-1)x間隙,并非頁寬的整倍數。
- 假如把空隙嵌入cell里作為cell的一部分,則需要增大cell的寬度,使其超出屏寬,再控制圖片視圖小于cell寬。這種辦法屬于技巧性解決問題的辦法,非大道也。因為讓cell的職責超出了它的本分,嘗試去處理它外部的事情,違反解耦,違反面向對象,導致cell內部增加許多本不屬于它的奇怪復雜邏輯。
所以希望使用collectionView.isPagingEnabled = true來實現邊緣吸附效果的想法,被否決,我們來另尋辦法。
首先,讓cell單純地只做展示圖片的行為,讓cell的size滿屏。兩cell之間的空隙由layout控制,當然cell的size也由layout控制:
override public func viewDidLoad() {
super.viewDidLoad()
flowLayout.minimumLineSpacing = photoSpacing
flowLayout.itemSize = view.bounds.size
}
UICollectionViewLayout有一個方法覆蓋點,通過重寫這個方法,可以重新指定scroll停止的位置,它就是:
public func targetContentOffset(forProposedContentOffset proposedContentOffset: CGPoint, withScrollingVelocity velocity: CGPoint) -> CGPoint
蘋果對它的說明,就是告訴我們可以用來實現邊緣吸附的:
If you want the scrolling behavior to snap to specific boundaries, you can override this method and use it to change the point at which to stop.
這個方法接收一個CGPoint,返回一個CGPoint,接收的是若不做任何處理,就在那里停下來的Point,我們要在方法內做的就是返回一個讓它在正確位置停下來的Point。
public class PhotoBrowserLayout: UICollectionViewFlowLayout {
/// 一頁寬度,算上空隙
private lazy var pageWidth: CGFloat = {
return self.itemSize.width + self.minimumLineSpacing
}()
/// 上次頁碼
private lazy var lastPage: CGFloat = {
guard let offsetX = self.collectionView?.contentOffset.x else {
return 0
}
return round(offsetX / self.pageWidth)
}()
/// 最小頁碼
private let minPage: CGFloat = 0
/// 最大頁碼
private lazy var maxPage: CGFloat = {
guard var contentWidth = self.collectionView?.contentSize.width else {
return 0
}
contentWidth += self.minimumLineSpacing
return contentWidth / self.pageWidth - 1
}()
/// 調整scroll停下來的位置
override public func targetContentOffset(forProposedContentOffset proposedContentOffset: CGPoint, withScrollingVelocity velocity: CGPoint) -> CGPoint {
// 頁碼
var page = round(proposedContentOffset.x / pageWidth)
// 處理輕微滑動
if velocity.x > 0.2 {
page += 1
} else if velocity.x < -0.2 {
page -= 1
}
// 一次滑動不允許超過一頁
if page > lastPage + 1 {
page = lastPage + 1
} else if page < lastPage - 1 {
page = lastPage - 1
}
if page > maxPage {
page = maxPage
} else if page < minPage {
page = minPage
}
lastPage = page
return CGPoint(x: page * pageWidth, y: 0)
}
}</code></pre>
可以看到,在targetContentOffset方法里,為了實現pagingEnabled屬性的效果,我們需要處理好幾個細節:
- 輕微滑動時,設定一個閾值,達到則翻頁
- 一次滑動時不允許超過一頁
- 因為有輕微滑動就翻頁的設定,故可能在首尾兩頁出現超過最小最大頁碼的情況,此時要進行最后的邊界檢查
另外,若不啟用pagingEnabled,在手勢滑動離開屏幕后,默認情況下collectionView會繼續滑動很久才會停下來,這時我們需要給它設置一個減速速率,讓它快速停下來:
collectionView.decelerationRate = UIScrollViewDecelerationRateFast
結果我們手動實現了一個與打開pagingEnabled屬性一模一樣的效果。
大圖瀏覽
負責展示圖片的類,是UICollectionViewCell。
為了方便讓圖片進行縮放,可以使用UIScrollView的能力zooming,我們把它作為imageView的容器。
public class PhotoBrowserCell: UICollectionViewCell {
/// 圖像加載視圖
public let imageView = UIImageView()
/// 內嵌容器。本類不能繼承UIScrollView。
/// 因為實測UIScrollView遵循了UIGestureRecognizerDelegate協議,而本類也需要遵循此協議,
/// 若繼承UIScrollView則會覆蓋UIScrollView的協議實現,故只內嵌而不繼承。
fileprivate let scrollView = UIScrollView()
override init(frame: CGRect) {
super.init(frame: frame)
contentView.addSubview(scrollView)
scrollView.delegate = self
scrollView.maximumZoomScale = 2.0
scrollView.showsVerticalScrollIndicator = false
scrollView.showsHorizontalScrollIndicator = false
scrollView.addSubview(imageView)
imageView.clipsToBounds = true
}
}</code></pre>

大圖瀏覽.gif
什么時候進行cell布局?設置圖片后就應該進行。
為什么這么迫切要立即刷新呢?其中有一個原因是下面講到的轉場動畫所需的,轉場動畫需要提前取到即將用于展示的cell。至于另外的原因,于情于理,數據確定后,UI跟著刷新也是沒毛病的。
public class PhotoBrowserCell: UICollectionViewCell {
/// 取圖片適屏size
private var fitSize: CGSize {
guard let image = imageView.image else {
return CGSize.zero
}
let width = scrollView.bounds.width
let scale = image.size.height / image.size.width
return CGSize(width: width, height: scale * width)
}
/// 取圖片適屏frame
private var fitFrame: CGRect {
let size = fitSize
let y = (scrollView.bounds.height - size.height) > 0 ? (scrollView.bounds.height - size.height) * 0.5 : 0
return CGRect(x: 0, y: y, width: size.width, height: size.height)
}
/// 布局
private func doLayout() {
scrollView.frame = contentView.bounds
scrollView.setZoomScale(1.0, animated: false)
imageView.frame = fitFrame
progressView.center = CGPoint(x: contentView.bounds.midX, y: contentView.bounds.midY)
}
/// 設置圖片。image為placeholder圖片,url為網絡圖片
public func setImage(_ image: UIImage, url: URL?) {
guard url != nil else {
imageView.image = image
doLayout()
return
}
self.progressView.isHidden = false
weak var weakSelf = self
imageView.kf.setImage(with: url, placeholder: image, options: nil, progressBlock: { (receivedSize, totalSize) in
// TODO
}, completionHandler: { (image, error, cacheType, url) in
weakSelf?.doLayout()
})
self.doLayout()
}
}</code></pre>
現在我們讓圖片放大。
設計支持兩種縮放操作:
- 捏合手勢
- 雙擊縮放
捏合手勢:
CollectionView是UIScorllView的子類,UIScorllView天生支持pinch捏合手勢,只需要實現它的代理方法即可:
extension PhotoBrowserCell: UIScrollViewDelegate {
public func viewForZooming(in scrollView: UIScrollView) -> UIView? {
return imageView
}
public func scrollViewDidZoom(_ scrollView: UIScrollView) {
imageView.center = centerOfContentSize
}
}</code></pre>
viewForZooming方法可以告訴ScrollView在發生zooming時,對哪個視圖進行縮放;
然后我們需要在scrollViewDidZoom的時候,重新把圖片放在中間,這樣調整可以讓視覺更美觀、體驗更良好。
雙擊縮放:
有些用戶更樂意單手操作手機,而捏合手勢需要兩根手指,很難一只手完成操作。雖然通過捏合可以控制縮放比率,但有時候用戶要的僅僅是“把圖片放大一些,看看細節”這樣的需求,于是我們可以折衷一下,通過雙擊手勢把圖片固定放大到2倍size:
public class PhotoBrowserCell: UICollectionViewCell {
override init(frame: CGRect) {
...
// 雙擊手勢
let doubleTap = UITapGestureRecognizer(target: self, action: #selector(onDoubleTap))
doubleTap.numberOfTapsRequired = 2
imageView.addGestureRecognizer(doubleTap)
}
func onDoubleTap() {
var scale = scrollView.maximumZoomScale
if scrollView.zoomScale == scrollView.maximumZoomScale {
scale = 1.0
}
scrollView.setZoomScale(scale, animated: true)
}
}</code></pre>
轉場動畫
為了呈現合理的打開/關閉圖片瀏覽器效果,我們決定使用轉場動畫。
這里使用modal轉場,并且使用custom方式,方便靈活定制我們想要的效果。
我們想要怎樣的效果?
- 打開圖片瀏覽器時,要從小圖逐漸放大進入大圖瀏覽模式
- 關閉圖片瀏覽時,要從大圖模式逐漸縮小回原來小圖的位置

Transition-Animation.gif
在轉場過程中,我們要妥善處理好的細節包括:
- 小圖和大圖在轉場容器里的坐標位置
- 小圖和大圖的暗中切換
- 背景蒙板
考慮到無論是presention轉場還是dismissal轉場,都是縮放式動畫,所以我們可以只寫一個動畫類:
public class ScaleAnimator: NSObject, UIViewControllerAnimatedTransitioning {
/// 動畫開始位置的視圖
public var startView: UIView?
/// 動畫結束位置的視圖
public var endView: UIView?
/// 用于轉場時的縮放視圖
public var scaleView: UIView?
/// 初始化
init(startView: UIView?, endView: UIView?, scaleView: UIView?) {
self.startView = startView
self.endView = endView
self.scaleView = scaleView
}
}</code></pre>
我們設計它只管動畫,同時適配presention和dismissal轉場,所以不在類中取presentingView和presentedView,而是由外界調用者傳進來,保持動畫類功能單純,只做最需要的事情。
public func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
// 判斷是presentataion動畫還是dismissal動畫
guard let fromVC = transitionContext.viewController(forKey: .from),
let toVC = transitionContext.viewController(forKey: .to) else {
return
}
let presentation = (toVC.presentingViewController == fromVC)
// dismissal轉場,需要把presentedView隱藏,只顯示scaleView
if !presentation, let presentedView = transitionContext.view(forKey: .from) {
presentedView.isHidden = true
}
// 取轉場中介容器
let containerView = transitionContext.containerView
// 求縮放視圖的起始和結束frame
guard let startView = self.startView,
let endView = self.endView,
let scaleView = self.scaleView else {
return
}
guard let startFrame = startView.superview?.convert(startView.frame, to: containerView) else {
print("無法獲取startFrame")
return
}
guard let endFrame = endView.superview?.convert(endView.frame, to: containerView) else {
print("無法獲取endFrame")
return
}
scaleView.frame = startFrame
containerView.addSubview(scaleView)
UIView.animate(withDuration: transitionDuration(using: transitionContext), animations: {
scaleView.frame = endFrame
}) { _ in
// presentation轉場,需要把目標視圖添加到視圖棧
if presentation, let presentedView = transitionContext.view(forKey: .to) {
containerView.addSubview(presentedView)
}
scaleView.removeFromSuperview()
transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
}
}</code></pre>
這里有個關鍵的方法,坐標轉換方法:
let startFrame = startView.superview?.convert(startView.frame, to: containerView)
let endFrame = endView.superview?.convert(endView.frame, to: containerView)
在調用convert之前,需要確保fromView和toView處于同一個window視圖棧內,坐標轉換才能成功。
這里把startView和endView的坐標統統轉成了容器視圖的坐標系坐標,只有在同一個坐標系內,縮放變換、動畫執行才是正確無誤的。
現在可以為PhotoBrowser提供轉場動畫類了。
注意這里有至關重要的細節需要處理,即對于轉場過程中的startView、endView和scaleView如何取的問題。
presention轉場
extension PhotoBrowser: UIViewControllerTransitioningDelegate {
public func animationController(forPresented presented: UIViewController, presenting: UIViewController, source: UIViewController) -> UIViewControllerAnimatedTransitioning? {
// 在本方法被調用時,endView和scaleView還未確定。需于viewDidLoad方法中給animator賦值endView
let animator = ScaleAnimator(startView: relatedView, endView: nil, scaleView: nil)
presentationAnimator = animator
return animator
}
}
在presention轉場時,startView毫無疑問就是縮略圖,小圖,即代碼中的 relatedView ,這個視圖需要圖片瀏覽器通過代理向用戶獲取,即:
public protocol PhotoBrowserDelegate {
/// 實現本方法以返回默認圖所在view,在轉場動畫完成后將會修改這個view的hidden屬性
/// 比如你可返回ImageView,或整個Cell
func photoBrowser(_ photoBrowser: PhotoBrowser, thumbnailViewForIndex index: Int) -> UIView
}
public class PhotoBrowser: UIViewController {
/// 當前正在顯示視圖的前一個頁面關聯視圖
fileprivate var relatedView: UIView {
return photoBrowserDelegate.photoBrowser(self, thumbnailViewForIndex: currentIndex)
}
}</code></pre>
對于endView,是圖片瀏覽器打開時的大圖所在imageView,而這個imageView是某個collectionViewCell的內部子視圖,顯然按正常邏輯來說,轉場動畫發生時,collectionView還沒完成它的視圖渲染,此時是無法取到那一個需要顯示的cell的。
而對于scaleView,這是一個只在轉場過程中創建,轉場結束即銷毀的視圖,它應是一個ImageView,它的創建需要一張圖片,這張圖片即為縮放過程中呈現的圖片,同時也是大圖瀏覽打開完畢后應展示的圖片,endView所用的那一張。所以scaleView也無法在此時創建。
那么在什么時候可以取到瀏覽器打開時所展示的cell?
實測可以發現,幾個關鍵的生命周期方法有如下執行順序:
// 1. 取presentation轉場動畫
public func animationController(forPresented presented: UIViewController, presenting: UIViewController, source: UIViewController) -> UIViewControllerAnimatedTransitioning?
// 2. 控制器的viewDidLoad
public func viewDidLoad()
// 3. 動畫類的轉場方法
public func animateTransition(using transitionContext: UIViewControllerContextTransitioning)
// 4. 控制器的viewDidAppear
public override func viewDidAppear(_ animated: Bool)
我們必須要在animateTransition方法執行之前,把endView和scaleView都取到,通過上面的順序分析,我們可以在viewDidLoad方法里強制刷新collectionView完成這件事:
override public func viewDidLoad() {
...
// 立即加載collectionView
let indexPath = IndexPath(item: currentIndex, section: 0)
collectionView.reloadData()
collectionView.scrollToItem(at: indexPath, at: .left, animated: false)
collectionView.layoutIfNeeded()
// 取當前應顯示的cell,完善轉場動畫器的設置
if let cell = collectionView.cellForItem(at: indexPath) as? PhotoBrowserCell {
presentationAnimator?.endView = cell.imageView
let imageView = UIImageView(image: cell.imageView.image)
imageView.contentMode = imageScaleMode
imageView.clipsToBounds = true
presentationAnimator?.scaleView = imageView
}
}
dismissal轉場
dismissal轉場就方便得多了,在關閉圖片瀏覽器時,轉場動畫的startView即為正在展示中的大圖視圖,endView即為外界的縮略圖視圖,scaleView也可以通過取大圖圖片來馬上創建得到:
extension PhotoBrowser: UIViewControllerTransitioningDelegate {
public func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? {
guard let cell = collectionView.visibleCells.first as? PhotoBrowserCell else {
return nil
}
let imageView = UIImageView(image: cell.imageView.image)
imageView.contentMode = imageScaleMode
imageView.clipsToBounds = true
return ScaleAnimator(startView: cell.imageView, endView: relatedView, scaleView: imageView)
}
}
轉場動畫協處理類
在iOS8以后,蘋果為轉場動畫加入了新成員 UIPresentationController ,它隨著轉場動畫出現而出現,隨著轉場動畫消失而消失,可以進行動畫以外的輔助性操作。
回想我們動畫是負責視圖的縮放的,但是在這過程中還有一點沒有解決,它就是背景蒙板。
我們需要一個純黑色視圖來遮住原頁面,且它應在轉場過程中不斷變更透明度alpha值。
誰來做蒙板比較好呢?
如果由圖片瀏覽控制器的view來充當,假如改變viewController.view,那在其上的所有視圖都會透明化,顯然不合適。
如果由圖片瀏覽控制器創建并持有一個純黑view,放入視圖棧,這樣確實可以實現效果。
只是,并不優雅。為何這么說?如果要給蒙板指定一個歸屬者,它應該屬于圖片瀏覽控制器呢還是更應該屬于轉場動畫呢?
我們更希望瀏覽控制器只做圖片瀏覽的事情,而蒙板的作用是隔離瀏覽器與原頁面,已經超出圖片瀏覽的職責,故不應該由PhotoBrowser來持有。
從另外一個角度來想,因為有轉場動畫,才會有蒙板出現的必要性,故蒙板與轉場動畫的相性更高,它應屬性轉場動畫的一部分。
然而我們希望動畫類保持單純,只做縮放動畫,蒙板這種動畫副產品就與我們的動畫協處理類非常之配,一拍即合。
在iOS8下,通過實現UIViewControllerTransitioningDelegate協議,返回一個UIPresentationController,在轉場動畫過程中,UIPresentationController的 presentationTransitionWillBegin 方法和 dismissalTransitionWillBegin 方法將會被調用。
顧名思義,這兩個方法一個在presentation動畫執行前調用,一個在dismissal動畫執行前調用,我們在這兩個方法里面可以通過transitionCoordinator方法取到與動畫同步進行的block,就可以讓蒙板的透明度變化與轉場動畫同步起來。
public class PhotoBrowser: UIViewController {
/// 轉場協調器
fileprivate weak var animatorCoordinator: ScaleAnimatorCoordinator?
}
extension PhotoBrowser: UIViewControllerTransitioningDelegate {
public func presentationController(forPresented presented: UIViewController, presenting: UIViewController?, source: UIViewController) -> UIPresentationController? {
let coordinator = ScaleAnimatorCoordinator(presentedViewController: presented, presenting: presenting)
coordinator.currentHiddenView = relatedView
animatorCoordinator = coordinator
return coordinator
}
}
public class ScaleAnimatorCoordinator: UIPresentationController {
/// 動畫結束后需要隱藏的view
public var currentHiddenView: UIView?
/// 蒙板
public var maskView: UIView = {
let view = UIView()
view.backgroundColor = UIColor.black
return view
}()
override public func presentationTransitionWillBegin() {
super.presentationTransitionWillBegin()
guard let containerView = self.containerView else { return }
containerView.addSubview(maskView)
maskView.frame = containerView.bounds
maskView.alpha = 0
currentHiddenView?.isHidden = true
presentedViewController.transitionCoordinator?.animate(alongsideTransition: { _ in
self.maskView.alpha = 1
}, completion:nil)
}
override public func dismissalTransitionWillBegin() {
super.dismissalTransitionWillBegin()
currentHiddenView?.isHidden = true
presentedViewController.transitionCoordinator?.animate(alongsideTransition: { _ in
self.maskView.alpha = 0
}, completion: { _ in
self.currentHiddenView?.isHidden = false
})
}
}</code></pre>
另外,協處理類還需要干一件事情,就是要在動畫完成后,悄悄把原頁面中的小圖隱藏掉,至于為什么這樣做,請看下節 Dismiss方式 。
Dismiss方式
關于怎樣關閉圖片瀏覽器,參考微信,有如下兩種操作方式:
- 單擊圖片就關閉
- 按住圖片往下拽,松手即關閉

Dismissal.gif
單擊圖片就關閉:
“單擊一下縮略圖,放大進行瀏覽;單擊一下大圖,縮小回去原圖”這是很自然的操作,我們來實現它:
public class PhotoBrowserCell: UICollectionViewCell {
override init(frame: CGRect) {
...
// 單擊手勢
imageView.isUserInteractionEnabled = true
let singleTap = UITapGestureRecognizer(target: self, action: #selector(onSingleTap))
imageView.addGestureRecognizer(singleTap)
singleTap.require(toFail: doubleTap)
func onSingleTap() {
if let dlg = photoBrowserCellDelegate {
dlg.photoBrowserCellDidSingleTap(self)
}
}
}
extension PhotoBrowser: PhotoBrowserCellDelegate {
public func photoBrowserCellDidSingleTap(_ view: PhotoBrowserCell) {
dismiss(animated: true, completion: nil)
}
}</code></pre>
這里的注意點是單擊手勢和雙擊手勢會有沖突,此時我們需要設置一個相當于優先級的東西,優先響應雙擊手勢:
singleTap.require(toFail: doubleTap)
假如不寫這一行,即便用戶如何快速雙擊,都無法進入雙擊手勢響應方法,因為單擊手勢會立即滿足條件,立即執行。
寫了這一行后,單擊手勢會變得相對遲鈍一些,在確認沒有雙擊手勢發生時,單擊手勢才會生效。
還有一點細節要提的是,執行dismiss應該由controller類內部代碼執行,所以不應該把controller傳值給cell,讓cell去調用controller的dismiss方法,這樣做cell就越權了。
所以這里我們通過代理,把單擊事件傳遞到cell外面去,讓controller自己進行dismiss。
按住圖片往下拽,松手即關閉:
這是個很有意思的效果,下拽圖片,讓圖片隨著下拽程度漸漸縮小,同時背景黑色蒙板漸變透明,可以看到之前的縮略圖界面,而且正在拖拽的圖片位置是空的,一松手圖片就歸位,給人的感受就是我們確實把這張小圖放大來看了。
public class PhotoBrowserCell: UICollectionViewCell {
/// 記錄pan手勢開始時imageView的位置
private var beganFrame = CGRect.zero
/// 記錄pan手勢開始時,手勢位置
private var beganTouch = CGPoint.zero
override init(frame: CGRect) {
// 拖動手勢
let pan = UIPanGestureRecognizer(target: self, action: #selector(onPan(_:)))
pan.delegate = self
imageView.addGestureRecognizer(pan)
}
func onPan(_ pan: UIPanGestureRecognizer) {
switch pan.state {
case .began:
beganFrame = imageView.frame
beganTouch = pan.location(in: pan.view?.superview)
case .changed:
// 拖動偏移量
let translation = pan.translation(in: self)
let currentTouch = pan.location(in: pan.view?.superview)
// 由下拉的偏移值決定縮放比例,越往下偏移,縮得越小。scale值區間[0.3, 1.0]
let scale = min(1.0, max(0.3, 1 - translation.y / bounds.height))
let theFitSize = fitSize
let width = theFitSize.width * scale
let height = theFitSize.height * scale
// 計算x和y。保持手指在圖片上的相對位置不變。
// 即如果手勢開始時,手指在圖片X軸三分之一處,那么在移動圖片時,保持手指始終位于圖片X軸的三分之一處
let xRate = (beganTouch.x - beganFrame.origin.x) / beganFrame.size.width
let currentTouchDeltaX = xRate * width
let x = currentTouch.x - currentTouchDeltaX
let yRate = (beganTouch.y - beganFrame.origin.y) / beganFrame.size.height
let currentTouchDeltaY = yRate * height
let y = currentTouch.y - currentTouchDeltaY
imageView.frame = CGRect(x: x, y: y, width: width, height: height)
// 通知代理,發生了縮放。代理可依scale值改變背景蒙板alpha值
if let dlg = photoBrowserCellDelegate {
dlg.photoBrowserCell(self, didPanScale: scale)
}
case .ended, .cancelled:
if pan.velocity(in: self).y > 0 {
onSingleTap()
} else {
endPan()
}
default:
endPan()
}
}
private func endPan() {
if let dlg = photoBrowserCellDelegate {
dlg.photoBrowserCell(self, didPanScale: 1.0)
}
// 如果圖片當前顯示的size小于原size,則重置為原size
let size = fitSize
let needResetSize = imageView.bounds.size.width < size.width
|| imageView.bounds.size.height < size.height
UIView.animate(withDuration: 0.25) {
self.imageView.center = self.centerOfContentSize
if needResetSize {
self.imageView.bounds.size = size
}
}
}
}</code></pre>
控制縮放比例:
// 由下拉的偏移值決定縮放比例,越往下偏移,縮得越小。scale值區間[0.3, 1.0]
let scale = min(1.0, max(0.3, 1 - translation.y / bounds.height))
當往下拽的時候,是線性同時縮小寬度和高度,但是,有一個極限值,不允許縮小到原來的0.3倍以下。至于為什么是0.3,這是N多次實踐測試后的結果,這個數值可以有比較良好的視覺體驗...
跟隨手勢移動
當手指按住圖片往下拖時,如果不改變圖片大小,可以非常簡單直接讓圖片下移translation.y的偏移量。但我們的情況略有麻煩,在改變圖片位置的同時,也改變著圖片的大小,這樣會導致手指在拖動時,圖片會縮著縮著跑出了手指的觸摸區。
我們得完善這個細節,一輪計算,算出相對的位移量,讓圖片不會跑偏,永遠處于手指之下:
// 計算x和y。保持手指在圖片上的相對位置不變。
// 即如果手勢開始時,手指在圖片X軸三分之一處,那么在移動圖片時,保持手指始終位于圖片X軸的三分之一處
let xRate = (beganTouch.x - beganFrame.origin.x) / beganFrame.size.width
let currentTouchDeltaX = xRate * width
let x = currentTouch.x - currentTouchDeltaX
let yRate = (beganTouch.y - beganFrame.origin.y) / beganFrame.size.height
let currentTouchDeltaY = yRate * height
let y = currentTouch.y - currentTouchDeltaY
imageView.frame = CGRect(x: x, y: y, width: width, height: height)
dismissal的發生與取消:
當松開手時,pan手勢是帶有速度向量屬性的,我們定義的發生dismiss的條件是”用戶往下拽的過程中松手“,而我們也允許用戶有后悔的機會,給他一個能取消的操作,就是重新往上拽回去時,可以取消dismiss:
case .ended, .cancelled:
if pan.velocity(in: self).y > 0 {
// dismiss
onSingleTap()
} else {
// 取消dismiss
endPan()
}
背景蒙板:
另外,在圖片縮放的過程中,背景蒙板也應該隨著縮放比例而變化,我們把比例值通過代理傳遞到外界去,讓控制器使用:
// 通知代理,發生了縮放。代理可依scale值改變背景蒙板alpha值
if let dlg = photoBrowserCellDelegate {
dlg.photoBrowserCell(self, didPanScale: scale)
}
extension PhotoBrowser: PhotoBrowserCellDelegate {
public func photoBrowserCell( view: PhotoBrowserCell, didPanScale scale: CGFloat) {
// 實測用scale的平方,效果比線性好些
animatorCoordinator?.maskView.alpha = scale * scale
}
}</code></pre>
隱藏/顯示關聯的縮略圖:
還有一個細節要處理,當蒙板漸漸變得透明時,就看到底下的原頁面了,這時原頁面中有一個小圖視圖應該要去掉/隱藏,這個小圖應當對應于我們正在瀏覽的那個大圖。
對于隱藏小圖的處理,在上節中的轉場動畫協處理類持有并控制著當前瀏覽大圖所關聯的小圖。
至于為什么這么費力地讓協處理類控制關聯小圖,而不是圖片瀏覽控制器,還是那個道理,各司其職,讓瀏覽器盡量只做瀏覽圖片的工作,況且小圖的隱藏/顯示與轉場動畫的相性更合。
在打開圖片瀏覽器時,所關聯的小圖就是用戶進入瀏覽器時所點的那一張,然后在瀏覽過程中,隨著collectionView左右滑動,關聯小圖就應該相應地立即更新:
public class ScaleAnimatorCoordinator: UIPresentationController {
/// 更新動畫結束后需要隱藏的view
public func updateCurrentHiddenView(
view: UIView) {
currentHiddenView?.isHidden = false
currentHiddenView = view
view.isHidden = true
}
}
public class PhotoBrowser: UIViewController {
/// 當前顯示的圖片序號,從0開始
fileprivate var currentIndex = 0 {
didSet {
animatorCoordinator?.updateCurrentHiddenView(relatedView)
if isShowPageControl {
pageControl.currentPage = currentIndex
}
}
}
/// 當前正在顯示視圖的前一個頁面關聯視圖
fileprivate var relatedView: UIView {
return photoBrowserDelegate.photoBrowser(self, thumbnailViewForIndex: currentIndex)
}
}
extension PhotoBrowser: UICollectionViewDelegate {
/// 減速完成后,計算當前頁
public func scrollViewDidEndDecelerating( scrollView: UIScrollView) {
let offsetX = scrollView.contentOffset.x
let width = scrollView.bounds.width + photoSpacing
currentIndex = Int(offsetX / width)
}
}</code></pre>
PhotoBrowser維護著一個變量currentIndex,而relatedView即為所關聯的小圖,當currentIndex變化時,協處理類應立即同步新的relatedView為隱藏,舊的relatedView恢復顯示,保持狀態完整性。
加載網絡圖片
現在我們的圖片瀏覽器還剩下最后一個關鍵能力:支持加載網絡圖片。
這里使用著名的Swift網絡圖片加載框架 Kingfisher ,也是本庫唯一依賴框架。
public class PhotoBrowserCell: UICollectionViewCell {
/// 設置圖片。image為placeholder圖片,url為網絡圖片
public func setImage(
image: UIImage, url: URL?) {
guard url != nil else {
imageView.image = image
doLayout()
return
}
self.progressView.isHidden = false
weak var weakSelf = self
imageView.kf.setImage(with: url, placeholder: image, options: nil, progressBlock: { (receivedSize, totalSize) in
if totalSize > 0 {
weakSelf?.progressView.progress = CGFloat(receivedSize) / CGFloat(totalSize)
}
}, completionHandler: { (image, error, cacheType, url) in
weakSelf?.progressView.isHidden = true
weakSelf?.doLayout()
})
self.doLayout()
}
}</code></pre>
在加載過程中,我們需要一個友好的加載進度指示,即progressView,寫一個:
public class PhotoBrowserProgressView: UIView {
/// 進度
public var progress: CGFloat = 0 {
didSet {
fanshapedLayer.path = makeProgressPath(progress).cgPath
}
}
/// 外邊界
private var circleLayer: CAShapeLayer!
/// 扇形區
private var fanshapedLayer: CAShapeLayer!
private func setupUI() {
backgroundColor = UIColor.clear
let strokeColor = UIColor(white: 1, alpha: 0.8).cgColor
circleLayer = CAShapeLayer()
circleLayer.strokeColor = strokeColor
circleLayer.fillColor = UIColor.clear.cgColor
circleLayer.path = makeCirclePath().cgPath
layer.addSublayer(circleLayer)
fanshapedLayer = CAShapeLayer()
fanshapedLayer.fillColor = strokeColor
layer.addSublayer(fanshapedLayer)
}
...
}</code></pre>

加載圖絡圖片.gif
其它
PageControl
默認開啟了頁碼指示器pageControl,在PhotoBrowser完全渲染出現于屏幕時,pageControl才會出現,并正確指示當前頁。
public override func viewDidLoad() {
...
if isShowPageControl {
pageControl.sizeToFit()
view.addSubview(pageControl)
}
}
public override func viewDidAppear(_ animated: Bool) {
if isShowPageControl {
pageControl.center = CGPoint(x: view.bounds.midX, y: view.bounds.maxY - 20)
}
}
之所以讓它在這個時機出現,是為了不影響轉場動畫的視覺效果。
如果不需要pageControl,可以設置public屬性 isShowPageControl :
browser.isShowPageControl = false
CocoaPods
庫已上傳CocoaPods,現可直接導入:
pod 'JXPhotoBrowser'
來自:http://www.jianshu.com/p/eacb5ec75542