浅析饿了么/手淘全屏下拉进入活动会场

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>