[iOS 交互]浅析饿了么/手淘全屏下拉进入活动会场

难度:⭐️⭐️
效果:

下拉刷新
下拉刷新

下拉进入活动会场
下拉进入活动会场

饿了么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 -

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 211,639评论 6 492
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,277评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,221评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,474评论 1 283
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,570评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,816评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,957评论 3 408
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,718评论 0 266
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,176评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,511评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,646评论 1 340
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,322评论 4 330
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,934评论 3 313
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,755评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,987评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,358评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,514评论 2 348

推荐阅读更多精彩内容