幾個月前,我有機會實現了一個可展開式菜單,效果同知名的 iOS 應用 Airbnb。而後,我認爲把它封裝爲庫會更好。如今我想和你們分享用於實現漂亮的滾動驅動動畫採用的一些解決方案。前端
此庫支持 3 個狀態。主要目的是在滾動 UIScrollView 時得到流暢的轉換。react
支持的狀態android
UIScrollView 是 iOS SDK 中的一個支持滾動和縮放的視圖。它是 UITableView 和 UICollectionView 的基類,所以,只要支持 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 泛型類,所以能夠監放任何類型。bash
internal class Observable<Value>: NSObject {
internal var observer: ((Value) -> Void)?
}
複製代碼
和兩個 Observable 子類:閉包
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)
}
}
複製代碼
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 協議。我也須要採用一種方式讓 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 結構體中封裝了全部可變狀態,包括 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)* 來計算。
public struct Configuration {
let compactStateHeight: CGFloat
let normalStateHeight: CGFloat
let expandedStateHeight: CGFloat
}
複製代碼
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 做爲初始參數。
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 個變換器來減小狀態:
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
}
複製代碼
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
}
複製代碼
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
}
}
複製代碼
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 } 複製代碼
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
}
}
複製代碼
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。
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)
}
}
複製代碼
爲了通知用戶狀態變化,BarController 調用注入 stateObserver 方法並傳入變化後的 State模型對象。
State 結構體提供了幾個公有方法用於從內部狀態中讀取有用信息:
public func height() -> CGFloat {
return -offset
}
複製代碼
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)
}
複製代碼
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 應用爲主題的調查。
若是本文對你有幫助, 點擊下方的 💚 ,這樣其餘人也會喜歡它。關注咱們更多關於如何構建極好產品的文章。
掘金翻譯計劃 是一個翻譯優質互聯網技術文章的社區,文章來源爲 掘金 上的英文分享文章。內容覆蓋 Android、 iOS、 React、 前端、 後端、 產品、 設計 等領域,想要查看更多優質譯文請持續關注 掘金翻譯計劃。