如何寫出一個絲滑的圖片瀏覽器

緣起

那時,我想要一個這樣的圖片瀏覽器:

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

很遺憾,久尋無果,于是我決定自己造一個。

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
    }
}
CollectionView布局.gif

圖間空隙與邊緣吸附

注意左右兩張圖片之間是有空隙的,這是個難點。
先讓空隙數(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才能正常工作。

  1. 假如給layout類設(shè)置spacing作為圖間隙,則collectionView的contentSize.width值為圖片數(shù)量x屏寬+(圖片數(shù)量-1)x間隙,并非頁寬的整倍數(shù)。
  2. 假如把空隙嵌入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
    }
}
大圖瀏覽.gif

什么時候進行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)閉圖片瀏覽時,要從大圖模式逐漸縮小回原來小圖的位置
Transition-Animation.gif

在轉(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)閉
Dismissal.gif

單擊圖片就關(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)
    }
    ...
}
加載圖絡(luò)圖片.gif

隱藏狀態(tài)欄

圖片瀏覽過程中并不需要狀態(tài)欄StatusBar,應(yīng)當(dāng)隱藏。
iOS7后,能控制狀態(tài)欄的類有兩個,UIApplicationUIViewController,兩者只能取其一,默認情況下,由各UIViewController獨立控制自己的狀態(tài)欄。
于是,隱藏狀態(tài)欄就有兩種辦法:

  • 重寫UIViewController的prefersStatusBarHidden屬性/方法,并返回true來隱藏狀態(tài)欄
  • info.plist中取消UIViewController的控制權(quán),即設(shè)置View controller-based status bar appearanceNO,然后再設(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)用它的地方主要有三處:

  1. 頁面出現(xiàn)前
    public override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)
        // 遮蓋狀態(tài)欄
        coverStatusBar(true)
    }
  1. 頁面消失前
    public func photoBrowserCellDidSingleTap(_ view: PhotoBrowserCell) {
        coverStatusBar(false)
        dismiss(animated: true, completion: nil)
    }
  1. 背景變半透明時
    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


![UIPageControl.png](http://upload-images.jianshu.io/upload_images/2419179-dd60f14462ea5114.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)

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

使用方法是裝配式的,只需為圖片瀏覽器指定代理即可:

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我。 _

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容