[譯]如何在 iOS 上實現相似 Airbnb 中的可展開式菜單


如何在 iOS 上實現相似 Airbnb 中的可展開式菜單




幾個月前,我有機會實現了一個可展開式菜單,效果同知名的 iOS 應用 Airbnb。而後,我認爲把它封裝爲庫會更好。如今我想和你們分享用於實現漂亮的滾動驅動動畫採用的一些解決方案。前端




此庫支持 3 個狀態。主要目的是在滾動 UIScrollView 時得到流暢的轉換。react




支持的狀態android

UIScrollView

UIScrollView 是 iOS SDK 中的一個支持滾動和縮放的視圖。它是 UITableViewUICollectionView 的基類,所以,只要支持 UIScrollView,就能夠使用它。ios

UIScrollView 使用 UIPanGestureRecognizer 在內部檢測滾動手勢。UIScrollView 的滾動狀態被定義爲 contentOffset: CGPoint 屬性。 可滾動區域由 contentInsets 和 contentSize 聯合決定。 所以,起始的 contentOffset 爲 *CGPoint(x: -contentInsets.left, y: -contentInsets.right)* ,結束值爲 *CGPoint(x: contentSize.width — frame.width+contentInsets.right, y: contentSize.height — frame.height+contentInsets.bottom)*.git

UIScrollView 有一個 bounces: Bool 屬性。bounces 可以避免設置 contentOffset 高於/低於限定值。咱們須要記住這一點。github




UIScrollView contentOffset 演示後端

咱們感興趣的是用於改變咱們菜單狀態的屬性 contentOffset: CGPoint。監聽滾動視圖 contentOffset 的主要方式是爲對象設置一個代理屬性,並實現 scrollViewDidScroll(UIScrollView) 方法。在 Swift 中,沒有辦法使用 delegate 而不影響其餘客戶端代碼(由於 NSProxy 不可用),所以我打算使用鍵值監聽(KVO)。api

Observable

我建立了 Observable 泛型類,所以能夠監放任何類型。bash

internal class Observable<Value>: NSObject {
  internal var observer: ((Value) -> Void)?
}
複製代碼

和兩個 Observable 子類:閉包

  • KVObservable — 用於封裝 KVO。
internal class KVObservable<Value>: Observable<Value> {
  private let keyPath: String
  private weak var object: AnyObject?
  private var observingContext = NSUUID().uuidString

  internal init(keyPath: String, object: AnyObject) {
    self.keyPath = keyPath
    self.object = object
    super.init()

    object.addObserver(self, forKeyPath: keyPath, options: [.new], context: &observingContext)
  }

  deinit {
    object?.removeObserver(self, forKeyPath: keyPath, context: &observingContext)
  }

  override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
    guard
      context == &observingContext,
      let newValue = change?[NSKeyValueChangeKey.newKey] as? Value
    else {
      return
    }

    observer?(newValue)
  }
}
複製代碼
  • GestureStateObservable — 封裝了 target-action 用於監聽 UIGestureRecognizer 狀態。
internal class GestureStateObservable: Observable<UIGestureRecognizerState> {
  private weak var gestureRecognizer: UIGestureRecognizer?

  internal init(gestureRecognizer: UIGestureRecognizer) {
    self.gestureRecognizer = gestureRecognizer
    super.init()

    gestureRecognizer.addTarget(self, action: #selector(self.handleEvent(_:)))
  }

  deinit {
    gestureRecognizer?.removeTarget(self, action: #selector(self.handleEvent(_:)))
  }

  @objc private func handleEvent(_ recognizer: UIGestureRecognizer) {
    observer?(recognizer.state)
  }
}
複製代碼

Scrollable

爲了便於庫的測試,我實現了 Scrollable 協議。我也須要採用一種方式讓 UIScrollView 監聽 contentOffset, contentSize 和 panGestureRecognizer.state。協議一致性是一個很好的方法。除了能夠監聽庫中使用的全部的屬性。還包括用於設置帶有動畫效果的 contentOffset 的 updateContentOffset(CGPoint, animated: Bool) 方法。

internal protocol Scrollable: class {
  var contentOffset: CGPoint { get }
  var contentInset: UIEdgeInsets { get set }
  var scrollIndicatorInsets: UIEdgeInsets { get set }
  var contentSize: CGSize { get }
  var frame: CGRect { get }
  var contentSizeObservable: Observable<CGSize> { get }
  var contentOffsetObservable: Observable<CGPoint> { get }
  var panGestureStateObservable: Observable<UIGestureRecognizerState> { get }
  func updateContentOffset(_ contentOffset: CGPoint, animated: Bool)
}

// MARK: - UIScrollView + Scrollable
extension UIScrollView: Scrollable {
  var contentSizeObservable: Observable<CGSize> {
    return KVObservable<CGSize>(keyPath: #keyPath(UIScrollView.contentSize), object: self)
  }

  var contentOffsetObservable: Observable<CGPoint> {
    return KVObservable<CGPoint>(keyPath: #keyPath(UIScrollView.contentOffset), object: self)
  }

  var panGestureStateObservable: Observable<UIGestureRecognizerState> {
    return GestureStateObservable(gestureRecognizer: panGestureRecognizer)
  }

  func updateContentOffset(_ contentOffset: CGPoint, animated: Bool) {
    // Stops native deceleration.
    setContentOffset(self.contentOffset, animated: false)

    let animate = {
      self.contentOffset = contentOffset
    }

    guard animated else {
      animate()
      return
    }

    UIView.animate(withDuration: 0.25, delay: 0, options: [], animations: {
      animate()
    }, completion: nil)
  }
}
複製代碼

我沒有使用系統庫提供的 UIScrollView 實現的方法 setContentOffset(...) ,由於在我看來,UIKit 動畫 API 更加靈活。這裏的問題是直接設置 contentOffset 屬性並不能使 UIScrollView減速停下來,因此使用沒有動畫效果的 updateContentOffset(…) 方法設置當前的 contentOffset。

State

我想要獲取可預測的菜單狀態。這就是爲何我在 State 結構體中封裝了全部可變狀態,包括 offset、isExpandedStateAvailable 和 configuration 屬性。

public struct State {
  internal let offset: CGFloat
  internal let isExpandedStateAvailable: Bool
  internal let configuration: Configuration

  internal init(offset: CGFloat, isExpandedStateAvailable: Bool, configuration: Configuration) {
    self.offset = offset
    self.isExpandedStateAvailable = isExpandedStateAvailable
    self.configuration = configuration
  }
}
複製代碼

offset 僅僅是菜單高度的相反數。我打算使用 offset 來代替 height,由於向下滾動時高度下降,當向上滾動時高度增長。offset 能夠使用 *offset = previousOffset + (contentOffset.y — previousContentOffset.y)* 來計算。

  • isExpandedStateAvailable 屬性用於判斷 offset 應該賦值爲 -normalStateHeight 或 -expandedStateHeight;
  • configuration 是一個包含菜單高度常量的結構體。
public struct Configuration {
  let compactStateHeight: CGFloat
  let normalStateHeight: CGFloat
  let expandedStateHeight: CGFloat
}
複製代碼

BarController

BarController 是用於管理全部計算狀態的主要對象,併爲調用者提供狀態改變。

public typealias StateObserver = (State) -> Void

private struct ScrollableObservables {
  let contentOffset: Observable<CGPoint>
  let contentSize: Observable<CGSize>
  let panGestureState: Observable<UIGestureRecognizerState>
}

public class BarController {

  private let stateReducer: StateReducer
  private let configuration: Configuration
  private let stateObserver: StateObserver

  private var state: State {
    didSet { stateObserver(state) }
  }

  private weak var scrollable: Scrollable?
  private var observables: ScrollableObservables?

  // MARK: - Lifecycle
  internal init(
    stateReducer: @escaping StateReducer,
    configuration: Configuration,
    stateObserver: @escaping StateObserver
  ) {
    self.stateReducer = stateReducer
    self.configuration = configuration
    self.stateObserver = stateObserver
    self.state = State(
      offset: -configuration.normalStateHeight,
      isExpandedStateAvailable: false,
      configuration: configuration
    )
  }

  ...
}
複製代碼

它傳遞 stateReducer, configuration 和 stateObserver 做爲初始參數。

  • stateObserver 閉包在 state 屬性的 didSet 中被調用中被調用。它通知庫的調用者關於狀態的改變。
  • stateReducer 是一個函數,它傳入以前的狀態,一些滾動上下文參數,並返回一個新狀態。經過初始化方法傳入參數,用於解耦狀態計算和 BarController 對象。
internal struct StateReducerParameters {
  let scrollable: Scrollable
  let configuration: Configuration
  let previousContentOffset: CGPoint
  let contentOffset: CGPoint
  let state: State
}

internal typealias StateReducer = (StateReducerParameters) -> State
複製代碼

默認的 state reducer 用於計算 contentOffset.y 和 previousContentOffset.y 的差值, 並對每一個變換器進行計算。而後返回返回新狀態:offset = previousState.offset + deltaY。

internal struct ContentOffsetDeltaYTransformerParameters {
  let scrollable: Scrollable
  let configuration: Configuration
  let previousContentOffset: CGPoint
  let contentOffset: CGPoint
  let state: State
  let contentOffsetDeltaY: CGFloat
}

internal typealias ContentOffsetDeltaYTransformer = (ContentOffsetDeltaYTransformerParameters) -> CGFloat

internal func makeDefaultStateReducer(transformers: [ContentOffsetDeltaYTransformer]) -> StateReducer {
  return { (params: StateReducerParameters) -> State in
    var deltaY = params.contentOffset.y - params.previousContentOffset.y

    deltaY = transformers.reduce(deltaY) { (deltaY, transformer) -> CGFloat in
      let params = ContentOffsetDeltaYTransformerParameters(
        scrollable: params.scrollable,
        configuration: params.configuration,
        previousContentOffset: params.previousContentOffset,
        contentOffset: params.contentOffset,
        state: params.state,
        contentOffsetDeltaY: deltaY
      )
      return transformer(params)
    }

    return params.state.add(offset: deltaY)
  }
}
複製代碼

庫中使用了 3 個變換器來減小狀態:

  • ignoreTopDeltaYTransformer — 確保滾動到 UIScrollView 的頂部被忽略而且不會影響到 BarController 狀態;
internal let ignoreTopDeltaYTransformer: ContentOffsetDeltaYTransformer = { params -> CGFloat in
  var deltaY = params.contentOffsetDeltaY

  // Minimum contentOffset.y without bounce.
  let start = params.scrollable.contentInset.top

  // Apply transform only when contentOffset is below starting point.
  if
    params.previousContentOffset.y < -start ||
      params.contentOffset.y < -start
  {
    // Adjust deltaY to ignore scroll view bounce below minimum contentOffset.y.
    deltaY += min(0, params.previousContentOffset.y + start)
  }

  return deltaY
}
複製代碼
  • ignoreBottomDeltaYTransformer — 和 ignoreTopDeltaYTransformer相似,只是滾動到底部;
internal let ignoreBottomDeltaYTransformer: ContentOffsetDeltaYTransformer = { params -> CGFloat in
  var deltaY = params.contentOffsetDeltaY

  // Maximum contentOffset.y without bounce.
  let end = params.scrollable.contentSize.height - params.scrollable.frame.height + params.scrollable.contentInset.bottom

  // Apply transform only when contentOffset.y is above ending.
  if params.previousContentOffset.y > end ||
      params.contentOffset.y > end
  {
    // Adjust deltaY to ignore scroll view bounce above maximum contentOffset.y.
    deltaY += max(0, params.previousContentOffset.y - end)
  }

  return deltaY
}
複製代碼
  • cutOutStateRangeDeltaYTransformer — 刪除那些超過BarController支持的狀態(最小值/最大值)限制的 delta Y。
internal let cutOutStateRangeDeltaYTransformer: ContentOffsetDeltaYTransformer = { params -> CGFloat in
  var deltaY = params.contentOffsetDeltaY

  if deltaY > 0 {
    // Transform when scrolling down.
    // Cut out extra deltaY that will go out of compact state offset after apply.
    deltaY = min(-params.configuration.compactStateHeight, (params.state.offset + deltaY)) - params.state.offset
  } else {
    // Transform when scrolling up.
    // Expanded or normal state height.
    let maxStateHeight = params.state.isExpandedStateAvailable ? params.configuration.expandedStateHeight : params.configuration.normalStateHeight
    // Cut out extra deltaY that will go out of maximum state offset after apply.
    deltaY = max(-maxStateHeight, (params.state.offset + deltaY)) - params.state.offset
  }

  return deltaY
}
複製代碼

每次 contentOffset 變化時,BarController 調用 stateReducer 並將結果賦值給 state。

private func setupObserving() {
    guard let observables = observables else { return }

    // Content offset observing.
    var previousContentOffset: CGPoint?
    observables.contentOffset.observer = { [weak self] contentOffset in
      guard previousContentOffset != contentOffset else { return }
      self?.contentOffsetChanged(previousValue: previousContentOffset, newValue: contentOffset)
      previousContentOffset = contentOffset
    }

    ...
  }

  private func contentOffsetChanged(previousValue: CGPoint?, newValue: CGPoint) {
    guard
      let previousValue = previousValue,
      let scrollable = scrollable
    else {
      return
    }

    let reducerParams = StateReducerParameters(
      scrollable: scrollable,
      configuration: configuration,
      previousContentOffset: previousValue,
      contentOffset: newValue,
      state: state
    )

    state = stateReducer(reducerParams)
  }

  ...
複製代碼

到此,該庫可以將 contentOffset 的變化轉化爲內部狀態的改變,可是 isExpandedStateAvailable 狀態屬性此時不能被修改,由於狀態狀態轉變還沒有結束。

該 panGestureRecognizer.state 監聽出場了:

private func setupObserving() {
    ...

    // Pan gesture state observing.
    observables.panGestureState.observer = { [weak self] state in
      self?.panGestureStateChanged(state: state)
    }
  }

  private func panGestureStateChanged(state: UIGestureRecognizerState) {
    switch state {
    case .began:
      panGestureBegan()
    case .ended:
      panGestureEnded()
    case .changed:
      panGestureChanged()
    default:
      break
    }
  }
複製代碼
  • 若是拖動手勢在在滾動的上部,或者咱們已經處於展開狀態,拖動手勢將 isExpandedStateAvailable 狀態屬性設置爲 true;
private func panGestureBegan() {
    guard let scrollable = scrollable else { return }

    // Is currently at top of scrollable area.
    // Assertion is not strict here, because of UIScrollView KVO observing bug.
    // First emitted contentOffset.y isn't always a decimal number. let isScrollingAtTop = scrollable.contentOffset.y.isNear(to: -configuration.normalStateHeight, delta: 5) // Is expanded state previously available. let isExpandedStatePreviouslyAvailable = scrollable.contentOffset.y < -configuration.normalStateHeight && state.isExpandedStateAvailable // Turn on expanded state if scrolling at top or expanded state previous available. state = state.set(isExpandedStateAvailable: isScrollingAtTop || isExpandedStatePreviouslyAvailable) // Configure contentInset.top to be consistent with available states. scrollable.contentInset.top = state.isExpandedStateAvailable ? configuration.expandedStateHeight : configuration.normalStateHeight } 複製代碼
  • 若是狀態偏移值達到正常狀態,拖動手勢變化回調方法就會設置 isExpandedStateAvailable;
private func panGestureChanged() {
  guard let scrollable = scrollable else { return }

  // Turn off expanded state if offset is bigger than normal state offset.
  if state.isExpandedStateAvailable && scrollable.contentOffset.y > -configuration.normalStateHeight {
    state = state.set(isExpandedStateAvailable: false)
    scrollable.contentInset.top = configuration.normalStateHeight
  }
}
複製代碼
  • 拖動手勢結束後找到最接近當前狀態的偏移量,添加其差值到偏移量上,並調用偏移量到結束狀態的動畫 updateContentOffset(CGPoint, animated: Bool)。
private func panGestureEnded() {
  guard let scrollable = scrollable else { return }

  let stateOffset = state.offset
  // 全部支持的狀態偏移。
  let offsets = [
    -configuration.compactStateHeight,
    -configuration.normalStateHeight,
    -configuration.expandedStateHeight
  ]

  // Find smallest absolute delta between current offset and supported state offsets.
  let smallestDelta = offsets.reduce(nil) { (smallestDelta: CGFloat?, offset: CGFloat) -> CGFloat in
    let delta = offset - stateOffset
    guard let smallestDelta = smallestDelta else { return delta }
    return abs(delta) < abs(smallestDelta) ? delta : smallestDelta
  }

  // Add samllestDelta to currentOffset.y and update scrollable contentOffset with animation.
  if let smallestDelta = smallestDelta, smallestDelta != 0 {
    let targetContentOffsetY = scrollable.contentOffset.y + smallestDelta
    let targetContentOffset = CGPoint(x: scrollable.contentOffset.x, y: targetContentOffsetY)
    scrollable.updateContentOffset(targetContentOffset, animated: true)
  }
}
複製代碼

所以,只有當用戶在可用的可滾動區域的頂部滾動時,可展開狀態纔會生效。若是可展開狀態可用而且用戶滾動到正常狀態之下,此時可展開狀態被禁用。若是用戶在狀態轉換期間結束拖動手勢,BarController 此時會以動畫的方式更新 contentoffset。

將 UIScrollView 綁定到 BarController

BarController 包含 2 個公有方法用於用戶設置 UIScrollView。一般狀況下,用戶使用 set(scrollView: UIScrollView) 方法。也能夠使用 preconfigure(scrollView: UIScrollView) 方法,用於設置滾動視圖的可視狀態與當前 BarController 狀態一致。 它被用於滾動視圖即將被交換的時候。例如,用戶能夠採用動畫替換當前的滾動視圖,並但願在動畫開始時將第二滾動視圖可視化配置。動畫結束後,用戶應該調用 set(scrollView: UIScrollView)。若是 UIScrollView 只設置一次,那麼 preconfigure(scrollView: UIScrollView) 方法不是必須調用的,由於 set(scrollView: UIScrollView) 是在內部調用的。

preconfigure 方法計算 contentSize 高度和 frame 高度的差值, 並將其賦值給 bottomcontentinset,使其菜單保持可擴展狀態,並設置 contentInsets.top 和 scrollIndicatorInsets.top,而後設置初始的 contentOffset 確保新的滾動視圖與狀態偏移保持一致。

public func set(scrollView: UIScrollView) {
  self.set(scrollable: scrollView)
}

internal func set(scrollable: Scrollable) {
  self.scrollable = scrollable
  self.observables = ScrollableObservables(
    contentOffset: scrollable.contentOffsetObservable,
    contentSize: scrollable.contentSizeObservable,
    panGestureState: scrollable.panGestureStateObservable
  )

  preconfigure(scrollable: scrollable)
  setupObserving()

  stateObserver(state)
}

public func preconfigure(scrollView: UIScrollView) {
  preconfigure(scrollable: scrollView)
}

internal func preconfigure(scrollable: Scrollable) {
  scrollable.setBottomContentInsetToFillEmptySpace(heightDelta: configuration.compactStateHeight)

  // Set contentInset.top to current state height.
  scrollable.contentInset.top = state.offset <= -configuration.normalStateHeight && state.isExpandedStateAvailable ? configuration.expandedStateHeight : configuration.normalStateHeight
  // Set scrollIndicator.top to normal state height.
  scrollable.scrollIndicatorInsets.top = configuration.normalStateHeight

  // Scroll to top of scrollable area if state is expanded or content offset is less than zero.
  if scrollable.contentOffset.y <= 0 || (state.offset < -configuration.normalStateHeight && state.isExpandedStateAvailable) {
    let targetContentOffset = CGPoint(x: scrollable.contentOffset.x, y: state.offset)
    scrollable.updateContentOffset(targetContentOffset, animated: false)
  }
}
複製代碼

API

爲了通知用戶狀態變化,BarController 調用注入 stateObserver 方法並傳入變化後的 State模型對象。

State 結構體提供了幾個公有方法用於從內部狀態中讀取有用信息:

  • height()— 返回 offset 的相反數, 菜單的實際高度;
public func height() -> CGFloat {
    return -offset
  }
複製代碼
  • transitionProgress()— 返回從 0 到 2 的改變狀態,0 — 簡潔狀態,1 — 正常狀態, 2 — 展開狀態
internal enum StateRange {
  case compactNormal
  case normalExpanded

  internal func progressBounds() -> (CGFloat, CGFloat) {
    switch self {
    case .compactNormal:
      return (0, 1)
    case .normalExpanded:
      return (1, 2)
    }
  }
}

...

internal func stateRange() -> StateRange {
  if offset > -configuration.normalStateHeight {
    return .compactNormal
  } else {
    return .normalExpanded
  }
}

public func transitionProgress() -> CGFloat {
  let stateRange = self.stateRange()
  let offsetBounds = configuration.offsetBounds(for: stateRange)
  let progressBounds = stateRange.progressBounds()
  let reversedProgressBounds = (progressBounds.1, progressBounds.0)
  return offset.map(from: offsetBounds, to: reversedProgressBounds)
}
複製代碼
  • value(compactNormalRange: ValueRangeType, normalExpandedRange: ValueRangeType) — 根據當前的 StateRange 將轉換進度映射爲 2 個範圍類型之一併返回。
public enum ValueRangeType {
    case value(CGFloat)
    case range(CGFloat, CGFloat)

    internal var range: (CGFloat, CGFloat) {
      switch self {
      case let .value(value):
        return (value, value)
      case let .range(range):
        return range
      }
    }
  }

  public func value(compactNormalRange: ValueRangeType, normalExpandedRange: ValueRangeType) -> CGFloat {
    let progress = self.transitionProgress()
    let stateRange = self.stateRange()
    let valueRange = stateRange == .compactNormal ? compactNormalRange : normalExpandedRange
    return progress.map(from: stateRange.progressBounds(), to: valueRange.range)
  }
複製代碼

如下爲 AirBarExampleApp 中使用 State 的公有方法。airBar.frame.height 根據 height() 動畫,backgroundView.alpha 根據 value(...) 動畫。這裏的背景視圖透明會進行 (0, 1) 範圍內的差值表示爲 compact-normal 的狀態, 1 爲 normal-expanded 狀態。

override func viewDidLoad() {
    ...

    let barStateObserver: (AirBar.State) -> Void = { [weak self] state in
      self?.handleBarControllerStateChanged(state: state)
    }

    barController = BarController(configuration: configuration, stateObserver: barStateObserver)
  }

  ...

  private func handleBarControllerStateChanged(state: State) {
    let height = state.height()

    airBar.frame = CGRect(
      x: airBar.frame.origin.x,
      y: airBar.frame.origin.y,
      width: airBar.frame.width,
      height: height // <~ Animated property
    )

    backgroundView.alpha = state.value(compactNormalRange: .range(0, 1), normalExpandedRange: .value(1)) // <~ Animated property
  }
複製代碼

總結

到此,我已經實現了一個帶有可預測狀態的漂亮的滾動驅動菜單,並學到了許多使用 UIScrollView 的經驗。

如下能夠找到本封裝庫,示例應用和安裝指南:



你能夠隨意使用它。若是遇到任何困難,請告訴我。

你有哪些使用 UIScrollView 及滾動驅動動畫經驗?歡迎在評論中分享/提問,我很樂意幫忙。

感謝您的閱讀!

咱們在 UPTech 上作了以 Freebird Rides 應用爲主題的調查。

若是本文對你有幫助, 點擊下方的 💚 ,這樣其餘人也會喜歡它。關注咱們更多關於如何構建極好產品的文章。

掘金翻譯計劃 是一個翻譯優質互聯網技術文章的社區,文章來源爲 掘金 上的英文分享文章。內容覆蓋 AndroidiOSReact前端後端產品設計 等領域,想要查看更多優質譯文請持續關注 掘金翻譯計劃
相關文章
相關標籤/搜索