Skip to content

Commit

Permalink
Merge pull request #83 from Yalantis/develop
Browse files Browse the repository at this point in the history
Merge with develop branch
  • Loading branch information
Sergey D authored Nov 6, 2017
2 parents dd8edc7 + eeb8a9e commit 4d3b2c5
Show file tree
Hide file tree
Showing 3 changed files with 210 additions and 46 deletions.
152 changes: 112 additions & 40 deletions PullToRefresh/PullToRefresh.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,31 +22,58 @@ open class PullToRefresh: NSObject {
open var springDamping: CGFloat = 0.4
open var initialSpringVelocity: CGFloat = 0.8
open var animationOptions: UIViewAnimationOptions = [.curveLinear]

open var shouldBeVisibleWhileScrolling: Bool = false {
willSet{
if shouldBeVisibleWhileScrolling {
sendRefreshViewToScrollView()
}
}
}

let refreshView: UIView
var action: (() -> ())?

fileprivate var isObserving = false
fileprivate let animator: RefreshViewAnimator

// MARK: - ScrollView & Observing

fileprivate var scrollViewDefaultInsets: UIEdgeInsets = .zero
weak var scrollView: UIScrollView? {
willSet {
if #available(iOS 11.0, *) {
scrollView?.removeAdjustedContentInsetsHandler(forPosition: position)
}
removeScrollViewObserving()
}
didSet {
if let scrollView = scrollView {
if #available(iOS 11.0, *) {
scrollView.addAdjustedContentInsetsHandler(forPosition: position) { [weak self] (adjustedInsets) in
self?.scrollViewDefaultAdjustedInsets = adjustedInsets
}
}
scrollViewDefaultInsets = scrollView.contentInset
addScrollViewObserving()
}
}
}

fileprivate let animator: RefreshViewAnimator
fileprivate var isObserving = false

// MARK: - ScrollView & Observing

fileprivate var scrollViewDefaultInsets: UIEdgeInsets = .zero
fileprivate var scrollViewDefaultAdjustedInsets: UIEdgeInsets = .zero
fileprivate var previousScrollViewOffset: CGPoint = CGPoint.zero

// MARK: - State

open fileprivate(set) var state: State = .initial {
willSet{
switch newValue {
case .finished:
if shouldBeVisibleWhileScrolling {
sendRefreshViewToScrollView()
}
default: break
}
}
didSet {
animator.animate(state)
switch state {
Expand All @@ -56,13 +83,13 @@ open class PullToRefresh: NSObject {
}

case .finished:
if isCurrentlyVisible() {
if isCurrentlyVisible {
animateFinishedState()
} else {
scrollView?.contentInset = self.scrollViewDefaultInsets
state = .initial
}

default: break
}
}
Expand All @@ -85,29 +112,44 @@ open class PullToRefresh: NSObject {
}

deinit {
scrollView?.removePullToRefresh(at: position)
removeScrollViewObserving()
}

// MARK: KVO
}

fileprivate static var KVOContext = "PullToRefreshKVOContext"
fileprivate let contentOffsetKeyPath = "contentOffset"
fileprivate let contentInsetKeyPath = "contentInset"
fileprivate let contentSizeKeyPath = "contentSize"
fileprivate var previousScrollViewOffset: CGPoint = CGPoint.zero
// MARK: KVO
extension PullToRefresh {

fileprivate struct KVO {

static var context = "PullToRefreshKVOContext"

enum ScrollViewPath {
static let contentOffset = #keyPath(UIScrollView.contentOffset)
static let contentInset = #keyPath(UIScrollView.contentInset)
static let contentSize = #keyPath(UIScrollView.contentSize)
}

}

override open func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
if (context == &PullToRefresh.KVOContext && keyPath == contentOffsetKeyPath && object as? UIScrollView == scrollView) {
if (context == &KVO.context && keyPath == KVO.ScrollViewPath.contentOffset && object as? UIScrollView == scrollView) {
var offset: CGFloat
var topInsetY: CGFloat
if #available(iOS 11, *) {
topInsetY = scrollView!.adjustedContentInset.top
} else {
topInsetY = scrollView!.contentInset.top
}
switch position {
case .top:
offset = previousScrollViewOffset.y + scrollViewDefaultInsets.top
offset = previousScrollViewOffset.y + topInsetY

case .bottom:
if scrollView!.contentSize.height > scrollView!.bounds.height {
offset = scrollView!.contentSize.height - previousScrollViewOffset.y - scrollView!.bounds.height
offset = scrollView!.contentSize.height - previousScrollViewOffset.y - scrollView!.bounds.height + topInsetY
} else {
offset = scrollView!.contentSize.height - previousScrollViewOffset.y
offset = scrollView!.contentSize.height - previousScrollViewOffset.y + topInsetY
}
}
let refreshViewHeight = refreshView.frame.size.height
Expand All @@ -125,19 +167,17 @@ open class PullToRefresh: NSObject {
}
default: break
}
} else if (context == &PullToRefresh.KVOContext && keyPath == contentSizeKeyPath && object as? UIScrollView == scrollView) {
} else if (context == &KVO.context && keyPath == KVO.ScrollViewPath.contentSize && object as? UIScrollView == scrollView) {
if case .bottom = position {
refreshView.frame = CGRect(x: 0, y: scrollView!.contentSize.height, width: scrollView!.bounds.width, height: refreshView.bounds.height)
}
} else if (context == &PullToRefresh.KVOContext && keyPath == contentInsetKeyPath && object as? UIScrollView == scrollView) {
} else if (context == &KVO.context && keyPath == KVO.ScrollViewPath.contentInset && object as? UIScrollView == scrollView) {
if self.state == .initial {
scrollViewDefaultInsets = scrollView!.contentInset
}

} else {
super.observeValue(forKeyPath: keyPath, of: object, change: change, context: context)
}

previousScrollViewOffset.y = scrollView?.contentOffset.y ?? 0
}

Expand All @@ -146,10 +186,10 @@ open class PullToRefresh: NSObject {
return
}

scrollView.addObserver(self, forKeyPath: contentOffsetKeyPath, options: .initial, context: &PullToRefresh.KVOContext)
scrollView.addObserver(self, forKeyPath: contentSizeKeyPath, options: .initial, context: &PullToRefresh.KVOContext)
scrollView.addObserver(self, forKeyPath: contentInsetKeyPath, options: .new, context: &PullToRefresh.KVOContext)

scrollView.addObserver(self, forKeyPath: KVO.ScrollViewPath.contentOffset, options: .initial, context: &KVO.context)
scrollView.addObserver(self, forKeyPath: KVO.ScrollViewPath.contentSize, options: .initial, context: &KVO.context)
scrollView.addObserver(self, forKeyPath: KVO.ScrollViewPath.contentInset, options: .new, context: &KVO.context)
isObserving = true
}

Expand All @@ -158,12 +198,13 @@ open class PullToRefresh: NSObject {
return
}

scrollView.removeObserver(self, forKeyPath: contentOffsetKeyPath, context: &PullToRefresh.KVOContext)
scrollView.removeObserver(self, forKeyPath: contentSizeKeyPath, context: &PullToRefresh.KVOContext)
scrollView.removeObserver(self, forKeyPath: contentInsetKeyPath, context: &PullToRefresh.KVOContext)

scrollView.removeObserver(self, forKeyPath: KVO.ScrollViewPath.contentOffset, context: &KVO.context)
scrollView.removeObserver(self, forKeyPath: KVO.ScrollViewPath.contentSize, context: &KVO.context)
scrollView.removeObserver(self, forKeyPath: KVO.ScrollViewPath.contentInset, context: &KVO.context)
isObserving = false
}

}

// MARK: - Start/End Refreshin
Expand Down Expand Up @@ -214,16 +255,20 @@ private extension PullToRefresh {
case .top:
let insets = self.refreshView.frame.height + self.scrollViewDefaultInsets.top
scrollView.contentInset.top = insets
scrollView.contentOffset = CGPoint(x: scrollView.contentOffset.x, y: -insets)
let offsetY = self.defaultInsets.top + self.refreshView.frame.height
scrollView.contentOffset = CGPoint(x: scrollView.contentOffset.x, y: -offsetY)

case .bottom:
let insets = self.refreshView.frame.height + self.scrollViewDefaultInsets.bottom
scrollView.contentInset.bottom = insets
}
},
},
completion: { _ in
scrollView.bounces = true
}
if self.shouldBeVisibleWhileScrolling {
self.bringRefreshViewToSuperview()
}
}
)

action?()
Expand All @@ -240,23 +285,50 @@ private extension PullToRefresh {
animations: {
self.scrollView?.contentInset = self.scrollViewDefaultInsets
if case .top = self.position {
self.scrollView?.contentOffset.y = -self.scrollViewDefaultInsets.top
self.scrollView?.contentOffset.y = -self.defaultInsets.top
}
},
},
completion: { _ in
self.addScrollViewObserving()
self.state = .initial
}
}
)
}
}

// MARK: - Helpers
private extension PullToRefresh {

func isCurrentlyVisible() -> Bool {
var defaultInsets: UIEdgeInsets {
if #available(iOS 11, *) {
return scrollViewDefaultAdjustedInsets
} else {
return scrollViewDefaultInsets
}
}

var isCurrentlyVisible: Bool {
guard let scrollView = scrollView else { return false }

return scrollView.contentOffset.y <= -scrollViewDefaultInsets.top
return scrollView.contentOffset.y <= -defaultInsets.top
}

func bringRefreshViewToSuperview() {
guard let scrollView = scrollView, let superView = scrollView.superview else { return }
let frame = scrollView.convert(refreshView.frame, to: superView)
refreshView.removeFromSuperview()
superView.insertSubview(refreshView, aboveSubview: scrollView)
refreshView.frame = frame
refreshView.layoutSubviews()
}

func sendRefreshViewToScrollView() {
refreshView.removeFromSuperview()
guard let scrollView = scrollView else { return }
scrollView.addSubview(refreshView)
refreshView.frame = scrollView.defaultFrame(forPullToRefresh: self)
scrollView.sendSubview(toBack: refreshView)
}

}

96 changes: 90 additions & 6 deletions PullToRefresh/UIScrollView+PullToRefresh.swift
Original file line number Diff line number Diff line change
Expand Up @@ -33,28 +33,35 @@ public extension UIScrollView {
}
}

internal func defaultFrame(forPullToRefresh pullToRefresh: PullToRefresh) -> CGRect {
let view = pullToRefresh.refreshView
var originY: CGFloat
switch pullToRefresh.position {
case .top:
originY = -view.frame.size.height
case .bottom:
originY = contentSize.height
}
return CGRect(x: 0, y: originY, width: frame.width, height: view.frame.height)
}

public func addPullToRefresh(_ pullToRefresh: PullToRefresh, action: @escaping () -> ()) {
pullToRefresh.scrollView = self
pullToRefresh.action = action

var originY: CGFloat
let view = pullToRefresh.refreshView

switch pullToRefresh.position {
case .top:
removePullToRefresh(at: .top)

topPullToRefresh = pullToRefresh
originY = -view.frame.size.height

case .bottom:
removePullToRefresh(at: .bottom)

bottomPullToRefresh = pullToRefresh
originY = contentSize.height
}

view.frame = CGRect(x: 0, y: originY, width: frame.width, height: view.frame.height)
view.frame = defaultFrame(forPullToRefresh: pullToRefresh)

addSubview(view)
sendSubview(toBack: view)
Expand Down Expand Up @@ -102,3 +109,80 @@ public extension UIScrollView {
endRefreshing(at: .bottom)
}
}

private var topPullToRefreshInsetsHandlerKey: UInt8 = 0
private var bottomPullToRefreshInsetsHandlerKey: UInt8 = 0
private var implementationSwapedKey: UInt8 = 0

@available(iOS 11.0, *)
extension UIScrollView {

private var topPullToRefreshInsetsHandler: ((UIEdgeInsets) -> Void)? {
get {
return objc_getAssociatedObject(self, &topPullToRefreshInsetsHandlerKey) as? ((UIEdgeInsets) -> Void)
}
set {
objc_setAssociatedObject(self, &topPullToRefreshInsetsHandlerKey, newValue, .OBJC_ASSOCIATION_RETAIN)
}
}

private var bottomPullToRefreshInsetsHandler: ((UIEdgeInsets) -> Void)? {
get {
return objc_getAssociatedObject(self, &bottomPullToRefreshInsetsHandlerKey) as? ((UIEdgeInsets) -> Void)
}
set {
objc_setAssociatedObject(self, &bottomPullToRefreshInsetsHandlerKey, newValue, .OBJC_ASSOCIATION_RETAIN)
}
}

private var isImplementationSwaped: Bool {
get{
return objc_getAssociatedObject(self, &implementationSwapedKey) as? Bool ?? false
}
set{
objc_setAssociatedObject(self, &implementationSwapedKey, newValue, .OBJC_ASSOCIATION_RETAIN)
}
}

internal func addAdjustedContentInsetsHandler(forPosition position: Position, handler: @escaping ((UIEdgeInsets) -> Void)) {
switch position {
case .top:
topPullToRefreshInsetsHandler = handler
case .bottom:
bottomPullToRefreshInsetsHandler = handler
}
if !isImplementationSwaped {
swapAdjustedContentInsetDidChangeImplementation()
isImplementationSwaped = true
}
}

private func swapAdjustedContentInsetDidChangeImplementation() {
let originalSelector = #selector(adjustedContentInsetDidChange)
let swizzledSelector = #selector(patchedAdjustedContentInsetDidChange)

if let originalMethod = class_getInstanceMethod(UIScrollView.self, originalSelector),
let swizzledMethod = class_getInstanceMethod(UIScrollView.self, swizzledSelector) {
method_exchangeImplementations(originalMethod, swizzledMethod)
}
}

internal func removeAdjustedContentInsetsHandler(forPosition position: Position) {
switch position {
case .top:
topPullToRefreshInsetsHandler = nil
case .bottom:
bottomPullToRefreshInsetsHandler = nil
}
if topPullToRefreshInsetsHandler == nil && bottomPullToRefreshInsetsHandler == nil {
swapAdjustedContentInsetDidChangeImplementation()
isImplementationSwaped = false
}
}

@objc internal func patchedAdjustedContentInsetDidChange() {
topPullToRefreshInsetsHandler?(adjustedContentInset)
bottomPullToRefreshInsetsHandler?(adjustedContentInset)
patchedAdjustedContentInsetDidChange()
}
}
Loading

0 comments on commit 4d3b2c5

Please sign in to comment.