(iOS)帶你寫一個類似MJRefresh的上下拉重新整理控制元件

ZeroJ發表於2016-08-23

在iOS開發中, 上下拉載入的重新整理動畫大多數的APP都會採用基本相似的樣式和動畫, 當然還是有很多優秀的載入動畫, 不過這些動畫在國內的APP中真的是很少看到使用(感覺比較新穎的東西都很少是國人自己首先實現的...), 在使用oc的時候, 相信很多的開發者都會選擇MJRefresh來整合上下拉重新整理, 這個優秀的載入框架很方便的實現了常見的載入需求, 同時, 因為其是使用系統的UIImageView來實現gif圖片的播放, 那麼就可以很方便的直接利用設計給的gif動畫圖片來實現上下拉載入動畫. 因為現在的筆者開發使用swift的時間比較多了, 很多的東西還是比較希望使用swift實現的. 像重新整理控制元件, 也希望使用個swift的, 於是自己動手也實現了一個, 在使用上儘量是接近了MJRefresh的, 不過, 如果你去比較的話, 和MJRefresh的效果,靈活度等相似, 但是程式碼量相差很大, 筆者這個主要檔案一個程式碼量不到400行, 如果你要借鑑的話, 很是方便. 然後需要說明的是, 在oc中提倡使用繼承來實現很多東西, 不過swift提倡面向協議程式設計, 所以這次我也是用協議來實現的.Demo地址(這個是在草原旅行的路上坐車寫的, 草原的風光最近真的不錯)

使用效果:

實現原理:

其實仔細想想, 上下拉重新整理的原理還是很簡單的 ------>>> 首先把重新整理控制元件新增到scrollView的頭部或者底部, 然後監控到scrollView的滾動進度(底部重新整理控制元件還需要監控scrollView的內容的改變, 每次改變後再次將控制元件調整到scrollView的底部), 根據不同的進度來設定重新整理控制元件的相應的文字和圖片動畫等...

實現過程:
  • 首先寫一個scrollView的分類, 在分類中給scrollView新增兩個屬性zj_refreshHeaderzj_refreshFooter用來存取header和footer重新整理控制元件, 這裡有兩種方法可以實現 1, 使用執行時
private var ZJHeaderKey: UInt8 = 0
private var ZJFooterKey: UInt8 = 0

extension UIScrollView {

    private var zj_refreshHeader: RefreshView? {
        set {
            objc_setAssociatedObject(self, &ZJHeaderKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
        }

        get {
            return objc_getAssociatedObject(self, &ZJHeaderKey) as? RefreshView
        }
    }
    private var zj_refreshFooter: RefreshView? {
        set {
            objc_setAssociatedObject(self, &ZJFooterKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
        }

        get {
            return objc_getAssociatedObject(self, &ZJFooterKey) as? RefreshView
        }
    }
}複製程式碼

2, 使用tag來存取

private var ZJHeaderTag = 1994
private var ZJFooterTag = 1995
extension UIScrollView {

    private var zj_refreshHeader: RefreshView? {
        set {
            if let header = newValue {
                header.tag = ZJHeaderTag
                addSubview(header)
            }
        }

        get {
            return viewWithTag(ZJHeaderTag) as? RefreshView
        }
    }
    private var zj_refreshFooter: RefreshView? {
        set {
            if let footer = newValue {
                footer.tag = ZJFooterTag
                addSubview(footer)
            }
        }

        get {
            return viewWithTag(ZJFooterTag) as? RefreshView
        }
    }
 }複製程式碼
  • 然後在分類中給出使用header和footer的方法, 注意看, 這裡我使用了一點swift中強大的泛型和型別約束, <Animator where Animator: UIView, Animator: RefreshViewDelegate> 這個就是約束Animator必須是UIView並且遵守RefreshViewDelegate協議的型別
    public func zj_addRefreshHeader(headerAnimator: Animator, refreshHandler: RefreshHandler ) {
}
    public func zj_addRefreshFooter(footerAnimator: Animator, refreshHandler: RefreshHandler ) {
}複製程式碼
  • 接著提供開啟和結束重新整理動畫的方法
    /// 開啟header重新整理
    public func zj_startHeaderAnimation() {
        zj_refreshHeader?.canBegin = true
    }
    /// 結束header重新整理
    public func zj_stopHeaderAnimation() {
        zj_refreshHeader?.canBegin = false
    }
    /// 開啟footer重新整理
    public func zj_startFooterAnimation() {
        zj_refreshFooter?.canBegin = true
    }
    /// 結束footer重新整理
    public func zj_stopFooterAnimation() {
        zj_refreshFooter?.canBegin = false
    }複製程式碼
  • 然後是RefreshView的實現, 在筆者的實現中, RefreshView是新增到scrollView的頂部或者底部來作為真正的重新整理控制元件的容器
  • 重新整理控制元件的狀態: 實際上控制元件有四種狀態
public enum RefreshViewState {
    /// 正在載入狀態
    case loading
    /// 正常狀態
    case normal
    /// 下拉狀態
    case pullToRefresh
    /// 鬆開手即進入重新整理狀態
    case releaseToFresh
}複製程式碼
  • 1, 正常狀態, 即未開始和已經結束的狀態.
  • 2, 拖拽狀態, 這個時候拖拽的進度小於1, 如果繼續拖拽直到拖拽進度等於(>)1的時候, 進入下一種狀態.
  • 3, 鬆手即進入重新整理的狀態, 這個時候鬆開手才能進入下一個狀態, 如果不鬆開手, 向反方向拖拽, 則拖拽進度會減小, 如果進度<1, 則會進入上一個狀態 ...
  • 4, 載入動畫狀態, 這個時候進入載入狀態, 知道收到 結束動畫的指定, 才結束重新整理動畫進入正常狀態等待
下拉重新整理
  • 首先將重新整理控制元件新增到scrollView的頂部(在scrollView的分類方法中新增)
    ///
    public func zj_addRefreshHeader(headerAnimator: Animator, refreshHandler: RefreshHandler ) {
        if let header = zj_refreshHeader {
            header.removeFromSuperview()
        }
        ///
        let frame = CGRect(x: 0.0, y: -headerAnimator.bounds.height, width: bounds.width, height: headerAnimator.bounds.height)
        zj_refreshHeader = RefreshView(frame: frame, refreshType: .header, refreshAnimator: headerAnimator, refreshHandler: refreshHandler)
        addSubview(zj_refreshHeader!)

    }複製程式碼
  • 然後需要監控scrollView的滾動(利用Cocoa強大的kvo機制)
    private func addObserverOf(scrollView: UIScrollView?) {
        scrollView?.addObserver(self, forKeyPath: ConstantValue.ScrollViewContentOffsetPath, options: .Initial, context: &ConstantValue.RefreshViewContext)

    }複製程式碼

  • 在scrollView的滾動過程中, 根據滾動的偏移量來計算出拖拽的進度, 然後計算出對應的header的狀態, 根據不同的狀態來相應的調整不同的UI或者動畫
        if scrollView.contentOffset.y > -scrollViewOriginalValue.contentInset.top {/**頭部檢視(隱藏)並且還沒到顯示的臨界點*/ return }

        // 已經進入拖拽狀態, 進行相關操作
        let progress = (-scrollViewOriginalValue.contentInset.top - scrollView.contentOffset.y) / self.bounds.height

        if scrollView.tracking {

            if progress >= 1.0 {
                refreshViewState = .releaseToFresh

            } else if progress <= 2="" 0.0="" {="" refreshviewstate=".normal" }="" else="" if="" .releasetofresh="" releasetofreah="" refresh="" canbegin="true//" begin="" release="" progress="" <="0.0" var="" actualprogress="min(1.0," progress)="" actualprogress)="" refreshanimator.refreshdidchangeprogress(self,="" progress:="" actualprogress,="" refreshviewtype:="" refreshviewtype)<="" code="">=>複製程式碼
  • 開始和停止動畫的處理, 這個時候需要調整scrollView的contentInset ----> 注意這裡需要了解scrollView的三大屬性 contentInset, contentOffset, contentSize (這裡就省略介紹了)

開始動畫的時候, 因為重新整理控制元件是新增到scrollView的頭部或者底部的, 在滾動的時候因為scrollView的bounces的原因, 鬆開手之後, 重新整理控制元件是會回到原來的位置的, 這個時候, 我們希望載入動畫的時候, 重新整理控制元件停在我們的實現之內, 所以需要調整scrollView的contentInset(會自動調整contentOffset), 比如下拉重新整理需要將contentInset的top加上重新整理控制元件的高度, 上拉重新整理的時候需要將contentInset的bottom加上重新整理控制元件的高度

    private func startAnimation() {
        guard let validScrollView = scrollView else { return }
        validScrollView.bounces = false
        /// may update UI
        dispatch_async(dispatch_get_main_queue(), {[weak self] in
            guard let validSelf = self else { return }

            UIView.animateWithDuration(0.25, animations: {
                if validSelf.refreshViewType == .header {
                    validScrollView.contentInset.top = validSelf.scrollViewOriginalValue.contentInset.top + validSelf.bounds.height
                } else {
                    let offPartHeight = validScrollView.contentSize.height - validSelf.heightOfContentOnScreenOfScrollView(validScrollView)
                    /// contentSize改變的時候設定的self.y不同導致不同的結果
                    /// 所有內容高度>螢幕上顯示的內容高度
                    let notSureBottom = validSelf.scrollViewOriginalValue.contentInset.bottom + validSelf.bounds.height
                    validScrollView.contentInset.bottom = offPartHeight>=0 ? notSureBottom : notSureBottom - offPartHeight // 加上

                }

                }, completion: { (_) in
                    /// 這個時候才正式重新整理
                    validScrollView.bounces = true
                    validSelf.refreshViewState = .loading
                    validSelf.refreshHandler()
            })

            })

    }複製程式碼

停止動畫的時候, 需要將scrollView的contentInset復原為動畫開始之前, 以便於不影響頁面的其他佈局

  • 對於上拉重新整理而言, 只是要多一個監控scrollView的contentSize, 在其改變的時候再次將重新整理控制元件調整到scrollView的contentSize的底部

  • RefreshViewDelegate的定義

public protocol RefreshViewDelegate {
    /// 你應該為每一個header或者footer設定一個不同的key來儲存時間, 否則將公用同一個key使用相同的時間
    var lastRefreshTimeKey: String? { get }
    /// 是否重新整理完成後自動隱藏 預設為false
    var isAutomaticlyHidden: Bool { get }
    /// 上次重新整理時間, 有預設賦值和返回
    var lastRefreshTime: NSDate? { get set }
    /// repuired 三個必須實現的代理方法

    /// 開始進入重新整理(loading)狀態, 這個時候應該開啟自定義的(動畫)重新整理
    func refreshDidBegin(refreshView: RefreshView, refreshViewType: RefreshViewType)

    /// 重新整理結束狀態, 這個時候應該關閉自定義的(動畫)重新整理
    func refreshDidEnd(refreshView: RefreshView, refreshViewType: RefreshViewType)

    /// 重新整理狀態變為新的狀態, 這個時候可以自定義設定各個狀態對應的屬性
    func refreshDidChangeState(refreshView: RefreshView, fromState: RefreshViewState, toState: RefreshViewState, refreshViewType: RefreshViewType)

    /// optional 兩個可選的實現方法
    /// 允許在控制元件新增到scrollView之前的準備
    func refreshViewDidPrepare(refreshView: RefreshView, refreshType: RefreshViewType)

    /// 拖拽的進度, 可用於自定義實現拖拽過程中的動畫
    func refreshDidChangeProgress(refreshView: RefreshView, progress: CGFloat, refreshViewType: RefreshViewType)

}複製程式碼
  • 最後是自己繼承 RefreshViewDelegate實現自定義的載入, 這裡, 筆者提供了兩種使用例項(程式碼佈局和xib), 這兩種能夠完成MJRefresh提供的使用效果, 當然, 更靈活的自定義方式, 你可以自己隨意實現, 具體的你可以參見demo中的示例, 這裡只貼一點程式碼出來
public class NormalAnimator: UIView {
    /// 設定imageView
    @IBOutlet private(set) weak var imageView: UIImageView!
    @IBOutlet private(set) weak var indicatorView: UIActivityIndicatorView!
    /// 設定state描述
    @IBOutlet private(set) weak var descriptionLabel: UILabel!
    /// 上次重新整理時間label footer 預設為hidden, 可設定hidden=false開啟
    @IBOutlet private(set) weak var lastTimelabel: UILabel!

    public typealias SetDescriptionClosure = (refreshState: RefreshViewState, refreshType: RefreshViewType) -> String
    public typealias SetLastTimeClosure = (date: NSDate) -> String


    /// 是否重新整理完成後自動隱藏 預設為false
    /// 這個屬性是協議定義的, 當寫在class裡面可以供外界修改, 如果寫在extension裡面只能是可讀的
    public var isAutomaticlyHidden: Bool = false

    private var setupDesctiptionClosure: SetDescriptionClosure?
    private var setupLastTimeClosure: SetLastTimeClosure?
    /// 耗時
    private lazy var formatter: NSDateFormatter = {
       let formatter = NSDateFormatter()
        formatter.dateStyle = .ShortStyle
        return formatter
    }()
    /// 耗時
    private lazy var calendar: NSCalendar = NSCalendar.currentCalendar()

    public class func normalAnimator() -> NormalAnimator {
        return NSBundle.mainBundle().loadNibNamed(String(NormalAnimator), owner: nil, options: nil).first as! NormalAnimator
    }


    public func setupDescriptionForState(closure: SetDescriptionClosure) {
        setupDesctiptionClosure = closure
    }

    public func setupLastFreshTime(closure: SetLastTimeClosure) {
        setupLastTimeClosure = closure
    }

    override public func awakeFromNib() {
        super.awakeFromNib()
        indicatorView.hidden = true
        indicatorView.hidesWhenStopped = true
    }

//    public override func layoutSubviews() {
//        super.layoutSubviews()
//        print("layout--------------------------------------------")
//    }
}

extension NormalAnimator: RefreshViewDelegate {

    public func refreshViewDidPrepare(refreshView: RefreshView, refreshType: RefreshViewType) {
        if refreshType == .header {
        } else {
            lastTimelabel.hidden = true
            rotateArrowToUpAnimated(false)
        }
        setupLastTime()

    }

    public func refreshDidBegin(refreshView: RefreshView, refreshViewType: RefreshViewType) {
        indicatorView.hidden = false
        indicatorView.startAnimating()
    }
    public func refreshDidEnd(refreshView: RefreshView, refreshViewType: RefreshViewType) {
        indicatorView.stopAnimating()
    }
    public func refreshDidChangeProgress(refreshView: RefreshView, progress: CGFloat, refreshViewType: RefreshViewType) {
        //        print(progress)

    }

    public func refreshDidChangeState(refreshView: RefreshView, fromState: RefreshViewState, toState: RefreshViewState, refreshViewType: RefreshViewType) {
        print(toState)

        setupDescriptionForState(toState, type: refreshViewType)
        switch toState {
        case .loading:
            imageView.hidden = true
        case .normal:

            setupLastTime()
            imageView.hidden = false
            ///恢復
            if refreshViewType == .header {
                rotateArrowToDownAnimated(false)

            } else {
                rotateArrowToUpAnimated(false)
            }

        case .pullToRefresh:
            if refreshViewType == .header {

                if fromState == .releaseToFresh {
                    rotateArrowToDownAnimated(true)
                }

            } else {

                if fromState == .releaseToFresh {
                    rotateArrowToUpAnimated(true)
                }
            }
            imageView.hidden = false

        case .releaseToFresh:

            imageView.hidden = false
            if refreshViewType == .header {
                rotateArrowToUpAnimated(true)
            } else {
                rotateArrowToDownAnimated(true)
            }
        }
    }

    private func setupDescriptionForState(state: RefreshViewState, type: RefreshViewType) {
        if descriptionLabel.hidden {
            descriptionLabel.text = ""
        } else {
            if let closure = setupDesctiptionClosure {
                descriptionLabel.text = closure(refreshState: state, refreshType: type)
            } else {
                switch state {
                case .normal:
                    descriptionLabel.text = "正常狀態"
                case .loading:
                    descriptionLabel.text = "載入資料中..."
                case .pullToRefresh:
                    if type == .header {
                        descriptionLabel.text = "繼續下拉重新整理"
                    } else {
                        descriptionLabel.text = "繼續上拉重新整理"
                    }
                case .releaseToFresh:
                    descriptionLabel.text = "鬆開手重新整理"

                }
            }
        }
    }
 }複製程式碼
  • 使用方法 NormalAnimator
        let normal = NormalAnimator.normalAnimator()
                /// 指定儲存重新整理時間的key, 如果不指定或設定為nil, 那麼將會和其他未指定的使用相同的key(記錄的時間相同, MJRefresh是所有的控制元件使用相同的時間的)
        normal.lastRefreshTimeKey = "DemoKey1"

        /// 隱藏時間顯示
//        normal.lastTimelabel.hidden = true


        /// 自定義提示文字
//        normal.setupDescriptionForState { (refreshState,refreshType) -> String in
//            switch refreshState {
//            case .loading:
//                return "努力載入中"
//            case .normal:
//                return "休息中"
//            case .pullToRefresh:
//                if refreshType == .header {
//                    return "繼續下下下下"
//
//                } else {
//                    return "繼續上上上上"
//                }
//            case .releaseToFresh:
//                return "放開我"
//            };
//        }

        /// 自定義時間顯示
//        normal.setupLastFreshTime { (date) -> String in
//            return ...
//        }

        tableView.zj_addRefreshHeader(normal, refreshHandler: {[weak self] in
            /// 多執行緒中不要使用 [unowned self]
            /// 注意這裡的gcd是為了模擬網路載入的過程, 在實際的使用中, 不需要這段gcd程式碼, 直接在這裡進行網路請求, 在請求完畢後, 呼叫分類方法, 結束重新整理
            dispatch_async(dispatch_get_global_queue(0, 0), { 
                for i in 0...50000 {
                    if i <= 10="" {="" self?.data.append(i)="" }="" 延時="" print("載入資料中")="" dispatch_async(dispatch_get_main_queue(),="" self?.tableview.reloaddata()="" 重新整理完畢,="" 停止動畫="" self?.tableview.zj_stopheaderanimation()="" })="" })<="" code="">=>複製程式碼
  • GifAnimator的使用
/// 設定高度
let gifAnimatorHeader = GifAnimator.gifAnimatorWithHeight(100.0)
        gifAnimatorHeader.lastRefreshTimeKey = "exampleHeader4"

        /// 為不同的state設定不同的圖片
        /// 閉包需要返回一個元組: 圖片陣列和gif動畫每一幀的執行時間
        /// 一般需要設定loading狀態的圖片(必須), 作為載入的gif
        /// 和pullToRefresh狀態的圖片陣列(可選擇設定), 作為拖拽時的載入動畫
        gifAnimatorHeader.setupImagesForRefreshstate { (refreshState) -> (images: [UIImage], duration: Double)? in
            if refreshState == .loading {
                var images = [UIImage]()
                for index in 1...47 {
                    let image = UIImage(named: "loading\\(index)")!
                    images.append(image)
                }
                return (images, 1.0)
            }
            else if  refreshState == .pullToRefresh {
                var images = [UIImage]()
                for index in 1...47 {
                    let image = UIImage(named: "loading\\(index)")!
                    images.append(image)
                }
                return (images, 0.25)
            }
            return nil
        }

        tableView.zj_addRefreshHeader(gif, refreshHandler: {[weak self] in
            /// 多執行緒中不要使用 [unowned self]
            /// 注意這裡的gcd是為了模擬網路載入的過程, 在實際的使用中, 不需要這段gcd程式碼, 直接在這裡進行網路請求, 在請求完畢後, 呼叫分類方法, 結束重新整理
            dispatch_async(dispatch_get_global_queue(0, 0), { 
                for i in 0...50000 {
                    if i <= 10="" {="" self?.data.append(i)="" }="" 延時="" print("載入資料中")="" dispatch_async(dispatch_get_main_queue(),="" self?.tableview.reloaddata()="" 重新整理完畢,="" 停止動畫="" self?.tableview.zj_stopheaderanimation()="" })="" })<="" code="">=>複製程式碼
  • 或者你可以將這些自定義的設定移到另外新建的class中, 例如
class TestNormal {
    class func normal() -> NormalAnimator {
        let normal = NormalAnimator.normalAnimator()
                /// 隱藏時間顯示
//        normal.lastTimelabel.hidden = true
        /// 指定儲存重新整理時間的key, 如果不指定或設定為nil, 那麼將會和其他未指定的使用相同的key(記錄的時間相同, MJRefresh是所有的控制元件使用相同的時間的)
        normal.lastRefreshTimeKey = "DemoKey1"
        normal.setupDescriptionForState({ (refreshState ,refreshType) -> String in
            switch refreshState {
            case .loading:
                return "努力載入中"
            case .normal:
                return "休息中"
            case .pullToRefresh:
                if refreshType == .header {
                    return "繼續下下下下"

                } else {
                    return "繼續上上上上"
                }
            case .releaseToFresh:
                return "放開我"
            }
        })
        return normal
    }
}


/// 使用方法
        let footer = TestNormal.normal()
        tableView.zj_addRefreshFooter(footer) {[weak self] in

            dispatch_async(dispatch_get_global_queue(0, 0), {
                for i in 0...50000 {
                    if i <= 10="" {="" self?.data.append(i)="" }="" 延時="" print("載入資料中")="" dispatch_async(dispatch_get_main_queue(),="" self?.tableview.reloaddata()="" self?.tableview.zj_stopfooteranimation()="" })="" }<="" code="">=>複製程式碼

總的來說, 簡單寫一個重新整理控制元件還是很簡單的, 但是在實現的過程中有很多的細節需要調整, 比如重新整理的時候要處理sectionHeader的懸停問題... (這裡直接借鑑了MJRefresh中的處理了), Demo地址

相關文章