淺析餓了么/手淘全屏下拉進入活動會場
難度: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()
})
}
就能看到和開頭一樣的效果了。這樣,一個全屏下拉的交互就做完了。接下來我們還可以做的有:把上次寫的小箭頭動效添加進來、給背景圖加上基本的視差動畫,這樣就能顯示出更好的效果了。
來自:http://www.jianshu.com/p/3997341f6940