你好,歡迎來到IOS教程網

 Ios教程網 >> IOS編程開發 >> IOS開發基礎 >> [iOS 交互]淺析餓了麼

[iOS 交互]淺析餓了麼

編輯:IOS開發基礎

難度:2星
效果:

1479951383544894.gif

1479951384866526.gif

餓了麼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()
    })
  }

就能看到和開頭一樣的效果了。這樣,一個全屏下拉的交互就做完了。接下來我們還可以做的有:把上次寫的小箭頭動效添加進來、給背景圖加上基本的視差動畫,這樣就能顯示出更好的效果了。





  1. 上一頁:
  2. 下一頁:
蘋果刷機越獄教程| IOS教程問題解答| IOS技巧綜合| IOS7技巧| IOS8教程
Copyright © Ios教程網 All Rights Reserved