难度:⭐️⭐️
效果:
饿了么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)))
}
self.pullPromotionView!.isObserving = !self.pullPromotionView!.isObserving
}
}
需要在
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()
})
}
关于文中代码引用
UIView
的 height/left/top/right/bottom, 这是通过一个 extension 实现的获取 frame的便捷方法,此处不再赘述.
就能看到和开头一样的效果了。这样,一个全屏下拉的交互就做完了。接下来我们还可以做的有:把上次写的小箭头动效添加进来、给背景图加上基本的视差动画,这样就能显示出更好的效果了。
本例源码: Github
- EOF -