Skip to content

Instantly share code, notes, and snippets.

@moaible
Last active June 17, 2019 09:07
Show Gist options
  • Save moaible/420437ea61e2619abf520246500b668b to your computer and use it in GitHub Desktop.
Save moaible/420437ea61e2619abf520246500b668b to your computer and use it in GitHub Desktop.
ScrollViewObserver
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