浅析饿了么/手淘全屏下拉进入活动会场
lingku7080
9年前
<p>难度:2星</p> <p>效果:</p> <p style="text-align:center"><img src="https://simg.open-open.com/show/ac5351fc34729f22f23997c4c05ad008.gif"></p> <p style="text-align:center"><img src="https://simg.open-open.com/show/d65c477d47ca172decc6b0d6c44463e2.gif"></p> <p>饿了么App在最近版本上线了一个新的活动会场进入方式,没错儿,就是类似 (clone) 于淘宝首页的下拉刷新-继续下拉进入活动会场。这对我们本身就已经很复杂的View Hierachy提出了不小的挑战。本篇文章带你一步一步解析这样的全屏下拉、普通下拉刷新的实现方式。</p> <p>Swift Version 3.0</p> <p>Xcode 8.1</p> <p>默认缩进 2空格</p> <p>首先我们想要的效果是:</p> <pre> <code class="language-objectivec">self.tableView.showPullPromotion = true</code></pre> <p>这一行代码就能启用整个下拉刷新,那么就需要一个 UIScrollView 的 extension (aka category in objc).</p> <p>其次,整个一屏显示的 UIImageView 的层次处于 UIScrollView 中,势必需要为 UIScrollView 动态添加这么一个用于显示图片的自定义 View,我定义其为:</p> <pre> <code class="language-objectivec">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() } }</code></pre> <p>在 PullPromotionView 里定义了一个指向其所在的 scrollView的弱引用,这个引用将被用于: PullPromotionView 作为 scrollView 的 Observer,监听其 contentOffset变化的同时判断下拉的状态。</p> <p>这时候我们添加一个 UIScrollView 的 extension:</p> <pre> <code class="language-objectivec">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) } }</code></pre> <p>因为我们要在 extension中添加一个 PullPromotionView 作为property,所以需要使用 runtime动态地去执行,复写一下 getter 和 setter 就 OK了, 这样通过 self.pullPromotionView 引用的就是 property,可以正确地帮我们保存各种上下文参数。然后我们在这个 extension中添加一个可以帮我们一行代码启用的 property:</p> <pre> <code class="language-objectivec">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))) } } }</code></pre> <p>需要在 PullPromotionView 中添加一个名为 isObserving 的 Bool类型 property</p> <p>通过上面的代码可以看到,我们在设置 self.tableView.showPullPromotion = true 的时候同时改变 pullPromotionView 的可见性和它的 Obsever。Observer被设置为这个 pullPromotionView,那么我们就可以在 PullPromotionView 里面实现和控制整个 UIScrollView 了。</p> <p>为了表示下拉的状态,我定义了一个枚举:</p> <pre> <code class="language-objectivec">enum PullPromotionState { case stopped //停止状态 case refreshTriggered //触发刷新 case promotionTriggered //触发全屏下拉 case refreshing //刷新中 case promotionShowing //全屏滑动显示中 }</code></pre> <p>有了这个状态的定义,我为 PullPromotionView 添加了一个表示状态的 property,并且在监听到 scrollView的 contentOffset变化时改变这个状态.</p> <pre> <code class="language-objectivec">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 } } }</code></pre> <p>几个状态的触发点分别是(根据代码由上至下):</p> <ul> <li>scrollView不在拖动中,前一个状态是触发刷新。 => 刷新中</li> <li>scrollView不在拖动中,前一个状态是触发全屏下拉。 => 全屏滑动显示中</li> <li>scrollView 的滑动距离大于刷新触发,小于全屏下拉触发, 在拖动中,前一个状态是停止 => 触发刷新</li> <li>scrollView 的滑动距离大于全屏触发点,前一个状态是停止或下拉刷新被出阿发,在拖动中 => 全屏下拉被触发</li> <li>scrollView 的滑动距离小于刷新触发,前一个状态非停止状态 => 停止</li> </ul> <p>在设置状态时,我们同时要更改一些 UI的显示,如下:</p> <pre> <code class="language-objectivec">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 } }</code></pre> <p>具体可以解释为不同状态下 scrollView需要有不同的 contentInset 和 contentOffset, setScrollViewForRefreshing() / setScrollViewForPromotion() / resetScrollView() 三个方法的实现如下:</p> <pre> <code class="language-objectivec">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) }</code></pre> <p>setScrollViewForPromotion() 这个方法没有使用和其他一样的 animateScrollView(contentInset) 方法设置全屏滑动是因为在 UITableView 中一屏的距离过长,UIView animate 开始的时候渲染树认为 UITableViewWrapperView 已经在屏幕外了,导致动画无衔接出现空白 View的现象。如果需要自定义这一块的动画时长和效果,可以使用 CADisplayLink 手动控制。</p> <p>至此,我们已经可以在一个 tableView 中看到基本的效果了。但是我们需要一个显示 “释放可刷新”的视图,并且跟随状态变化而变化, 本例中具体代码如下:</p> <pre> <code class="language-objectivec">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 } }</code></pre> <p>然后,把它加入到 PullPromotionView 中去:</p> <pre> <code class="language-objectivec">class PullPromotionView: UIView { ...... var hud = RefreshControl() //记得 set State的时候一并设置 hud的 state ...... func commonInit() { loadImageView() self.addSubview(self.hud) } }</code></pre> <p>这时候,找个 ViewController 试一下这个效果:</p> <pre> <code class="language-objectivec">self.tableView.showPullPromotion = true self.tableView.pullPromotionView?.refreshAction = { [weak self] in DispatchQueue.main.asyncAfter(deadline: .now() + 2, execute: { self?.tableView.pullPromotionView?.stopAnimate() }) }</code></pre> <p>就能看到和开头一样的效果了。这样,一个全屏下拉的交互就做完了。接下来我们还可以做的有:把上次写的小箭头动效添加进来、给背景图加上基本的视差动画,这样就能显示出更好的效果了。</p> <p> </p> <p> </p> <p>来自:http://www.jianshu.com/p/3997341f6940</p> <p> </p>