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

Richard_Lee發表於2017-09-04


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


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



幾個月前,我有機會實現了一個可展開式選單,效果同知名的 iOS 應用 Airbnb。然後,我認為把它封裝為庫會更好。現在我想和大家分享用於實現漂亮的滾動驅動動畫採用的一些解決方案。



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


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


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



支援的狀態

UIScrollView

UIScrollView 是 iOS SDK 中的一個支援滾動和縮放的檢視。它是 UITableViewUICollectionView 的基類,因此,只要支援 UIScrollView,就可以使用它。

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)*.

UIScrollView 有一個 bounces: Bool 屬性。bounces 能夠避免設定 contentOffset 高於/低於限定值。我們需要記住這一點。



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


UIScrollView contentOffset 演示

我們感興趣的是用於改變我們選單狀態的屬性 contentOffset: CGPoint。監聽滾動檢視 contentOffset 的主要方式是為物件設定一個代理屬性,並實現 scrollViewDidScroll(UIScrollView) 方法。在 Swift 中,沒有辦法使用 delegate 而不影響其他客戶端程式碼(因為 NSProxy 不可用),因此我打算使用鍵值監聽(KVO)。

Observable

我建立了 Observable 泛型類,因此可以監聽任何型別。

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 的經驗。

以下可以找到本封裝庫,示例應用和安裝指南:


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


你可以隨意使用它。如果遇到任何困難,請告訴我。

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

感謝您的閱讀!

我們在 UPTech 上做了以 Freebird Rides 應用為主題的調查。

如果本文對你有幫助, 點選下方的 ? ,這樣其他人也會喜歡它。關注我們更多關於如何構建極好產品的文章。

掘金翻譯計劃 是一個翻譯優質網際網路技術文章的社群,文章來源為 掘金 上的英文分享文章。內容覆蓋 AndroidiOSReact前端後端產品設計 等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃


相關文章