TIPullToRefresh/Classes/RMRPullToRefreshController....

422 lines
15 KiB
Swift

//
// RMRPullToRefreshController.swift
// RMRPullToRefresh
//
// Created by Merkulov Ilya on 19.03.16.
// Copyright © 2016 Merkulov Ilya. All rights reserved.
//
import UIKit
open class RMRPullToRefreshController {
// MARK: - Vars
weak var scrollView: UIScrollView?
let containerView = RMRPullToRefreshContainerView()
let backgroundView = UIView(frame: CGRect.zero)
var backgroundViewHeightConstraint: NSLayoutConstraint?
var backgroundViewTopConstraint: NSLayoutConstraint?
var stopped = true
var actionHandler: (() -> Void)!
var height = CGFloat(0.0)
var originalTopInset = CGFloat(0.0)
var originalBottomInset = CGFloat(0.0)
var state = RMRPullToRefreshState.stopped
var result = RMRPullToRefreshResultType.success
var position: RMRPullToRefreshPosition?
var changingContentInset = false
var contentSizeWhenStartLoading: CGSize?
var hideDelayValues = [RMRPullToRefreshResultType: TimeInterval]()
open var hideWhenError: Bool = true
// MARK: - Observation
private var contentOffsetObservation: NSKeyValueObservation?
private var contentSizeObservation: NSKeyValueObservation?
private var panStateObservation: NSKeyValueObservation?
// MARK: - Init
init(scrollView: UIScrollView, position:RMRPullToRefreshPosition, actionHandler: @escaping () -> Void) {
self.scrollView = scrollView
self.actionHandler = actionHandler
self.position = position
self.configureBackgroundView(self.backgroundView)
self.configureHeight()
self.containerView.backgroundColor = UIColor.clear
self.subscribeOnScrollViewEvents()
}
private func configureBackgroundView(_ backgroundView: UIView) {
backgroundView.translatesAutoresizingMaskIntoConstraints = false
scrollView?.addSubview(backgroundView)
addBackgroundViewConstraints(backgroundView)
}
private func addBackgroundViewConstraints(_ backgroundView: UIView) {
guard let scrollView = scrollView, let position = position else {
return
}
let backgroundViewHeightConstraint = backgroundView.heightAnchor.constraint(equalToConstant: 0)
backgroundViewHeightConstraint.isActive = true
self.backgroundViewHeightConstraint = backgroundViewHeightConstraint
backgroundView.widthAnchor.constraint(equalTo: scrollView.widthAnchor).isActive = true
switch position {
case .top:
backgroundView.bottomAnchor.constraint(equalTo: scrollView.topAnchor).isActive = true
case .bottom:
let constant = max(scrollView.contentSize.height, scrollView.bounds.height)
let backgroundViewTopConstraint = backgroundView.topAnchor.constraint(
equalTo: scrollView.bottomAnchor, constant: constant)
backgroundViewTopConstraint.isActive = true
self.backgroundViewTopConstraint = backgroundViewTopConstraint
}
}
private func configureHeight() {
if let scrollView = self.scrollView {
self.originalTopInset = scrollView.contentInset.top
self.originalBottomInset = scrollView.contentInset.bottom
}
configureHeight(RMRPullToRefreshConstants.DefaultHeight)
}
// MARK: - Public
open func configureView(_ view:RMRPullToRefreshView, result:RMRPullToRefreshResultType) {
configureView(view, state: .loading, result: result)
configureView(view, state: .dragging, result: result)
configureView(view, state: .stopped, result: result)
}
open func configureView(_ view:RMRPullToRefreshView, state:RMRPullToRefreshState, result:RMRPullToRefreshResultType) {
containerView.configureView(view, state: state, result: result)
}
open func configureHeight(_ height: CGFloat) {
self.height = height
updateContainerFrame()
}
open func configureBackgroundColor(_ color: UIColor) {
self.backgroundView.backgroundColor = color
}
open func setupDefaultSettings() {
setupDefaultSettings(.success, hideDelay: 0.0)
setupDefaultSettings(.noUpdates, hideDelay: 2.0)
setupDefaultSettings(.error, hideDelay: 2.0)
configureBackgroundColor(UIColor.white)
updateContainerView(self.state)
}
open func startLoading() {
startLoading(0.0)
}
open func stopLoading(_ result:RMRPullToRefreshResultType) {
self.result = result
self.state = .stopped
updateContainerView(self.state)
containerView.prepareForStopAnimations()
var delay = hideDelay(result)
let afterDelay = 0.4
if result == .error && !hideWhenError {
delay = 0.0
}
DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + Double(Int64(delay * Double(NSEC_PER_SEC))) / Double(NSEC_PER_SEC), execute: { [weak self] in
if self?.shouldHideWhenStopLoading() == true {
self?.resetContentInset()
if let position = self?.position {
switch (position) {
case .top:
self?.scrollToTop(true)
case .bottom:
self?.scrollToBottom(true)
}
}
self?.contentSizeWhenStartLoading = nil
DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + afterDelay) {
self?.resetBackgroundViewHeightConstraint()
}
}
DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + afterDelay) {
self?.stopAllAnimations()
}
})
}
open func setHideDelay(_ delay: TimeInterval, result: RMRPullToRefreshResultType) {
self.hideDelayValues[result] = delay
}
// MARK: - Private
func setupDefaultSettings(_ result:RMRPullToRefreshResultType, hideDelay: TimeInterval) {
if let view = RMRPullToRefreshViewFactory.create(result) {
configureView(view, result: result)
setHideDelay(hideDelay, result: result)
}
}
func scrollToTop(_ animated: Bool) {
if let scrollView = self.scrollView {
if scrollView.contentOffset.y < -originalTopInset {
let offset = CGPoint(x: scrollView.contentOffset.x, y: -self.originalTopInset)
scrollView.setContentOffset(offset, animated: true)
}
}
}
func scrollToBottom(_ animated: Bool) {
if let scrollView = self.scrollView {
var offset = scrollView.contentOffset
if let contentSize = self.contentSizeWhenStartLoading {
offset.y = contentSize.height - scrollView.bounds.height + scrollView.contentInset.bottom
if state == .stopped {
if scrollView.contentOffset.y < offset.y {
return
} else if scrollView.contentOffset.y > offset.y {
offset.y += height
}
}
} else {
offset.y = scrollView.contentSize.height - scrollView.bounds.height + scrollView.contentInset.bottom
}
scrollView.setContentOffset(offset, animated: animated)
}
}
func startLoading(_ startProgress: CGFloat) {
stopped = false
contentSizeWhenStartLoading = scrollView?.contentSize
state = .loading
updateContainerView(state)
actionHandler()
containerView.startLoadingAnimation(startProgress)
}
@objc private func stopAllAnimations() {
if shouldHideWhenStopLoading() {
stopped = true
}
containerView.stopAllAnimations(shouldHideWhenStopLoading())
}
@objc private func forceStopAllAnimations() {
stopped = true
containerView.stopAllAnimations(true)
}
@objc private func resetBackgroundViewHeightConstraint() {
backgroundViewHeightConstraint?.constant = 0
}
private func scrollViewDidChangePanState(_ scrollView: UIScrollView, panState: UIGestureRecognizer.State) {
if panState == .ended || panState == .cancelled || panState == .failed {
if state == .loading || (shouldHideWhenStopLoading() && !stopped) {
return
}
var y: CGFloat = 0.0
if position == .top {
y = -scrollView.contentOffset.y
} else if position == .bottom {
y = -(scrollView.contentSize.height - (scrollView.contentOffset.y + scrollView.bounds.height + originalBottomInset));
}
if y >= height {
startLoading(y/height)
// inset
var inset = scrollView.contentInset
if position == .top {
inset.top = originalTopInset+height
} else if position == .bottom {
inset.bottom = originalBottomInset+height
}
setContentInset(inset, animated: true)
} else {
state = .stopped
updateContainerView(state)
}
}
}
private func scrollViewDidChangeContentSize(_ scrollView: UIScrollView, contentSize: CGSize) {
updateContainerFrame()
if position == .bottom {
self.backgroundViewTopConstraint?.constant = max(scrollView.contentSize.height, scrollView.bounds.height)
if changingContentInset {
scrollToBottom(true)
}
}
}
private func scrollViewDidScroll(_ scrollView: UIScrollView, contentOffset: CGPoint) {
if state == .loading {
if scrollView.contentOffset.y >= 0 {
scrollView.contentInset = UIEdgeInsets.zero
} else {
scrollView.contentInset = UIEdgeInsets.init(top: min(-scrollView.contentOffset.y, originalTopInset+height),left: 0,bottom: 0,right: 0)
}
}
if !stopped {
return
}
if scrollView.isDragging && state == .stopped {
state = .dragging
updateContainerView(state)
}
var y: CGFloat = 0.0
if position == .top {
y = -(contentOffset.y)
} else if position == .bottom {
y = -(scrollView.contentSize.height - (contentOffset.y + scrollView.bounds.height + originalBottomInset))
}
if y > 0 {
if state == .dragging {
containerView.dragging(y/height)
}
configureBackgroundHeightConstraint(y, contentInset: scrollView.contentInset)
}
}
private func configureBackgroundHeightConstraint(_ contentOffsetY: CGFloat, contentInset: UIEdgeInsets) {
var constant = CGFloat(-1.0)
if position == .top {
constant = contentOffsetY + contentInset.top
} else {
constant = contentOffsetY + contentInset.bottom
}
if let backgroundViewHeightConstraint = backgroundViewHeightConstraint,
constant > 0,
constant > backgroundViewHeightConstraint.constant {
backgroundViewHeightConstraint.constant = constant
}
}
func updateContainerView(_ state: RMRPullToRefreshState) {
containerView.updateView(state, result: self.result)
}
func updateContainerFrame() {
if let scrollView = self.scrollView, let position = self.position {
var frame = CGRect.zero
switch (position) {
case .top:
frame = CGRect(x: 0, y: -height, width: scrollView.bounds.width, height: height)
case .bottom:
let y = max(scrollView.contentSize.height, scrollView.bounds.height)
frame = CGRect(x: 0, y: y, width: scrollView.bounds.width, height: height)
}
self.containerView.frame = frame
}
}
func resetContentInset() {
if let scrollView = scrollView, let position = self.position {
var inset = scrollView.contentInset
switch (position) {
case .top:
inset.top = originalTopInset
case .bottom:
inset.bottom = originalBottomInset
}
setContentInset(inset, animated: true)
}
}
func setContentInset(_ contentInset: UIEdgeInsets, animated: Bool) {
changingContentInset = true
UIView.animate(withDuration: 0.3,
delay: 0.0,
options: UIView.AnimationOptions.beginFromCurrentState,
animations: { [weak self]() -> Void in
self?.scrollView?.contentInset = contentInset
}, completion: { [weak self](finished) -> Void in
self?.changingContentInset = false
})
}
func checkContentSize(_ scrollView: UIScrollView) -> Bool{
let height = scrollView.bounds.height
if scrollView.contentSize.height < height {
scrollView.contentSize = CGSize(width: scrollView.contentSize.width, height: height)
return false
}
return true
}
func shouldHideWhenStopLoading() -> Bool{
return (result != .error) || (result == .error && hideWhenError)
}
func hideDelay(_ result: RMRPullToRefreshResultType) -> TimeInterval {
if let delay = hideDelayValues[result] {
return delay
}
return 0.0
}
// MARK: - KVO
open func subscribeOnScrollViewEvents() {
guard let scrollView = scrollView else {
return
}
self.contentOffsetObservation = scrollView.observe(
\.contentOffset,
options: [.new]) { [weak self] (scrollView, change) in
guard let newContentOffset = change.newValue else { return }
self?.scrollViewDidScroll(scrollView, contentOffset: newContentOffset)
}
self.contentSizeObservation = scrollView.observe(
\.contentSize,
options: [.new]) { [weak self] (scrollView, change) in
guard let newContentSize = change.newValue else { return }
self?.scrollViewDidChangeContentSize(scrollView, contentSize: newContentSize)
}
self.panStateObservation = scrollView.panGestureRecognizer.observe(
\.state,
options: [.new]) { [weak self] panGestureRecognizer, _ in
self?.scrollViewDidChangePanState(scrollView, panState: panGestureRecognizer.state)
}
}
open func unsubscribeFromScrollViewEvents() {
contentOffsetObservation?.invalidate()
contentSizeObservation?.invalidate()
panStateObservation?.invalidate()
}
}