難度:2星
效果:
餓了麼App在最近版本上線了一個新的活動會場進入方式,沒錯兒,就是類似 (clone) 於淘寶首頁的下拉刷新-繼續下拉進入活動會場。這對我們本身就已經很復雜的View Hierachy提出了不小的挑戰。本篇文章帶你一步一步解析這樣的全屏下拉、普通下拉刷新的實現方式。
Swift Version 3.0
Xcode 8.1
默認縮進 2空格
首先我們想要的效果是:
self.tableView.showPullPromotion = true
這一行代碼就能啟用整個下拉刷新,那麼就需要一個 UIScrollView
的 extension (aka category in objc).
其次,整個一屏顯示的 UIImageView
的層次處於 UIScrollView
中,勢必需要為 UIScrollView
動態添加這麼一個用於顯示圖片的自定義 View,我定義其為:
class PullPromotionView: UIView { weak var scrollView:UIScrollView? convenience init() { var rect = UIScreen.main.bounds rect.origin.y = -rect.size.height self.init(frame:rect) commonInit() } func commonInit() { loadImageView() } }
在 PullPromotionView
裡定義了一個指向其所在的 scrollView的弱引用,這個引用將被用於:PullPromotionView
作為 scrollView 的 Observer,監聽其 contentOffset變化的同時判斷下拉的狀態。
這時候我們添加一個 UIScrollView
的 extension:
private var PULL_REFRESH_PROPERTY = 0 extension UIScrollView { var pullPromotionView:PullPromotionView? { get { return getPullPromotionView() } set { setPullPromotionView(view: newValue) } } func getPullPromotionView() -> PullPromotionView? { let view = objc_getAssociatedObject(self, &PULL_REFRESH_PROPERTY) if view == nil { createPullPromotionView() } return objc_getAssociatedObject(self, &PULL_REFRESH_PROPERTY) as? PullPromotionView } func setPullPromotionView(view:PullPromotionView?) { self.willChangeValue(forKey: NSStringFromSelector(#selector(getter: pullPromotionView))) objc_setAssociatedObject(self, &PULL_REFRESH_PROPERTY, view, objc_AssociationPolicy.OBJC_ASSOCIATION_RETAIN_NONATOMIC) self.didChangeValue(forKey: NSStringFromSelector(#selector(getter: pullPromotionView))) } func createPullPromotionView() { let view = PullPromotionView() self.addSubview(view) view.scrollView = self view.layer.zPosition = 1 setPullPromotionView(view: view) } }
因為我們要在 extension中添加一個PullPromotionView
作為property,所以需要使用 runtime動態地去執行,復寫一下 getter 和 setter 就 OK了, 這樣通過 self.pullPromotionView
引用的就是 property,可以正確地幫我們保存各種上下文參數。然後我們在這個 extension中添加一個可以幫我們一行代碼啟用的 property:
var showPullPromotion:Bool { get { return self.pullPromotionView!.isHidden } set { self.pullPromotionView?.isHidden = !newValue if !self.pullPromotionView!.isObserving { self.addObserver(self.pullPromotionView!, forKeyPath: NSStringFromSelector(#selector(getter: contentOffset)), options: NSKeyValueObservingOptions.new, context: nil) } else if self.pullPromotionView!.isObserving { self.removeObserver(self.pullPromotionView!, forKeyPath: NSStringFromSelector(#selector(getter: contentOffset))) } } }
需要在
PullPromotionView
中添加一個名為 isObserving 的 Bool類型 property
通過上面的代碼可以看到,我們在設置 self.tableView.showPullPromotion = true
的時候同時改變 pullPromotionView 的可見性和它的 Obsever。Observer被設置為這個 pullPromotionView,那麼我們就可以在 PullPromotionView
裡面實現和控制整個 UIScrollView
了。
為了表示下拉的狀態,我定義了一個枚舉:
enum PullPromotionState { case stopped //停止狀態 case refreshTriggered //觸發刷新 case promotionTriggered //觸發全屏下拉 case refreshing //刷新中 case promotionShowing //全屏滑動顯示中 }
有了這個狀態的定義,我為 PullPromotionView
添加了一個表示狀態的 property,並且在監聽到 scrollView的 contentOffset變化時改變這個狀態.
let RefreshTriggerHeight:CGFloat = 70 let PromotionTirggerHeight:CGFloat = 100 class PullPromotionView: UIView { ...... ...... override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) { if keyPath == "contentOffset" { let point = change?[NSKeyValueChangeKey.newKey] as! CGPoint scrollViewDidScroll(to: point) } } func scrollViewDidScroll(to contentOffset:CGPoint) { if self.state == .refreshing { return } let scrollOffsetRefreshHold = -RefreshTriggerHeight let scrollOffsetPromoteHold = -PromotionTirggerHeight if !self.scrollView!.isDragging && self.state == .refreshTriggered { self.state = .refreshing } else if !self.scrollView!.isDragging && self.state == .promotionTriggered { self.state = .promotionShowing } else if contentOffset.y < scrollOffsetRefreshHold && contentOffset.y > scrollOffsetPromoteHold && self.scrollView!.isDragging && self.state == .stopped { self.state = .refreshTriggered } else if contentOffset.y < scrollOffsetPromoteHold && (self.state == .stopped || self.state == .refreshTriggered) && self.scrollView!.isDragging { self.state = .promotionTriggered } else if contentOffset.y >= scrollOffsetRefreshHold && self.state != .stopped { self.state = .stopped } } }
幾個狀態的觸發點分別是(根據代碼由上至下):
scrollView不在拖動中,前一個狀態是觸發刷新。 => 刷新中
scrollView不在拖動中,前一個狀態是觸發全屏下拉。 => 全屏滑動顯示中
scrollView 的滑動距離大於刷新觸發,小於全屏下拉觸發, 在拖動中,前一個狀態是停止 => 觸發刷新
scrollView 的滑動距離大於全屏觸發點,前一個狀態是停止或下拉刷新被出阿發,在拖動中 => 全屏下拉被觸發
scrollView 的滑動距離小於刷新觸發,前一個狀態非停止狀態 => 停止
在設置狀態時,我們同時要更改一些 UI的顯示,如下:
typealias CallBack = () -> Void var _state:PullPromotionState = .stopped var state:PullPromotionState { get { return _state } set { if _state == newValue { return } dispatchState(state: newValue) } } var refreshAction:CallBack? var promotionAction:CallBack? func dispatchState(state:PullPromotionState) { let previousState = _state _state = state switch state { case .refreshing: setScrollViewForRefreshing() if previousState == .refreshTriggered { //do refresh action if self.refreshAction != nil { self.refreshAction!() } } break case .promotionShowing: setScrollViewForPromotion() //do show promotion action if self.promotionAction != nil { self.promotionAction!() } break case .stopped: resetScrollView() break default: break } }
具體可以解釋為不同狀態下 scrollView需要有不同的 contentInset 和 contentOffset, setScrollViewForRefreshing()
/setScrollViewForPromotion()
/resetScrollView()
三個方法的實現如下:
func setScrollViewForRefreshing() { var currentInset = self.scrollView?.contentInset currentInset?.top = RefreshTriggerHeight let offset = CGPoint(x: self.scrollView!.contentOffset.x, y: -currentInset!.top) animateScrollView(contentInset: currentInset!, contentOffset: offset, animationDuration: 0.2) } func setScrollViewForPromotion() { var currentInset = self.scrollView?.contentInset currentInset?.top = self.bounds.size.height let offset = CGPoint(x: self.scrollView!.contentOffset.x, y: -currentInset!.top) self.scrollView?.contentInset = currentInset! self.scrollView?.setContentOffset(offset, animated: true) } func resetScrollView() { var currentInset = self.scrollView?.contentInset currentInset?.top = 0 let offset = CGPoint(x: self.scrollView!.contentOffset.x, y: -currentInset!.top) animateScrollView(contentInset: currentInset!, contentOffset: offset, animationDuration: 0.2) } func animateScrollView(contentInset:UIEdgeInsets, contentOffset:CGPoint, animationDuration:CFTimeInterval) { UIView.animate(withDuration: animationDuration, delay: 0, options: [.allowUserInteraction, .beginFromCurrentState], animations: { self.scrollView?.contentOffset = contentOffset self.scrollView?.contentInset = contentInset }, completion: nil) }
setScrollViewForPromotion()
這個方法沒有使用和其他一樣的animateScrollView(contentInset)
方法設置全屏滑動是因為在UITableView
中一屏的距離過長,UIView animate 開始的時候渲染樹認為UITableViewWrapperView
已經在屏幕外了,導致動畫無銜接出現空白 View的現象。如果需要自定義這一塊的動畫時長和效果,可以使用CADisplayLink
手動控制。
至此,我們已經可以在一個 tableView 中看到基本的效果了。但是我們需要一個顯示 “釋放可刷新”的視圖,並且跟隨狀態變化而變化, 本例中具體代碼如下:
class RefreshControl: UIView { var _state:PullPromotionState = .stopped var state:PullPromotionState { get { return _state } set { _state = newValue dispatchState(state: newValue) } } var hintLabel = UILabel() let refreshHint = "下拉可刷新" let releaseHint = "釋放可刷新" let refreshingHint = "正在刷新" let promotionHint = "雙11會場" convenience init() { let rect = UIScreen.main.bounds self.init(frame:CGRect(x: 0, y: 0, width: rect.size.width, height: RefreshTriggerHeight)) self.top = rect.size.height - RefreshTriggerHeight self.hintLabel.text = self.refreshHint self.hintLabel.textColor = UIColor.white self.hintLabel.font = UIFont.systemFont(ofSize: 12) self.addSubview(self.hintLabel) } func dispatchState(state:PullPromotionState) { self.isHidden = state == .promotionShowing switch state { case .promotionTriggered: self.hintLabel.text = self.promotionHint break case .promotionShowing: self.hintLabel.text = nil break case .refreshing: self.hintLabel.text = self.refreshingHint break case .stopped: self.hintLabel.text = refreshHint break case .refreshTriggered: self.hintLabel.text = self.releaseHint break; } self.setNeedsLayout() self.layoutIfNeeded() } override func layoutSubviews() { super.layoutSubviews() self.hintLabel.sizeToFit() self.hintLabel.left = (self.width - self.hintLabel.width) / 2 self.hintLabel.bottom = RefreshTriggerHeight - 8 } }
然後,把它加入到 PullPromotionView
中去:
class PullPromotionView: UIView { ...... var hud = RefreshControl() //記得 set State的時候一並設置 hud的 state ...... func commonInit() { loadImageView() self.addSubview(self.hud) } }
這時候,找個 ViewController 試一下這個效果:
self.tableView.showPullPromotion = true self.tableView.pullPromotionView?.refreshAction = { [weak self] in DispatchQueue.main.asyncAfter(deadline: .now() + 2, execute: { self?.tableView.pullPromotionView?.stopAnimate() }) }
就能看到和開頭一樣的效果了。這樣,一個全屏下拉的交互就做完了。接下來我們還可以做的有:把上次寫的小箭頭動效添加進來、給背景圖加上基本的視差動畫,這樣就能顯示出更好的效果了。