授權轉載,作者:ZeroJ
在iOS開發中, 上下拉加載的刷新動畫大多數的APP都會采用基本相似的樣式和動畫, 當然還是有很多優秀的加載動畫, 不過這些動畫在國內的APP中真的是很少看到使用(感覺比較新穎的東西都很少是國人自己首先實現的...), 在使用oc的時候, 相信很多的開發者都會選擇MJRefresh來集成上下拉刷新, 這個優秀的加載框架很方便的實現了常見的加載需求, 同時, 因為其是使用系統的UIImageView來實現gif圖片的播放, 那麼就可以很方便的直接利用設計給的gif動畫圖片來實現上下拉加載動畫. 因為現在的筆者開發使用swift的時間比較多了, 很多的東西還是比較希望使用swift實現的。像刷新控件, 也希望使用個swift的, 於是自己動手也實現了一個, 在使用上盡量是接近了MJRefresh的, 不過, 如果你去比較的話, 和MJRefresh的效果,靈活度等相似, 但是代碼量相差很大, 筆者這個主要文件一個代碼量不到400行, 如果你要借鑒的話, 很是方便。然後需要說明的是, 在oc中提倡使用繼承來實現很多東西, 不過swift提倡面向協議編程, 所以這次我也是用協議來實現的。Demo地址(這個是在草原旅行的路上坐車寫的, 草原的風光最近真的不錯)
使用效果:
實現原理:
其實仔細想想, 上下拉刷新的原理還是很簡單的 ------>>> 首先把刷新控件添加到scrollView的頭部或者底部, 然後監控到scrollView的滾動進度(底部刷新控件還需要監控scrollView的內容的改變, 每次改變後再次將控件調整到scrollView的底部), 根據不同的進度來設置刷新控件的相應的文字和圖片動畫等...
實現過程:
首先寫一個scrollView的分類, 在分類中給scrollView添加兩個屬性zj_refreshHeader和zj_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必須是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 <= 0.0 { refreshViewState = .normal } else { refreshViewState = .pullToRefresh } } else if refreshViewState == .releaseToFresh {// releaseToFreah 2 refresh canBegin = true// begin refresh } else {// release if progress <= 0.0 { refreshViewState = .normal } } var actualProgress = min(1.0, progress) actualProgress = max(0.0, actualProgress) refreshAnimator.refreshDidChangeProgress(self, progress: actualProgress, refreshViewType: refreshViewType)
開始和停止動畫的處理, 這個時候需要調整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() }) }) })
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() }) }) })
或者你可以將這些自定義的設置移到另外新建的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() }) }) }
總的來說, 簡單寫一個刷新控件還是很簡單的, 但是在實現的過程中有很多的細節需要調整, 比如刷新的時候要處理sectionHeader的懸停問題... (這裡直接借鑒了MJRefresh中的處理了), Demo地址。