Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fixes based on some custom animation. #127

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 0 additions & 4 deletions PullToRefresh/DefaultRefreshView.swift
Original file line number Diff line number Diff line change
@@ -11,11 +11,7 @@ import UIKit
class DefaultRefreshView: UIView {

fileprivate(set) lazy var activityIndicator: UIActivityIndicatorView! = {
#if swift(>=4.2)
let activityIndicator = UIActivityIndicatorView(style: .gray)
#else
let activityIndicator = UIActivityIndicatorView(activityIndicatorStyle: .gray)
#endif
self.addSubview(activityIndicator)
return activityIndicator
}()
17 changes: 6 additions & 11 deletions PullToRefresh/DefaultViewAnimator.swift
Original file line number Diff line number Diff line change
@@ -22,22 +22,17 @@ class DefaultViewAnimator: RefreshViewAnimator {
refreshView.activityIndicator.stopAnimating()

case .releasing(let progress):
transform(to: progress)
refreshView.activityIndicator.isHidden = false

var transform = CGAffineTransform.identity
transform = transform.scaledBy(x: progress, y: progress)
transform = transform.rotated(by: CGFloat(Double.pi) * progress * 2)
refreshView.activityIndicator.transform = transform

case .loading:
transform(to: 1.0)
refreshView.activityIndicator.startAnimating()

default: break
}
}

private func transform(to progress: CGFloat) {
refreshView.activityIndicator.isHidden = false

var transform = CGAffineTransform.identity
transform = transform.scaledBy(x: progress, y: progress)
transform = transform.rotated(by: CGFloat(Double.pi) * progress * 2)
refreshView.activityIndicator.transform = transform
}
}
218 changes: 54 additions & 164 deletions PullToRefresh/PullToRefresh.swift
Original file line number Diff line number Diff line change
@@ -11,16 +11,6 @@ import UIKit
public enum Position {

case top, bottom

var opposite: Position {
switch self {
case .top:
return .bottom
case .bottom:
return .top
}
}

}

open class PullToRefresh: NSObject {
@@ -31,29 +21,17 @@ open class PullToRefresh: NSObject {
open var hideDelay: TimeInterval = 0
open var springDamping: CGFloat = 0.4
open var initialSpringVelocity: CGFloat = 0.8
#if swift(>=4.2)
open var animationOptions: UIView.AnimationOptions = [.curveLinear]
#else
open var animationOptions: UIViewAnimationOptions = [.curveLinear]
#endif
open var shouldBeVisibleWhileScrolling: Bool = false
open var topPadding : CGFloat? = nil


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

private(set) var isAutoenablePosible = true
public internal(set) var isEnabled: Bool = false {
didSet{
refreshView.isHidden = !isEnabled
if isEnabled {
addScrollViewObserving()
} else {
removeScrollViewObserving()
}
}
}
var action: (() -> Void)?
fileprivate var isObserving = false
fileprivate let animator: RefreshViewAnimator

// MARK: - ScrollView & Observing

fileprivate var scrollViewDefaultInsets: UIEdgeInsets = .zero
weak var scrollView: UIScrollView? {
willSet {
removeScrollViewObserving()
@@ -66,26 +44,9 @@ open class PullToRefresh: NSObject {
}
}

fileprivate let animator: RefreshViewAnimator
fileprivate var isObserving = false

// MARK: - ScrollView & Observing

fileprivate var scrollViewDefaultInsets: 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 {
@@ -95,19 +56,15 @@ open class PullToRefresh: NSObject {
}

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

case .releasing(progress: let value) where value < 0.1:
state = .initial


default: break
}
self.enableOppositeRefresher(state == .initial)
}
}

@@ -117,10 +74,6 @@ open class PullToRefresh: NSObject {
self.refreshView = refreshView
self.animator = animator
self.position = position

self.refreshView.frame.size.height = height
self.refreshView.translatesAutoresizingMaskIntoConstraints = false
self.refreshView.autoresizingMask = [.flexibleWidth]
}

public convenience init(height: CGFloat = 40, position: Position = .top) {
@@ -131,34 +84,20 @@ open class PullToRefresh: NSObject {
self.init(refreshView: refreshView, animator: DefaultViewAnimator(refreshView: refreshView), height: height, position: position)
}

public func setEnable(isEnabled: Bool) {
self.isEnabled = isEnabled
isAutoenablePosible = isEnabled
}

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

// 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)
}

}
// 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

override open func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
if (context == &KVO.context && keyPath == KVO.ScrollViewPath.contentOffset && object as? UIScrollView == scrollView) {
if (context == &PullToRefresh.KVOContext && keyPath == contentOffsetKeyPath && object as? UIScrollView == scrollView) {
var offset: CGFloat
switch position {
case .top:
@@ -170,11 +109,9 @@ extension PullToRefresh {
} else {
offset = scrollView!.contentSize.height - previousScrollViewOffset.y
}
if #available(iOS 11, *) {
offset += scrollView!.safeAreaInsets.top
}
}
let refreshViewHeight = refreshView.frame.size.height

switch offset {
case 0 where (state != .loading): state = .initial
case -refreshViewHeight...0 where (state != .loading && state != .finished):
@@ -188,29 +125,31 @@ extension PullToRefresh {
}
default: break
}
} else if (context == &KVO.context && keyPath == KVO.ScrollViewPath.contentSize && object as? UIScrollView == scrollView) {
} else if (context == &PullToRefresh.KVOContext && keyPath == contentSizeKeyPath && 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 == &KVO.context && keyPath == KVO.ScrollViewPath.contentInset && object as? UIScrollView == scrollView) {
} else if (context == &PullToRefresh.KVOContext && keyPath == contentInsetKeyPath && 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?.normalizedContentOffset.y ?? 0

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

fileprivate func addScrollViewObserving() {
guard let scrollView = scrollView, !isObserving else {
return
}

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

isObserving = true
}

@@ -219,51 +158,39 @@ extension PullToRefresh {
return
}

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)
scrollView.removeObserver(self, forKeyPath: contentOffsetKeyPath, context: &PullToRefresh.KVOContext)
scrollView.removeObserver(self, forKeyPath: contentSizeKeyPath, context: &PullToRefresh.KVOContext)
scrollView.removeObserver(self, forKeyPath: contentInsetKeyPath, context: &PullToRefresh.KVOContext)

isObserving = false
}

}

// MARK: - Start/End Refreshin
extension PullToRefresh {

func startRefreshing() {
guard !isOppositeRefresherLoading, state == .initial, let scrollView = scrollView, isEnabled else {
if self.state != .initial {
return
}

let topInset: CGFloat = {
if #available(iOS 11, *) {
return scrollView.safeAreaInsets.top
}
return 0
}()

var offsetY: CGFloat
switch position {
case .top:
offsetY = -refreshView.frame.height - scrollViewDefaultInsets.top - topInset
offsetY = -refreshView.frame.height - scrollViewDefaultInsets.top

case .bottom:
if scrollView.contentSize.height + refreshView.frame.height > scrollView.frame.height {
offsetY = scrollView.contentSize.height
+ refreshView.frame.height
+ scrollViewDefaultInsets.bottom
- scrollView.bounds.height
} else {
offsetY = 0 - topInset
}
offsetY = scrollView!.contentSize.height + refreshView.frame.height + scrollViewDefaultInsets.bottom - scrollView!.bounds.height
}

scrollView?.setContentOffset(CGPoint(x: 0, y: offsetY), animated: true)
let delayTime = DispatchTime.now() + Double(Int64(0.27 * Double(NSEC_PER_SEC))) / Double(NSEC_PER_SEC)
DispatchQueue.main.asyncAfter(deadline: delayTime) { [weak self] in
self?.state = .loading
}
state = .loading
scrollView.setContentOffset(CGPoint(x: 0, y: offsetY), animated: true)
}

func endRefreshing() {
guard isEnabled else { return }

if state == .loading {
state = .finished
}
@@ -273,25 +200,8 @@ extension PullToRefresh {
// MARK: - Animate scroll view
private extension PullToRefresh {

var isOppositeRefresherLoading: Bool {
guard let scrollView = scrollView, let oppositeRefresher = scrollView.refresher(at: position.opposite) else {
return false
}
return oppositeRefresher.state != .initial
}

func enableOppositeRefresher(_ enable: Bool) {
guard
let scrollView = scrollView,
let oppositeRefresher = scrollView.refresher(at: position.opposite),
oppositeRefresher.isAutoenablePosible
else { return }

oppositeRefresher.isEnabled = enable
}

func animateLoadingState() {
guard !isOppositeRefresherLoading, let scrollView = scrollView else {
guard let scrollView = scrollView else {
return
}

@@ -302,21 +212,20 @@ private extension PullToRefresh {
animations: {
switch self.position {
case .top:
let insetY = self.refreshView.frame.height + self.scrollViewDefaultInsets.top
scrollView.contentInset.top = insetY
scrollView.contentOffset = CGPoint(x: scrollView.contentOffset.x, y: -insetY)
let insets = self.refreshView.frame.height + self.scrollViewDefaultInsets.top
scrollView.contentInset.top = insets
scrollView.contentOffset = CGPoint(x: scrollView.contentOffset.x, y: -insets)

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

action?()
}

@@ -330,6 +239,9 @@ private extension PullToRefresh {
options: animationOptions,
animations: {
self.scrollView?.contentInset = self.scrollViewDefaultInsets
if case .top = self.position {
self.scrollView?.contentOffset.y = -self.scrollViewDefaultInsets.top
}
},
completion: { _ in
self.addScrollViewObserving()
@@ -342,31 +254,9 @@ private extension PullToRefresh {
// MARK: - Helpers
private extension PullToRefresh {

var isCurrentlyVisible: Bool {
func isCurrentlyVisible() -> Bool {
guard let scrollView = scrollView else { return false }

return scrollView.normalizedContentOffset.y <= -scrollViewDefaultInsets.top
return scrollView.contentOffset.y <= -scrollViewDefaultInsets.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)
#if swift(>=4.2)
scrollView.sendSubviewToBack(refreshView)
#else
scrollView.sendSubview(toBack: refreshView)
#endif
}

}
78 changes: 7 additions & 71 deletions PullToRefresh/UIScrollView+PullToRefresh.swift
Original file line number Diff line number Diff line change
@@ -33,40 +33,31 @@ public extension UIScrollView {
}
}

func addPullToRefresh(_ pullToRefresh: PullToRefresh, action: @escaping () -> ()) {
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 = defaultFrame(forPullToRefresh: pullToRefresh)
view.frame = CGRect(x: 0, y: originY, width: frame.width, height: view.frame.height)

addSubview(view)
#if swift(>=4.2)
sendSubviewToBack(view)
#else
sendSubview(toBack: view)
#endif
}

func refresher(at position: Position) -> PullToRefresh? {
switch position {
case .top:
return topPullToRefresh

case .bottom:
return bottomPullToRefresh
}
}

func removePullToRefresh(at position: Position) {
@@ -110,59 +101,4 @@ public extension UIScrollView {
endRefreshing(at: .top)
endRefreshing(at: .bottom)
}

}

internal func - (lhs: UIEdgeInsets, rhs: UIEdgeInsets) -> UIEdgeInsets {
return UIEdgeInsets(
top: lhs.top - rhs.top,
left: lhs.left - rhs.left,
bottom: lhs.bottom - rhs.bottom,
right: lhs.right - rhs.right
)
}

internal extension UIScrollView {

var normalizedContentOffset: CGPoint {
get {
let contentOffset = self.contentOffset
let contentInset = self.effectiveContentInset

let output = CGPoint(x: contentOffset.x + contentInset.left, y: contentOffset.y + contentInset.top)
return output
}
}

var effectiveContentInset: UIEdgeInsets {
get {
if #available(iOS 11, *) {
return adjustedContentInset
} else {
return contentInset
}
}

set {
if #available(iOS 11.0, *), contentInsetAdjustmentBehavior != .never {
contentInset = newValue - safeAreaInsets
} else {
contentInset = newValue
}
}
}

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
}
let height = view.frame.height + (pullToRefresh.topPadding ?? 0)
return CGRect(x: 0, y: originY, width: frame.width, height: height)
}

}