|
@@ -0,0 +1,403 @@
|
|
|
+//
|
|
|
+// FastScrollCollectionView.swift
|
|
|
+// FastScroll
|
|
|
+//
|
|
|
+// Created by Arsene Huot on 15/06/2018.
|
|
|
+// Copyright © 2018 Frichti. All rights reserved.
|
|
|
+//
|
|
|
+
|
|
|
+import Foundation
|
|
|
+import UIKit
|
|
|
+
|
|
|
+@objc public protocol FastScrollCollectionViewDelegate {
|
|
|
+ @objc func hideHandle()
|
|
|
+}
|
|
|
+
|
|
|
+open class FastScrollCollectionView: UICollectionView {
|
|
|
+ public enum BubbleFocus {
|
|
|
+ case first
|
|
|
+ case last
|
|
|
+ case dynamic
|
|
|
+ }
|
|
|
+
|
|
|
+ // Bubble to display your information during scroll
|
|
|
+ public var deactivateBubble: Bool = false
|
|
|
+ public var bubble: UITextView?
|
|
|
+ public var bubbleFont: UIFont = UIFont.systemFont(ofSize: 12.0)
|
|
|
+ public var bubbleTextSize: CGFloat = 12.0
|
|
|
+ public var bubbleTextColor: UIColor = UIColor.white
|
|
|
+ public var bubbleRadius: CGFloat = 20.0
|
|
|
+ public var bubblePadding: CGFloat = 12.0
|
|
|
+ public var bubbleMarginRight: CGFloat = 30.0
|
|
|
+ public var bubbleColor: UIColor = UIColor.darkGray
|
|
|
+ public var bubbleShadowColor: UIColor = UIColor.darkGray
|
|
|
+ public var bubbleShadowOpacity: Float = 0.7
|
|
|
+ public var bubbleShadowRadius: CGFloat = 3.0
|
|
|
+ public var bubbleShadowOffset: CGSize = CGSize(width: 0.0, height: 5.0)
|
|
|
+ public var bubbleFocus: BubbleFocus = .first
|
|
|
+
|
|
|
+ // Handler to scroll
|
|
|
+ public var handle: UIView?
|
|
|
+ public var handleImage: UIImage?
|
|
|
+ public var handleWidth: CGFloat = 30.0
|
|
|
+ public var handleHeight: CGFloat = 30.0
|
|
|
+ public var handleRadius: CGFloat = 15.0
|
|
|
+ public var handleMarginRight: CGFloat = 6.0
|
|
|
+ public var handleShadowColor: UIColor = UIColor.darkGray
|
|
|
+ public var handleShadowOpacity: Float = 0.7
|
|
|
+ public var handleShadowOffset: CGSize = CGSize(width: 0.0, height: 5.0)
|
|
|
+ public var handleShadowRadius: CGFloat = 3.0
|
|
|
+ public var handleColor: UIColor = UIColor.darkGray
|
|
|
+ public var handleTimeToDisappear: CGFloat = 1.5
|
|
|
+ public var handleDisappearAnimationDuration: CGFloat = 0.2
|
|
|
+ fileprivate var handleTouched: Bool = false
|
|
|
+
|
|
|
+ // Gesture center on handler
|
|
|
+ public var gestureHandleView: UIView?
|
|
|
+ public var gestureWidth: CGFloat = 50.0
|
|
|
+ public var gestureHeight: CGFloat = 50.0
|
|
|
+
|
|
|
+ // Scrollbar
|
|
|
+ public var scrollbar: UIView?
|
|
|
+ public var scrollbarWidth: CGFloat = 2.0
|
|
|
+ public var scrollbarColor: UIColor = UIColor(red: 220.0 / 255.0, green: 220.0 / 255.0, blue: 220.0 / 255.0, alpha: 1.0)
|
|
|
+ public var scrollbarRadius: CGFloat = 1.0
|
|
|
+ public var scrollbarMarginTop: CGFloat = 40.0
|
|
|
+ public var scrollbarMarginBottom: CGFloat = 20.0
|
|
|
+ public var scrollbarMarginRight: CGFloat = 20.0
|
|
|
+
|
|
|
+ // Timer to dismiss handle
|
|
|
+ fileprivate var handleTimer: Timer?
|
|
|
+
|
|
|
+ // Action callback
|
|
|
+ public var bubbleNameForIndexPath: (IndexPath) -> String = { _ in return ""}
|
|
|
+
|
|
|
+ // Delegate
|
|
|
+ public var fastScrollDelegate: FastScrollCollectionViewDelegate?
|
|
|
+
|
|
|
+ // MARK: LifeCycle
|
|
|
+
|
|
|
+ override open func draw(_ rect: CGRect) {
|
|
|
+ super.draw(rect)
|
|
|
+ setup()
|
|
|
+ setupCollectionView()
|
|
|
+ }
|
|
|
+
|
|
|
+ // MARK: Setups
|
|
|
+
|
|
|
+ fileprivate func setupCollectionView() {
|
|
|
+ showsVerticalScrollIndicator = false
|
|
|
+ }
|
|
|
+
|
|
|
+ public func setup() {
|
|
|
+ cleanViews()
|
|
|
+
|
|
|
+ setupScrollbar()
|
|
|
+ setupHandle()
|
|
|
+ setupBubble()
|
|
|
+ }
|
|
|
+
|
|
|
+ public func cleanViews() {
|
|
|
+ guard let bubble = bubble, let handle = handle, let scrollbar = scrollbar, let gestureHandleView = gestureHandleView else {
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ bubble.removeFromSuperview()
|
|
|
+ handle.removeFromSuperview()
|
|
|
+ scrollbar.removeFromSuperview()
|
|
|
+ gestureHandleView.removeFromSuperview()
|
|
|
+
|
|
|
+ self.bubble = nil
|
|
|
+ self.handle = nil
|
|
|
+ self.scrollbar = nil
|
|
|
+ self.gestureHandleView = nil
|
|
|
+ }
|
|
|
+
|
|
|
+ fileprivate func setupHandle() {
|
|
|
+ if handle == nil {
|
|
|
+ handle = UIView(frame: CGRect(x: self.frame.width - handleWidth - handleMarginRight, y: scrollbarMarginTop, width: handleWidth, height: handleHeight))
|
|
|
+ self.superview?.addSubview(handle!)
|
|
|
+
|
|
|
+ gestureHandleView = UIView(frame: CGRect(x: 0.0, y: 0.0, width: gestureWidth, height: gestureHeight))
|
|
|
+ gestureHandleView!.center = handle!.center
|
|
|
+
|
|
|
+ self.superview?.addSubview(handle!)
|
|
|
+ self.superview?.addSubview(gestureHandleView!)
|
|
|
+ }
|
|
|
+
|
|
|
+ //config layer
|
|
|
+ handle!.backgroundColor = handleColor
|
|
|
+ handle!.layer.cornerRadius = handleRadius
|
|
|
+ handle!.layer.shadowColor = handleShadowColor.cgColor
|
|
|
+ handle!.layer.shadowOffset = handleShadowOffset
|
|
|
+ handle!.layer.shadowRadius = handleShadowRadius
|
|
|
+ handle!.layer.shadowOpacity = handleShadowOpacity
|
|
|
+
|
|
|
+ //set imageView
|
|
|
+ if let handleImage = handleImage {
|
|
|
+ let imageView = UIImageView(frame: CGRect(x: 0, y: 0, width: handleWidth, height: handleHeight))
|
|
|
+ imageView.image = handleImage
|
|
|
+ handle!.addSubview(imageView)
|
|
|
+ }
|
|
|
+
|
|
|
+ //set gesture
|
|
|
+ let panGesture = UIPanGestureRecognizer(target: self, action: #selector(handlePanGesture(_:)))
|
|
|
+ gestureHandleView!.addGestureRecognizer(panGesture)
|
|
|
+
|
|
|
+ //hide
|
|
|
+ handle!.alpha = 0.0
|
|
|
+ handle!.isHidden = true
|
|
|
+ gestureHandleView!.isHidden = true
|
|
|
+
|
|
|
+ //position
|
|
|
+ positionHandle(scrollbarMarginTop)
|
|
|
+ }
|
|
|
+
|
|
|
+ fileprivate func setupBubble() {
|
|
|
+ if bubble == nil {
|
|
|
+ bubble = UITextView()
|
|
|
+ self.superview?.addSubview(bubble!)
|
|
|
+ }
|
|
|
+
|
|
|
+ bubble!.font = bubbleFont
|
|
|
+ bubble!.font = UIFont(name: bubbleFont.fontName, size: bubbleTextSize)
|
|
|
+ bubble!.text = "Test"
|
|
|
+ bubble!.textColor = bubbleTextColor
|
|
|
+ bubble!.textAlignment = NSTextAlignment.center
|
|
|
+ bubble!.textContainerInset = UIEdgeInsets(top: bubblePadding, left: bubblePadding, bottom: bubblePadding, right: bubblePadding)
|
|
|
+ bubble!.contentMode = UIView.ContentMode.scaleAspectFit
|
|
|
+ bubble!.sizeToFit()
|
|
|
+
|
|
|
+ bubble!.backgroundColor = bubbleColor
|
|
|
+ bubble!.layer.cornerRadius = bubbleRadius
|
|
|
+ bubble!.layer.shadowColor = bubbleShadowColor.cgColor
|
|
|
+ bubble!.layer.shadowOffset = bubbleShadowOffset
|
|
|
+ bubble!.layer.shadowRadius = bubbleRadius
|
|
|
+ bubble!.layer.shadowOpacity = bubbleShadowOpacity
|
|
|
+ bubble!.layer.shadowRadius = bubbleShadowRadius
|
|
|
+ bubble!.layer.masksToBounds = false
|
|
|
+
|
|
|
+ bubble!.isHidden = true
|
|
|
+
|
|
|
+ updateBubblePosition()
|
|
|
+ }
|
|
|
+
|
|
|
+ fileprivate func setupScrollbar() {
|
|
|
+ guard let superview = self.superview else {
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ if scrollbar == nil {
|
|
|
+ scrollbar = UIView(frame: CGRect(x: self.frame.width - scrollbarWidth - scrollbarMarginRight, y: scrollbarMarginTop, width: scrollbarWidth, height: superview.bounds.height - scrollbarMarginBottom - scrollbarMarginTop))
|
|
|
+ self.superview?.addSubview(scrollbar!)
|
|
|
+ }
|
|
|
+
|
|
|
+ scrollbar!.backgroundColor = scrollbarColor
|
|
|
+ scrollbar!.layer.cornerRadius = scrollbarRadius
|
|
|
+ scrollbar!.alpha = 0.0
|
|
|
+ scrollbar!.isHidden = true
|
|
|
+ }
|
|
|
+
|
|
|
+ // MARK: Helpers
|
|
|
+ @objc func hideHandle() {
|
|
|
+ guard let handle = handle, let scrollbar = scrollbar, let gestureHandleView = gestureHandleView else {
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ if gestureHandleView.isHidden == false {
|
|
|
+ self.fastScrollDelegate?.hideHandle()
|
|
|
+ }
|
|
|
+
|
|
|
+ gestureHandleView.isHidden = true
|
|
|
+
|
|
|
+ UIView.animate(withDuration: TimeInterval(handleDisappearAnimationDuration), animations: {
|
|
|
+ handle.alpha = 0.0
|
|
|
+ scrollbar.alpha = 0.0
|
|
|
+ }, completion: { finished in
|
|
|
+ if finished {
|
|
|
+ handle.isHidden = true
|
|
|
+ scrollbar.isHidden = true
|
|
|
+ }
|
|
|
+ })
|
|
|
+ }
|
|
|
+
|
|
|
+ fileprivate func updateBubblePosition() {
|
|
|
+ guard let scrollbar = scrollbar, let bubble = bubble, let handle = handle else {
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ bubble.frame.origin.x = scrollbar.frame.origin.x - bubble.frame.size.width - bubbleMarginRight
|
|
|
+ bubble.center.y = handle.center.y
|
|
|
+ }
|
|
|
+
|
|
|
+ fileprivate func positionHandle(_ y: CGFloat) {
|
|
|
+ guard let handle = handle, let scrollbar = scrollbar, let gestureHandleView = gestureHandleView else {
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ handle.frame.origin.y = y >= scrollbarMarginTop ?
|
|
|
+ (y > scrollbarMarginTop + scrollbar.frame.height - handle.frame.height) ? scrollbarMarginTop + scrollbar.frame.height - handle.frame.height : y
|
|
|
+ :
|
|
|
+ scrollbarMarginTop
|
|
|
+
|
|
|
+ gestureHandleView.center = handle.center
|
|
|
+ }
|
|
|
+
|
|
|
+ fileprivate func scrollCollectionFromHandle() {
|
|
|
+ guard let handle = handle, let scrollbar = scrollbar else {
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ let collectionContentHeight = self.contentSize.height - self.bounds.height
|
|
|
+ let scrollBarHeight = scrollbar.frame.height
|
|
|
+
|
|
|
+ let scrollY = (handle.frame.origin.y - scrollbarMarginTop) * (collectionContentHeight / (scrollBarHeight - handle.frame.size.height))
|
|
|
+
|
|
|
+ self.setContentOffset(CGPoint(x: 0.0, y: scrollY), animated: false)
|
|
|
+ }
|
|
|
+
|
|
|
+ @objc func handlePanGesture(_ panGesture: UIPanGestureRecognizer) {
|
|
|
+ guard let superview = superview, let bubble = bubble, let handle = handle, let scrollbar = scrollbar, let gestureHandleView = gestureHandleView else {
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ // get translation
|
|
|
+ let translation = panGesture.translation(in: superview)
|
|
|
+ panGesture.setTranslation(CGPoint.zero, in: superview)
|
|
|
+
|
|
|
+ // manage start stop pan
|
|
|
+ if panGesture.state == UIGestureRecognizer.State.began {
|
|
|
+ bubble.isHidden = deactivateBubble ? true : false
|
|
|
+ handleTouched = true
|
|
|
+
|
|
|
+ //invalid hide timer
|
|
|
+ if let handleTimer = handleTimer {
|
|
|
+ handleTimer.invalidate()
|
|
|
+ }
|
|
|
+
|
|
|
+ handle.alpha = 1.0
|
|
|
+ scrollbar.alpha = 1.0
|
|
|
+ handle.isHidden = false
|
|
|
+ scrollbar.isHidden = false
|
|
|
+ gestureHandleView.isHidden = false
|
|
|
+ }
|
|
|
+
|
|
|
+ if panGesture.state == UIGestureRecognizer.State.ended {
|
|
|
+ bubble.isHidden = true
|
|
|
+ handleTouched = false
|
|
|
+ if contentOffset.y < 0 {
|
|
|
+ self.setContentOffset(CGPoint(x: 0.0, y: 0), animated: false)
|
|
|
+ }
|
|
|
+ self.handleTimer = Timer.scheduledTimer(timeInterval: TimeInterval(handleTimeToDisappear), target: self, selector: #selector(hideHandle), userInfo: nil, repeats: false)
|
|
|
+ }
|
|
|
+
|
|
|
+ if panGesture.state == UIGestureRecognizer.State.changed {
|
|
|
+ //invalid hide timer
|
|
|
+ if let handleTimer = handleTimer {
|
|
|
+ handleTimer.invalidate()
|
|
|
+ }
|
|
|
+
|
|
|
+ handle.alpha = 1.0
|
|
|
+ scrollbar.alpha = 1.0
|
|
|
+ handle.isHidden = false
|
|
|
+ scrollbar.isHidden = false
|
|
|
+ gestureHandleView.isHidden = false
|
|
|
+ }
|
|
|
+
|
|
|
+ // views positions
|
|
|
+ positionHandle(handle.frame.origin.y + translation.y)
|
|
|
+ updateBubblePosition()
|
|
|
+ scrollCollectionFromHandle()
|
|
|
+
|
|
|
+ // manage bubble info
|
|
|
+ manageBubbleInfo()
|
|
|
+ }
|
|
|
+
|
|
|
+ fileprivate func manageBubbleInfo() {
|
|
|
+ guard let bubble = bubble else {
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ let visibleCells = self.visibleCells
|
|
|
+
|
|
|
+ var currentCellIndex: Int
|
|
|
+
|
|
|
+ switch bubbleFocus {
|
|
|
+ case .first:
|
|
|
+ currentCellIndex = 0
|
|
|
+
|
|
|
+ case .last:
|
|
|
+ currentCellIndex = visibleCells.count - 1
|
|
|
+
|
|
|
+ case .dynamic:
|
|
|
+ //Calcul scroll percentage
|
|
|
+ let scrollY = contentOffset.y
|
|
|
+ let collectionContentHeight = self.contentSize.height > self.bounds.height ? self.contentSize.height - self.bounds.height : self.bounds.height
|
|
|
+ let scrollPercentage = scrollY / collectionContentHeight
|
|
|
+ currentCellIndex = Int(floor(CGFloat(visibleCells.count) * scrollPercentage))
|
|
|
+ if currentCellIndex < 0 {
|
|
|
+ currentCellIndex = 0
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ if currentCellIndex < visibleCells.count {
|
|
|
+ if let indexPath = indexPath(for: visibleCells[currentCellIndex]) {
|
|
|
+ bubble.text = bubbleNameForIndexPath(indexPath)
|
|
|
+ let newSize = bubble.sizeThatFits(CGSize(width: self.bounds.width - (self.bounds.width - (bubble.frame.origin.x + bubble.frame.size.width)), height: bubble.frame.size.height))
|
|
|
+ let oldSize = bubble.frame.size
|
|
|
+ bubble.frame = CGRect(x: bubble.frame.origin.x + (oldSize.width - newSize.width), y: bubble.frame.origin.y, width: newSize.width, height: newSize.height)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// MARK: Scroll Management
|
|
|
+
|
|
|
+extension FastScrollCollectionView {
|
|
|
+ public func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
|
|
|
+ guard let handle = handle, let scrollbar = scrollbar, let gestureHandleView = gestureHandleView else {
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ handle.alpha = 1.0
|
|
|
+ scrollbar.alpha = 1.0
|
|
|
+ handle.isHidden = false
|
|
|
+ scrollbar.isHidden = false
|
|
|
+ gestureHandleView.isHidden = false
|
|
|
+ }
|
|
|
+
|
|
|
+ public func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
|
|
|
+ if !decelerate {
|
|
|
+ self.handleTimer = Timer.scheduledTimer(timeInterval: TimeInterval(handleTimeToDisappear), target: self, selector: #selector(hideHandle), userInfo: nil, repeats: false)
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ public func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
|
|
|
+ self.handleTimer = Timer.scheduledTimer(timeInterval: TimeInterval(handleTimeToDisappear), target: self, selector: #selector(hideHandle), userInfo: nil, repeats: false)
|
|
|
+ }
|
|
|
+
|
|
|
+ public func scrollViewDidScroll(_ scrollView: UIScrollView) {
|
|
|
+ guard let handle = handle, let scrollbar = scrollbar else {
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ //invalid timer
|
|
|
+ if let handleTimer = handleTimer {
|
|
|
+ handleTimer.invalidate()
|
|
|
+ }
|
|
|
+
|
|
|
+ //scroll position
|
|
|
+ let scrollY = scrollView.contentOffset.y
|
|
|
+ let collectionContentHeight = self.contentSize.height > self.bounds.height ? self.contentSize.height - self.bounds.height : self.bounds.height
|
|
|
+ let scrollBarHeight = scrollbar.frame.height
|
|
|
+
|
|
|
+
|
|
|
+ let handlePosition = (scrollY / collectionContentHeight) * (scrollBarHeight - handle.frame.size.height) + scrollbarMarginTop
|
|
|
+ if (handleTouched == false) {
|
|
|
+ positionHandle(handlePosition)
|
|
|
+ }
|
|
|
+
|
|
|
+ updateBubblePosition()
|
|
|
+ }
|
|
|
+}
|
|
|
+
|