緣起
那時,我想要一個這樣的圖片瀏覽器:
- 從小圖進入大圖瀏覽時,使用轉(zhuǎn)場動畫
- 可加載網(wǎng)絡(luò)圖片,且過渡自然,不阻塞操作
- 可各種姿勢玩弄圖片,且過渡自然,不阻塞操作
- 可以在往下拽時,尺寸隨位移縮小,背景半透明,要能看見底下的場景
- 反正就是各種效果啦...

很遺憾,久尋無果,于是我決定自己造一個。
Requirements
- iOS 8.0+
- Swift 3.0+
- Xcode 8.1+
調(diào)起方式
由于我們打算使用轉(zhuǎn)場動畫,所以在容器的選擇上,只能使用UIViewController,那就讓我們的類繼承它吧:
public class PhotoBrowser: UIViewController
這樣的話,有個方法是躲不開的,必須用它調(diào)起我們的圖片瀏覽器:
open func present(_ viewControllerToPresent: UIViewController, animated flag: Bool, completion: (() -> Swift.Void)? = nil)
寫一個庫,提供給別人用時,我們總希望對外接口設(shè)計得越簡單明了越好,當(dāng)然最好能做到傻瓜式操作。
稟承這一原則,我們把present方法的調(diào)用,以及各種屬性賦值都對外隱藏起來,讓用戶少操心。
所以,提供個方法給用戶show一下吧:
public func show() {
self.transitioningDelegate = self
self.modalPresentationStyle = .custom
presentingVC.present(self, animated: true, completion: nil)
}
但是,想要在我們的PhotoBrowser類內(nèi)部present出自己的實例,需要一個ViewController作為動作執(zhí)行者,它就是上面show方法里面的presentingVC。
考慮到這位執(zhí)行者是不會變化的,只需要告訴我們一次,是誰,就可以了,所以這里可以設(shè)計成在init創(chuàng)建實例時,就進行綁定:
public init(showByViewController presentingVC: UIViewController) {
self.presentingVC = presentingVC
}
傳遞數(shù)據(jù)
作為一個圖片瀏覽器,它需要知道哪些關(guān)鍵信息?
- 一共有多少張圖片
- 第n張圖片它的縮略圖,或者說占位圖,是什么
- 第n張圖片它的大圖,或者URL是什么
- 打開圖片瀏覽器時,顯示哪一張圖片
我們大概有這么些辦法,可以讓圖片瀏覽器拿到需要展示的圖片信息:
- 在調(diào)起瀏覽器之前,向瀏覽器正向傳入它需要的所有數(shù)據(jù)
- 預(yù)先設(shè)置回調(diào)block,或者代理,在瀏覽器需要用到某個數(shù)據(jù)時,回調(diào)block或者向代理反向取數(shù)據(jù)
對于圖片瀏覽器來說,并不需要保存一份從用戶傳過來的數(shù)據(jù),而是希望在用到的時候再取,這里我們就為它設(shè)計代理協(xié)議吧。
public protocol PhotoBrowserDelegate {
/// 實現(xiàn)本方法以返回圖片數(shù)量
func numberOfPhotos(in photoBrowser: PhotoBrowser) -> Int
/// 實現(xiàn)本方法以返回默認圖片,縮略圖或占位圖
func photoBrowser(_ photoBrowser: PhotoBrowser, thumbnailImageForIndex index: Int) -> UIImage
/// 實現(xiàn)本方法以返回高質(zhì)量圖片??蛇x
func photoBrowser(_ photoBrowser: PhotoBrowser, highQualityImageForIndex index: Int) -> UIImage?
/// 實現(xiàn)本方法以返回高質(zhì)量圖片的url??蛇x
func photoBrowser(_ photoBrowser: PhotoBrowser, highQualityUrlStringForIndex index: Int) -> URL?
}
然后在init方法綁定代理對象,變成這樣:
public init(showByViewController presentingVC: UIViewController, delegate: PhotoBrowserDelegate) {
self.presentingVC = presentingVC
self.photoBrowserDelegate = delegate
super.init(nibName: nil, bundle: nil)
}
但是有一項關(guān)鍵信息例外,它就是"打開圖片瀏覽器時,顯示哪一張圖片"。
這一項數(shù)據(jù)與用戶的show動作關(guān)聯(lián)性更大,從用戶的角度來說,適合在show的同時正向傳遞給圖片瀏覽器。
從圖片瀏覽器來說,它內(nèi)部也需要維護一個變量,用來記錄當(dāng)前正在顯示哪一張圖片,所以這一項數(shù)據(jù)適合讓圖片瀏覽器保存下來。
我們把show方法改一下,接收一個參數(shù),并保存在屬性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)
}
讓用戶傻瓜式操作!
現(xiàn)在我們調(diào)起圖片瀏覽器的姿勢是這樣的:
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)
}
現(xiàn)在,我們調(diào)起圖片瀏覽器的姿勢是這樣的:
PhotoBrowser.show(byViewController: self, delegate: self, index: indexPath.item)
橫向滑動布局
嗯,這是個橫向的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)
}
布局類繼承自UICollectionViewFlowLayout,設(shè)置為橫向滑動:
/// 容器layout
private let flowLayout: PhotoBrowserLayout
public class PhotoBrowserLayout: UICollectionViewFlowLayout {
override init() {
super.init()
scrollDirection = .horizontal
}
}

圖間空隙與邊緣吸附
注意左右兩張圖片之間是有空隙的,這是個難點。
先讓空隙數(shù)值可配置:
public class PhotoBrowser: UIViewController {
/// 左右兩張圖之間的間隙
public var photoSpacing: CGFloat = 30
}
現(xiàn)在考慮一個問題:圖片是一頁一頁左右滑的,那么究竟要怎樣實現(xiàn)一頁?
已經(jīng)確定不變的,是每張圖片的寬度必須占滿屏幕,每頁的寬度必須是屏寬+間隙
就有這些可能性:
每個CollectionViewCell的寬度是一個屏寬呢?還是屏寬+間隙?間隙是做為cell的一部分嵌進cell里呢?還是作為layout類的屬性?
考慮到手指滑動,離開屏幕后,需要讓圖片對齊邊緣,即吸附,很自然就想到使用collectionView.isPagingEnabled = true。
如果使用這個屬性,意味著頁寬x頁數(shù)要剛剛好等于collectionView的contentSize.width,只有這樣,collectionView.isPagingEnabled才能正常工作。
- 假如給layout類設(shè)置spacing作為圖間隙,則collectionView的contentSize.width值為圖片數(shù)量x屏寬+(圖片數(shù)量-1)x間隙,并非頁寬的整倍數(shù)。
- 假如把空隙嵌入cell里作為cell的一部分,則需要增大cell的寬度,使其超出屏寬,再控制圖片視圖小于cell寬。這種辦法屬于技巧性解決問題的辦法,非大道也。因為讓cell的職責(zé)超出了它的本分,嘗試去處理它外部的事情,違反解耦,違反面向?qū)ο?,?dǎo)致cell內(nèi)部增加許多本不屬于它的奇怪復(fù)雜邏輯。
所以希望使用collectionView.isPagingEnabled = true來實現(xiàn)邊緣吸附效果的想法,被否決,我們來另尋辦法。
首先,讓cell單純地只做展示圖片的行為,讓cell的size滿屏。兩cell之間的空隙由layout控制,當(dāng)然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
蘋果對它的說明,就是告訴我們可以用來實現(xiàn)邊緣吸附的:
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,我們要在方法內(nèi)做的就是返回一個讓它在正確位置停下來的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
}()
/// 調(diào)整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)
}
}
可以看到,在targetContentOffset方法里,為了實現(xiàn)pagingEnabled屬性的效果,我們需要處理好幾個細節(jié):
- 輕微滑動時,設(shè)定一個閾值,達到則翻頁
- 一次滑動時不允許超過一頁
- 因為有輕微滑動就翻頁的設(shè)定,故可能在首尾兩頁出現(xiàn)超過最小最大頁碼的情況,此時要進行最后的邊界檢查
另外,若不啟用pagingEnabled,在手勢滑動離開屏幕后,默認情況下collectionView會繼續(xù)滑動很久才會停下來,這時我們需要給它設(shè)置一個減速速率,讓它快速停下來:
collectionView.decelerationRate = UIScrollViewDecelerationRateFast
結(jié)果我們手動實現(xiàn)了一個與打開pagingEnabled屬性一模一樣的效果。
大圖瀏覽
負責(zé)展示圖片的類,是UICollectionViewCell。
為了方便讓圖片進行縮放,可以使用UIScrollView的能力zooming,我們把它作為imageView的容器。
public class PhotoBrowserCell: UICollectionViewCell {
/// 圖像加載視圖
public let imageView = UIImageView()
/// 內(nèi)嵌容器。本類不能繼承UIScrollView。
/// 因為實測UIScrollView遵循了UIGestureRecognizerDelegate協(xié)議,而本類也需要遵循此協(xié)議,
/// 若繼承UIScrollView則會覆蓋UIScrollView的協(xié)議實現(xiàn),故只內(nèi)嵌而不繼承。
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
}
}

什么時候進行cell布局?設(shè)置圖片后就應(yīng)該進行。
為什么這么迫切要立即刷新呢?其中有一個原因是下面講到的轉(zhuǎn)場動畫所需的,轉(zhuǎn)場動畫需要提前取到即將用于展示的cell。至于另外的原因,于情于理,數(shù)據(jù)確定后,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)
}
/// 設(shè)置圖片。image為placeholder圖片,url為網(wǎng)絡(luò)圖片
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()
}
}
現(xiàn)在我們讓圖片放大。
設(shè)計支持兩種縮放操作:
- 捏合手勢
- 雙擊縮放
捏合手勢:
CollectionView是UIScorllView的子類,UIScorllView天生支持pinch捏合手勢,只需要實現(xiàn)它的代理方法即可:
extension PhotoBrowserCell: UIScrollViewDelegate {
public func viewForZooming(in scrollView: UIScrollView) -> UIView? {
return imageView
}
public func scrollViewDidZoom(_ scrollView: UIScrollView) {
imageView.center = centerOfContentSize
}
}
viewForZooming方法可以告訴ScrollView在發(fā)生zooming時,對哪個視圖進行縮放;
然后我們需要在scrollViewDidZoom的時候,重新把圖片放在中間,這樣調(diào)整可以讓視覺更美觀、體驗更良好。
雙擊縮放:
有些用戶更樂意單手操作手機,而捏合手勢需要兩根手指,很難一只手完成操作。雖然通過捏合可以控制縮放比率,但有時候用戶要的僅僅是“把圖片放大一些,看看細節(jié)”這樣的需求,于是我們可以折衷一下,通過雙擊手勢把圖片固定放大到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)
}
}
轉(zhuǎn)場動畫
為了呈現(xiàn)合理的打開/關(guān)閉圖片瀏覽器效果,我們決定使用轉(zhuǎn)場動畫。
這里使用modal轉(zhuǎn)場,并且使用custom方式,方便靈活定制我們想要的效果。
我們想要怎樣的效果?
- 打開圖片瀏覽器時,要從小圖逐漸放大進入大圖瀏覽模式
- 關(guān)閉圖片瀏覽時,要從大圖模式逐漸縮小回原來小圖的位置

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

單擊圖片就關(guān)閉:
“單擊一下縮略圖,放大進行瀏覽;單擊一下大圖,縮小回去原圖”這是很自然的操作,我們來實現(xiàn)它:
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)
}
}
這里的注意點是單擊手勢和雙擊手勢會有沖突,此時我們需要設(shè)置一個相當(dāng)于優(yōu)先級的東西,優(yōu)先響應(yīng)雙擊手勢:
singleTap.require(toFail: doubleTap)
假如不寫這一行,即便用戶如何快速雙擊,都無法進入雙擊手勢響應(yīng)方法,因為單擊手勢會立即滿足條件,立即執(zhí)行。
寫了這一行后,單擊手勢會變得相對遲鈍一些,在確認沒有雙擊手勢發(fā)生時,單擊手勢才會生效。
還有一點細節(jié)要提的是,執(zhí)行dismiss應(yīng)該由controller類內(nèi)部代碼執(zhí)行,所以不應(yīng)該把controller傳值給cell,讓cell去調(diào)用controller的dismiss方法,這樣做cell就越權(quán)了。
所以這里我們通過代理,把單擊事件傳遞到cell外面去,讓controller自己進行dismiss。
按住圖片往下拽,松手即關(guān)閉:
這是個很有意思的效果,下拽圖片,讓圖片隨著下拽程度漸漸縮小,同時背景黑色蒙板漸變透明,可以看到之前的縮略圖界面,而且正在拖拽的圖片位置是空的,一松手圖片就歸位,給人的感受就是我們確實把這張小圖放大來看了。
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值區(qū)間[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)
// 通知代理,發(fā)生了縮放。代理可依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)
}
// 如果圖片當(dāng)前顯示的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
}
}
}
}
控制縮放比例:
// 由下拉的偏移值決定縮放比例,越往下偏移,縮得越小。scale值區(qū)間[0.3, 1.0]
let scale = min(1.0, max(0.3, 1 - translation.y / bounds.height))
當(dāng)往下拽的時候,是線性同時縮小寬度和高度,但是,有一個極限值,不允許縮小到原來的0.3倍以下。至于為什么是0.3,這是N多次實踐測試后的結(jié)果,這個數(shù)值可以有比較良好的視覺體驗...
跟隨手勢移動
當(dāng)手指按住圖片往下拖時,如果不改變圖片大小,可以非常簡單直接讓圖片下移translation.y的偏移量。但我們的情況略有麻煩,在改變圖片位置的同時,也改變著圖片的大小,這樣會導(dǎo)致手指在拖動時,圖片會縮著縮著跑出了手指的觸摸區(qū)。
我們得完善這個細節(jié),一輪計算,算出相對的位移量,讓圖片不會跑偏,永遠處于手指之下:
// 計算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的發(fā)生與取消:
當(dāng)松開手時,pan手勢是帶有速度向量屬性的,我們定義的發(fā)生dismiss的條件是”用戶往下拽的過程中松手“,而我們也允許用戶有后悔的機會,給他一個能取消的操作,就是重新往上拽回去時,可以取消dismiss:
case .ended, .cancelled:
if pan.velocity(in: self).y > 0 {
// dismiss
onSingleTap()
} else {
// 取消dismiss
endPan()
}
背景蒙板:
另外,在圖片縮放的過程中,背景蒙板也應(yīng)該隨著縮放比例而變化,我們把比例值通過代理傳遞到外界去,讓控制器使用:
// 通知代理,發(fā)生了縮放。代理可依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
}
}
隱藏/顯示關(guān)聯(lián)的縮略圖:
還有一個細節(jié)要處理,當(dāng)蒙板漸漸變得透明時,就看到底下的原頁面了,這時原頁面中有一個小圖視圖應(yīng)該要去掉/隱藏,這個小圖應(yīng)當(dāng)對應(yīng)于我們正在瀏覽的那個大圖。
對于隱藏小圖的處理,在上節(jié)中的轉(zhuǎn)場動畫協(xié)處理類持有并控制著當(dāng)前瀏覽大圖所關(guān)聯(lián)的小圖。
至于為什么這么費力地讓協(xié)處理類控制關(guān)聯(lián)小圖,而不是圖片瀏覽控制器,還是那個道理,各司其職,讓瀏覽器盡量只做瀏覽圖片的工作,況且小圖的隱藏/顯示與轉(zhuǎn)場動畫的相性更合。
在打開圖片瀏覽器時,所關(guān)聯(lián)的小圖就是用戶進入瀏覽器時所點的那一張,然后在瀏覽過程中,隨著collectionView左右滑動,關(guān)聯(lián)小圖就應(yīng)該相應(yīng)地立即更新:
public class ScaleAnimatorCoordinator: UIPresentationController {
/// 更新動畫結(jié)束后需要隱藏的view
public func updateCurrentHiddenView(_ view: UIView) {
currentHiddenView?.isHidden = false
currentHiddenView = view
view.isHidden = true
}
}
public class PhotoBrowser: UIViewController {
/// 當(dāng)前顯示的圖片序號,從0開始
fileprivate var currentIndex = 0 {
didSet {
animatorCoordinator?.updateCurrentHiddenView(relatedView)
if isShowPageControl {
pageControl.currentPage = currentIndex
}
}
}
/// 當(dāng)前正在顯示視圖的前一個頁面關(guān)聯(lián)視圖
fileprivate var relatedView: UIView {
return photoBrowserDelegate.photoBrowser(self, thumbnailViewForIndex: currentIndex)
}
}
extension PhotoBrowser: UICollectionViewDelegate {
/// 減速完成后,計算當(dāng)前頁
public func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
let offsetX = scrollView.contentOffset.x
let width = scrollView.bounds.width + photoSpacing
currentIndex = Int(offsetX / width)
}
}
PhotoBrowser維護著一個變量currentIndex,而relatedView即為所關(guān)聯(lián)的小圖,當(dāng)currentIndex變化時,協(xié)處理類應(yīng)立即同步新的relatedView為隱藏,舊的relatedView恢復(fù)顯示,保持狀態(tài)完整性。
加載網(wǎng)絡(luò)圖片
現(xiàn)在我們的圖片瀏覽器還剩下最后一個關(guān)鍵能力:支持加載網(wǎng)絡(luò)圖片。
這里使用著名的Swift網(wǎng)絡(luò)圖片加載框架Kingfisher,也是本庫唯一依賴框架。
public class PhotoBrowserCell: UICollectionViewCell {
/// 設(shè)置圖片。image為placeholder圖片,url為網(wǎng)絡(luò)圖片
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()
}
}
在加載過程中,我們需要一個友好的加載進度指示,即progressView,寫一個:
public class PhotoBrowserProgressView: UIView {
/// 進度
public var progress: CGFloat = 0 {
didSet {
fanshapedLayer.path = makeProgressPath(progress).cgPath
}
}
/// 外邊界
private var circleLayer: CAShapeLayer!
/// 扇形區(qū)
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)
}
...
}

隱藏狀態(tài)欄
圖片瀏覽過程中并不需要狀態(tài)欄StatusBar,應(yīng)當(dāng)隱藏。
iOS7后,能控制狀態(tài)欄的類有兩個,UIApplication和UIViewController,兩者只能取其一,默認情況下,由各UIViewController獨立控制自己的狀態(tài)欄。
于是,隱藏狀態(tài)欄就有兩種辦法:
- 重寫UIViewController的
prefersStatusBarHidden屬性/方法,并返回true來隱藏狀態(tài)欄 - 在
info.plist中取消UIViewController的控制權(quán),即設(shè)置View controller-based status bar appearance為NO,然后再設(shè)置UIApplication.shared.isStatusBarHidden = false
作為一個框架,不應(yīng)該設(shè)置全局屬性,不應(yīng)該操作UIApplication,而且從解耦角度來說就更不應(yīng)該了。所以我們只負責(zé)自己Controller視圖的狀態(tài)欄:
public override var prefersStatusBarHidden: Bool {
return true
}
然而這種做法,會導(dǎo)致一個問題:在使用pan手勢下拽圖片時,背景變半透明后,會看見底下頁面的狀態(tài)欄沒有了!這是因為背景半透明時,當(dāng)前ViewController依然還是圖片瀏覽器,而圖片瀏覽器控制著狀態(tài)欄隱藏。
我們或許會想著,在背景半透明時,刷新狀態(tài)欄,讓prefersStatusBarHidden返回false,在背景恢復(fù)全黑時,再刷新狀態(tài)欄,讓prefersStatusBarHidden返回true。
然而這種做法,還是會有問題,我們是不可以讓prefersStatusBarHidden直接就返回true的,因為說不定前一頁面的狀態(tài)欄本身就是隱藏的呢,我們這么做豈不是破壞了現(xiàn)場?
我們或許又會想到,那讓用戶,讓調(diào)用者在使用圖片瀏覽器的時候,告訴我們,它原本的狀態(tài)欄是隱藏還是不隱藏的,這樣不就解決了嗎?確實這樣好像能解決問題,但是不好的地方在于增加了用戶使用的難度,畢竟多加了一個參數(shù)。
不行!為了把讓用戶傻瓜式操作的理念貫徹到底,參數(shù)必須能少一個就少一個!我們來另想辦法。
其實我們的目的只是要讓狀態(tài)不要擋住圖片瀏覽,那么除了讓狀態(tài)欄本身隱藏掉,還有辦法就是蓋住它。
我們知道,狀態(tài)欄在視圖層上的level是非常高的,所以得讓我們的視圖level比它還要高,才有可能蓋住它。沒錯!就是設(shè)置windowLevel屬性為UIWindowLevelStatusBar + 1:
public class PhotoBrowser: UIViewController {
/// 保存原windowLevel
private var originWindowLevel: UIWindowLevel!
/// 遮蓋狀態(tài)欄。以改變windowLevel的方式遮蓋
fileprivate func coverStatusBar(_ cover: Bool) {
guard let window = view.window else {
return
}
if originWindowLevel == nil {
originWindowLevel = window.windowLevel
}
if cover {
if window.windowLevel == UIWindowLevelStatusBar + 1 {
return
}
window.windowLevel = UIWindowLevelStatusBar + 1
} else {
if window.windowLevel == originWindowLevel {
return
}
window.windowLevel = originWindowLevel
}
}
}
我們定義了一個coverStatusBar方法,讓它控制是否遮蓋狀態(tài)欄。而調(diào)用它的地方主要有三處:
- 頁面出現(xiàn)前
public override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
// 遮蓋狀態(tài)欄
coverStatusBar(true)
}
- 頁面消失前
public func photoBrowserCellDidSingleTap(_ view: PhotoBrowserCell) {
coverStatusBar(false)
dismiss(animated: true, completion: nil)
}
- 背景變半透明時
public func photoBrowserCell(_ view: PhotoBrowserCell, didPanScale scale: CGFloat) {
let alpha = scale * scale
// 半透明時重現(xiàn)狀態(tài)欄,否則遮蓋狀態(tài)欄
coverStatusBar(alpha >= 1.0)
}
頁碼指示器#
為了框架適用性,PhotoBrowser內(nèi)部并沒有內(nèi)嵌PageControl,而是以協(xié)議的方式支持裝配一個PageControl。
// MARK: - PhotoBrowserPageControl
public protocol PhotoBrowserPageControlDelegate {
/// 取PageControl,只會取一次
func pageControlOfPhotoBrowser(_ photoBrowser: PhotoBrowser) -> UIView
/// 添加到父視圖上時調(diào)用
func photoBrowserPageControl(_ pageControl: UIView, didMoveTo superView: UIView)
/// 讓pageControl布局時調(diào)用
func photoBrowserPageControl(_ pageControl: UIView, needLayoutIn superView: UIView)
/// 頁碼變更時調(diào)用
func photoBrowserPageControl(_ pageControl: UIView, didChangedCurrentPage currentPage: Int)
}
同時為了方便使用,我提供了兩個寫好的實現(xiàn)了PhotoBrowserPageControlDelegate協(xié)議的類,它們分別是:
/// 給圖片瀏覽器提供一個UIPageControl
public class PhotoBrowserDefaultPageControlDelegate: PhotoBrowserPageControlDelegate

- ```swift
/// 給圖片瀏覽器提供一個數(shù)字樣式的PageControl
public class PhotoBrowserNumberPageControlDelegate: PhotoBrowserPageControlDelegate

使用方法是裝配式的,只需為圖片瀏覽器指定代理即可:
let vc = PhotoBrowser(showByViewController: self, delegate: self)
// 裝配PageControl,這里示例隨機選擇一種PageControl實現(xiàn)
if arc4random_uniform(2) % 2 == 0 {
vc.pageControlDelegate = PhotoBrowserDefaultPageControlDelegate(numberOfPages: imageArray.count)
} else {
vc.pageControlDelegate = PhotoBrowserNumberPageControlDelegate(numberOfPages: imageArray.count)
}
vc.show(index: indexPath.item)
如果框架的兩個樣式都無法滿足需求時,也可自己實現(xiàn)PageControl協(xié)議,自由定制。
CocoaPods
已上傳CocoaPods,現(xiàn)可直接導(dǎo)入:
pod 'JXPhotoBrowser'
源碼
GitHub地址: PhotoBrowser
若使用過程中有任何問題,請拼命issues我。 _