Last active
June 17, 2019 09:07
-
-
Save moaible/420437ea61e2619abf520246500b668b to your computer and use it in GitHub Desktop.
ScrollViewObserver
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import Foundation | |
import RxSwift | |
import RxCocoa | |
/// スクロール対象 | |
public class ScrollContent { | |
// MARK: - Property | |
// MARK: Content info | |
public var offset: CGPoint | |
public var inset: UIEdgeInsets | |
public var size: CGSize | |
// MARK: Boundary | |
public var topBoundary: CGFloat { | |
return -inset.top | |
} | |
public var bottomBoundary: CGFloat { | |
return size.height + inset.bottom | |
} | |
// MARK: - Initialize | |
public init(offset: CGPoint, inset: UIEdgeInsets, size: CGSize) { | |
self.offset = offset | |
self.inset = inset | |
self.size = size | |
} | |
// MARK: - Confirm Boundary | |
/// スクロール領域上部外かどうか | |
public func isOverTopBoundary() -> Bool { | |
return offset.y <= topBoundary | |
} | |
/// スクロール領域下部外かどうか | |
public func isOverBottomBoundary() -> Bool { | |
return offset.y >= bottomBoundary | |
} | |
} | |
/// スクロールの方向性 | |
public enum ScrollDirection { | |
/// 判定無し | |
case none | |
/// 上にスクロールしたとみなした状態 | |
case upward | |
/// 下にスクロールしたとみなした状態 | |
case downward | |
public init(offsetYWith currentY: CGFloat, byPrevious previousY: CGFloat) { | |
self = currentY > previousY ? .upward | |
: currentY < previousY ? .downward | |
: .none | |
} | |
} | |
/// スクロール操作 | |
public struct ScrollCommand { | |
public var current: ScrollContent | |
public var previous: ScrollContent? | |
public var isDragging: Bool | |
init(current: ScrollContent, | |
previous: ScrollContent? = nil, | |
isDragging: Bool) { | |
self.current = current | |
self.previous = previous | |
self.isDragging = isDragging | |
} | |
/// 方向 | |
public var direction: ScrollDirection { | |
guard let previous = previous else { | |
return .none | |
} | |
return ScrollDirection( | |
offsetYWith: current.offset.y, | |
byPrevious: previous.offset.y) | |
} | |
/// スクロールの量 | |
public var delta: CGFloat { | |
let previousY = self.previous?.offset.y ?? 0 | |
return previousY - current.offset.y | |
} | |
/// 操作した結果バウンス状態かどうか | |
public var isBouncing: Bool { | |
let toOverTop = current.isOverTopBoundary() && direction != .downward | |
let toOverBottom = current.isOverBottomBoundary() && direction != .upward | |
return toOverTop || toOverBottom | |
} | |
} | |
/// トリガーとなるスクロールの閾値定義 | |
private struct ScrollTriggerThreshold { | |
public var upward: CGFloat | |
public var downward: CGFloat | |
public init(upward: CGFloat = 0, downward: CGFloat = 200) { | |
self.upward = upward | |
self.downward = downward | |
} | |
} | |
/// UIScrollViewのスクロール位置をよしなにしてくれるRx的Wrapper | |
public final class ScrollViewObserver { | |
/// 対象ScrollView | |
public private(set) weak var scrollView: UIScrollView? | |
/// スクロール操作 | |
public var command: ScrollCommand? | |
private var previous: ScrollContent? | |
private var previousDirection: ScrollDirection? | |
/// 操作によるスクロール量の蓄積 | |
public var accumulatedDelta: CGFloat = 0 | |
private var endDraggingScrollUpRelay = PublishRelay<Void>() | |
public var endDraggingScrollUp: Observable<Void> { | |
return endDraggingScrollUpRelay.asObservable() | |
} | |
private var endDraggingScrollDownRelay = PublishRelay<Void>() | |
public var endDraggingScrollDown: Observable<Void> { | |
return endDraggingScrollDownRelay.asObservable() | |
} | |
// MARK: - | |
/// トリガーの閾値定義 | |
private var threshold = ScrollTriggerThreshold() | |
private var disposeBag = DisposeBag() | |
// MARK: - Initialize | |
public init(with scrollView: UIScrollView) { | |
self.scrollView = scrollView | |
let reactive = scrollView.rx | |
reactive.didScroll.subscribe(onNext: { [weak self] in | |
guard let strongSelf = self else { | |
return | |
} | |
let command = ScrollCommand( | |
current: ScrollContent( | |
offset: scrollView.contentOffset, | |
inset: scrollView.contentInset, | |
size: scrollView.contentSize), | |
previous: strongSelf.previous, | |
isDragging: scrollView.isDragging) | |
strongSelf.command = command | |
guard !command.isBouncing && command.isDragging else { | |
return | |
} | |
strongSelf.accumulatedDelta += command.delta | |
switch command.direction { | |
case .upward: | |
let isOverThreshold = strongSelf.accumulatedDelta < strongSelf.threshold.upward | |
if isOverThreshold || command.current.isOverBottomBoundary() { | |
strongSelf.endDraggingScrollUpRelay.accept(()) | |
} | |
case .downward: | |
let isOverThreshold = strongSelf.accumulatedDelta > strongSelf.threshold.downward | |
if isOverThreshold || command.current.isOverTopBoundary() { | |
strongSelf.endDraggingScrollDownRelay.accept(()) | |
} | |
case .none: | |
break | |
} | |
if !command.current.isOverBottomBoundary() && | |
!command.current.isOverTopBoundary() && | |
command.direction != strongSelf.previousDirection { | |
strongSelf.accumulatedDelta = 0 | |
} | |
strongSelf.previousDirection = command.direction | |
strongSelf.previous = command.current | |
}).disposed(by: disposeBag) | |
reactive.didScrollToTop.subscribe(onNext: { [weak self] in | |
self?.endDraggingScrollDownRelay.accept(()) | |
}).disposed(by: disposeBag) | |
reactive.didEndDragging.subscribe().disposed(by: disposeBag) | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment