// // ZKCycleScrollView.swift // ZKCycleScrollViewDemo // // Created by bestdew on 2019/3/8. // Copyright © 2019 bestdew. All rights reserved. // // d*##$. // zP"""""$e. $" $o //4$ '$ $" $ //'$ '$ J$ $F // 'b $k $> $ // $k $r J$ d$ // '$ $ $" $~ // '$ "$ '$E $ // $ $L $" $F ... // $. 4B $ $$$*"""*b // '$ $. $$ $$ $F // "$ R$ $F $" $ // $k ?$ u* dF .$ // ^$. $$" z$ u$$$$e // #$b $E.dW@e$" ?$ // #$ .o$$# d$$$$c ?F // $ .d$$#" . zo$> #$r .uF // $L .u$*" $&$$$k .$$d$$F // $$" ""^"$$$P"$P9$ // JP .o$$$$u:$P $$ // $ ..ue$" "" $" // d$ $F $ // $$ ....udE 4B // #$ """"` $r @$ // ^$L '$ $F // RN 4N $ // *$b d$ // $$k $F // $$b $F // $"" $F // '$ $ // $L $ // '$ $ // $ $ import UIKit public typealias ZKCycleScrollViewCell = UICollectionViewCell public enum ZKScrollDirection: Int { case horizontal case vertical } @objc public protocol ZKCycleScrollViewDataSource: NSObjectProtocol { /// Return number of pages func numberOfItems(in cycleScrollView: ZKCycleScrollView) -> Int /// The cell that is returned must be retrieved from a call to -dequeueReusableCellWithReuseIdentifier:forIndex: func cycleScrollView(_ cycleScrollView: ZKCycleScrollView, cellForItemAt index: Int) -> ZKCycleScrollViewCell } @objc public protocol ZKCycleScrollViewDelegate: NSObjectProtocol { /// Called when the cell is clicked @objc optional func cycleScrollView(_ cycleScrollView: ZKCycleScrollView, didSelectItemAt index: Int) /// Called when the offset changes. The progress range is from 0 to the maximum index value, which means the progress value for a round of scrolling @objc optional func cycleScrollViewDidScroll(_ cycleScrollView: ZKCycleScrollView, progress: Double) /// Called when scrolling to a new index page @objc optional func cycleScrollView(_ cycleScrollView: ZKCycleScrollView, didScrollFromIndex fromIndex: Int, toIndex: Int) } @IBDesignable open class ZKCycleScrollView: UIView { @IBOutlet open weak var delegate: ZKCycleScrollViewDelegate? @IBOutlet open weak var dataSource: ZKCycleScrollViewDataSource? var currentIdx = 0 var stepLength: CGFloat? // 自定义每个时间间隔移动的步长 #if TARGET_INTERFACE_BUILDER @IBInspectable open var scrollDirection: Int = 0 #else /// default horizontal. scroll direction open var scrollDirection: ZKScrollDirection = .horizontal { didSet { switch scrollDirection { case .vertical: flowLayout?.scrollDirection = .vertical default: flowLayout?.scrollDirection = .horizontal } } } #endif /// default 3.f. automatic scroll time interval @IBInspectable open var autoScrollInterval: TimeInterval = 3 { didSet { addTimer() } } @IBInspectable open var isAutoScroll: Bool = true { didSet { addTimer() } } /// default true. turn off any dragging temporarily @IBInspectable open var allowsDragging: Bool = true { didSet { collectionView.isScrollEnabled = allowsDragging } } /// default the view size @IBInspectable open var itemSize: CGSize = CGSize.zero { didSet { itemSizeFlag = true flowLayout.itemSize = itemSize flowLayout.headerReferenceSize = CGSize(width: (bounds.width - itemSize.width) / 2, height: (bounds.height - itemSize.height) / 2) flowLayout.footerReferenceSize = CGSize(width: (bounds.width - itemSize.width) / 2, height: (bounds.height - itemSize.height) / 2) } } /// default 0.0 @IBInspectable open var itemSpacing: CGFloat = 0.0 { didSet { flowLayout.minimumLineSpacing = itemSpacing } } /// default 1.f(no scaling), it ranges from 0.f to 1.f @IBInspectable open var itemZoomScale: CGFloat = 1.0 { didSet { flowLayout.zoomScale = itemZoomScale } } @IBInspectable open var hidesPageControl: Bool = false { didSet { pageControl?.isHidden = hidesPageControl } } @IBInspectable open var pageIndicatorTintColor: UIColor = UIColor.gray { didSet { pageControl?.pageIndicatorTintColor = pageIndicatorTintColor } } @IBInspectable open var currentPageIndicatorTintColor: UIColor = UIColor.white { didSet { pageControl?.currentPageIndicatorTintColor = currentPageIndicatorTintColor } } /// current page index open var pageIndex: Int { return changeIndex(currentIndex()) } /// current content offset open var contentOffset: CGPoint { let num = CGFloat(numberOfAddedCells() / 2) switch scrollDirection { case .vertical: return CGPoint(x: 0.0, y: max(0.0, collectionView.contentOffset.y - (flowLayout.itemSize.height + flowLayout.minimumLineSpacing) * num)) default: return CGPoint(x: max(0.0, collectionView.contentOffset.x - (flowLayout.itemSize.width + flowLayout.minimumLineSpacing) * num), y: 0.0) } } /// infinite cycle @IBInspectable open private(set) var isInfiniteLoop: Bool = true /// load completed callback open var loadCompletion: (() -> Void)? = nil open var pageControlFrame: CGRect? var pageControl: UIPageControl! var collectionView: UICollectionView! private var flowLayout: ZKCycleScrollViewFlowLayout! private var timer: Timer? private var numberOfItems: Int = 0 private var fromIndex: Int = 0 private var itemSizeFlag: Bool = false private var indexOffset: Int = 0 private var configuredFlag: Bool = false private var tempIndex: Int = 0 // MARK: - Open Func open func register(_ cellClass: AnyClass?, forCellWithReuseIdentifier identifier: String) { collectionView.register(cellClass, forCellWithReuseIdentifier: identifier) } open func register(_ nib: UINib?, forCellWithReuseIdentifier identifier: String) { collectionView.register(nib, forCellWithReuseIdentifier: identifier) } open func dequeueReusableCell(withReuseIdentifier identifier: String, for index: Int) -> ZKCycleScrollViewCell { let indexPath = IndexPath(item: changeIndex(index), section: 0) return collectionView.dequeueReusableCell(withReuseIdentifier: identifier, for: indexPath) } open func reloadData() { removeTimer() UIView.performWithoutAnimation { self.collectionView.reloadData() } collectionView.performBatchUpdates(nil) { _ in self.configuration() self.loadCompletion?() } } /// Call -beginUpdates and -endUpdates to update layout /// Allows multiple scrollDirection/itemSize/itemSpacing/itemZoomScale to be set simultaneously. open func beginUpdates() { tempIndex = pageIndex removeTimer() } open func endUpdates() { flowLayout.invalidateLayout() scrollToItem(at: tempIndex, animated: false) addTimer() } /// Scroll to page open func scrollToItem(at index: Int, animated: Bool) { let num = numberOfAddedCells() guard index >= 0 && index <= numberOfItems - 1 - num else { print("⚠️attempt to scroll to invalid index:\(index)") return } removeTimer() let idx = index + num / 2 let position = scrollPosition() let indexPath = IndexPath(item: idx, section: 0) collectionView.scrollToItem(at: indexPath, at: position, animated: animated) addTimer() } /// Returns the visible cell object at the specified index open func cellForItem(at index: Int) -> ZKCycleScrollViewCell? { let num = numberOfAddedCells() guard index >= 0 && index <= numberOfItems - 1 - num else { return nil } let idx = index + num / 2 let indexPath = IndexPath(item: idx, section: 0) let cell = collectionView.cellForItem(at: indexPath) return cell } // MARK: - Init public init(frame: CGRect, shouldInfiniteLoop infiniteLoop: Bool? = nil) { super.init(frame: frame) isInfiniteLoop = infiniteLoop ?? true initialization() } required public init?(coder aDecoder: NSCoder) { super.init(coder: aDecoder) initialization() } override open func layoutSubviews() { super.layoutSubviews() if itemSizeFlag { flowLayout.itemSize = itemSize flowLayout.headerReferenceSize = CGSize(width: (bounds.width - itemSize.width) / 2, height: (bounds.height - itemSize.height) / 2) flowLayout.footerReferenceSize = CGSize(width: (bounds.width - itemSize.width) / 2, height: (bounds.height - itemSize.height) / 2) } else { flowLayout.itemSize = bounds.size flowLayout.headerReferenceSize = CGSize.zero flowLayout.footerReferenceSize = CGSize.zero } collectionView.frame = bounds if let pageControlFrame = pageControlFrame { pageControl.frame = pageControlFrame } else { pageControl.frame = CGRect(x: 0.0, y: bounds.height - 15.0, width: bounds.width, height: 15.0) } } override open func willMove(toSuperview newSuperview: UIView?) { if newSuperview == nil { removeTimer() } } override open func setValue(_ value: Any?, forUndefinedKey key: String) { guard key == "scrollDirection" else { return; } let direction = value as! Int if direction == 1 { scrollDirection = .vertical } else { scrollDirection = .horizontal } } deinit { collectionView.delegate = nil collectionView.dataSource = nil } // MARK: - Private Func private func initialization() { flowLayout = ZKCycleScrollViewFlowLayout() flowLayout.minimumLineSpacing = 0 flowLayout.minimumInteritemSpacing = 0 flowLayout.scrollDirection = .horizontal collectionView = UICollectionView(frame: CGRect.zero, collectionViewLayout: flowLayout) collectionView.backgroundColor = nil collectionView.delegate = self collectionView.dataSource = self collectionView.scrollsToTop = false collectionView.bounces = false collectionView.showsVerticalScrollIndicator = false collectionView.showsHorizontalScrollIndicator = false addSubview(collectionView) pageControl = UIPageControl() pageControl.isEnabled = false pageControl.hidesForSinglePage = true pageControl.pageIndicatorTintColor = UIColor.gray pageControl.currentPageIndicatorTintColor = UIColor.white addSubview(pageControl); DispatchQueue.main.async { self.configuration() self.loadCompletion?() } } private func configuration() { fromIndex = 0 indexOffset = 0 configuredFlag = false guard numberOfItems > 1 else { return } let position = scrollPosition() if isInfiniteLoop { let indexPath = IndexPath(item: 2, section: 0) collectionView.scrollToItem(at: indexPath, at: position, animated: false) } addTimer() updatePageControl() configuredFlag = true } func addTimer() { removeTimer() if numberOfItems < 2 || !isAutoScroll || autoScrollInterval <= 0.0 { return } timer = Timer.scheduledTimer(timeInterval: autoScrollInterval, target: YYWeakProxy(target: self), selector: #selector(automaticScroll), userInfo: nil, repeats: true) RunLoop.main.add(timer!, forMode: .common) } private func updatePageControl() { let num = numberOfAddedCells() pageControl.currentPage = 0 pageControl.numberOfPages = max(0, numberOfItems - num) pageControl.isHidden = (hidesPageControl || pageControl.numberOfPages < 2) } private func numberOfAddedCells() -> Int { return isInfiniteLoop ? 4 : 0 } @objc private func automaticScroll() { var index = currentIndex() + 1 if !isInfiniteLoop && index >= numberOfItems { index = 0 } if let stepLength = stepLength { let offsetX = self.collectionView.contentOffset.x let width = flowLayout.itemSize.width + flowLayout.minimumLineSpacing self.collectionView.contentOffset = CGPoint(x: offsetX+stepLength, y: self.contentOffset.y) let position = scrollPosition() if self.currentIndex() == 1 { let indexPath = IndexPath(item: numberOfItems - 3, section: 0) collectionView.scrollToItem(at: indexPath, at: position, animated: false) } else if self.currentIndex() == numberOfItems - 2 { let offsetx = (width)*CGFloat(2) - width*0.5 self.collectionView.contentOffset = CGPoint(x: offsetx, y: self.contentOffset.y) self.collectionView.reloadData() } } else { let position = scrollPosition() let indexPath = IndexPath(item: index, section: 0) collectionView.scrollToItem(at: indexPath, at: position, animated: true) } } func removeTimer() { timer?.invalidate() timer = nil } private func scrollPosition() -> UICollectionView.ScrollPosition { switch scrollDirection { case .vertical: return .centeredVertically default: return .centeredHorizontally } } private func currentIndex() -> Int { guard numberOfItems > 0 else { return -1 } var index = 0 var minimumIndex = 0 var maximumIndex = numberOfItems - 1 if numberOfItems == 1 { return index } if isInfiniteLoop { minimumIndex = 1 maximumIndex = numberOfItems - 2 } switch scrollDirection { case .vertical: let height = flowLayout.itemSize.height + flowLayout.minimumLineSpacing index = Int((collectionView.contentOffset.y + height / 2) / height) default: let width = flowLayout.itemSize.width + flowLayout.minimumLineSpacing index = Int((collectionView.contentOffset.x + width / 2) / width) } return min(maximumIndex, max(minimumIndex, index)) } private func changeIndex(_ index: Int) -> Int { guard isInfiniteLoop && numberOfItems > 1 else { return index } var idx = index if index == 0 { idx = numberOfItems - 6 } else if index == 1 { idx = numberOfItems - 5 } else if index == numberOfItems - 2 { idx = 0 } else if index == numberOfItems - 1 { idx = 1 } else { idx = index - 2 } return idx } } extension ZKCycleScrollView: UICollectionViewDelegate { public func collectionView(_ collectionView: UICollectionView, shouldHighlightItemAt indexPath: IndexPath) -> Bool { return true } public func collectionView(_ collectionView: UICollectionView, didHighlightItemAt indexPath: IndexPath) { if let delegate = delegate, delegate.responds(to: #selector(ZKCycleScrollViewDelegate.cycleScrollView(_:didSelectItemAt:))) { removeTimer() } } public func collectionView(_ collectionView: UICollectionView, didUnhighlightItemAt indexPath: IndexPath) { if let delegate = delegate, delegate.responds(to: #selector(ZKCycleScrollViewDelegate.cycleScrollView(_:didSelectItemAt:))) { addTimer() } } public func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { if let delegate = delegate, delegate.responds(to: #selector(ZKCycleScrollViewDelegate.cycleScrollView(_:didSelectItemAt:))) { delegate.cycleScrollView!(self, didSelectItemAt: changeIndex(indexPath.item)) } } public func scrollViewDidScroll(_ scrollView: UIScrollView) { pageControl.currentPage = pageIndex if let delegate = delegate, delegate.responds(to: #selector(ZKCycleScrollViewDelegate.cycleScrollViewDidScroll(_:progress:))) { var total: CGFloat = 0.0 var offset: CGFloat = 0.0 let num = numberOfAddedCells() switch scrollDirection { case .vertical: total = CGFloat(numberOfItems - 1 - num) * (flowLayout.itemSize.height + flowLayout.minimumLineSpacing) offset = contentOffset.y.truncatingRemainder(dividingBy:((flowLayout.itemSize.height + flowLayout.minimumLineSpacing) * CGFloat(numberOfItems - num))) default: total = CGFloat(numberOfItems - 1 - num) * (flowLayout.itemSize.width + flowLayout.minimumLineSpacing) offset = contentOffset.x.truncatingRemainder(dividingBy:((flowLayout.itemSize.width + flowLayout.minimumLineSpacing) * CGFloat(numberOfItems - num))) } let percent = Double(offset / total) let progress = percent * Double(numberOfItems - 1 - num) delegate.cycleScrollViewDidScroll!(self, progress: progress) } } public func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { removeTimer() } public func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) { if let _ = stepLength { DispatchQueue.main.asyncAfter(deadline: .now() + 1) { self.addTimer() } } else { addTimer() } } public func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) { let index = currentIndex() if isInfiniteLoop { let position = scrollPosition() if index == 1 { let indexPath = IndexPath(item: numberOfItems - 3, section: 0) collectionView.scrollToItem(at: indexPath, at: position, animated: false) } else if index == numberOfItems - 2 { let indexPath = IndexPath(item: 2, section: 0) collectionView.scrollToItem(at: indexPath, at: position, animated: false) } } let toIndex = changeIndex(index) if let delegate = delegate, delegate.responds(to: #selector(ZKCycleScrollViewDelegate.cycleScrollView(_:didScrollFromIndex:toIndex:))) { delegate.cycleScrollView!(self, didScrollFromIndex: fromIndex, toIndex: toIndex) } fromIndex = toIndex } public func scrollViewWillEndDragging(_ scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer) { guard pageIndex == fromIndex else { return } let sum = velocity.x + velocity.y if sum > 0 { indexOffset = 1 } else if sum < 0 { indexOffset = -1 } else { indexOffset = 0 } } public func scrollViewWillBeginDecelerating(_ scrollView: UIScrollView) { let position = scrollPosition() var index = currentIndex() + indexOffset index = max(0, index) index = min(numberOfItems - 1, index) let indexPath = IndexPath(item: index, section: 0) collectionView.scrollToItem(at: indexPath, at: position, animated: true) indexOffset = 0 } } extension ZKCycleScrollView: UICollectionViewDataSource { public func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { numberOfItems = dataSource?.numberOfItems(in: self) ?? 0 if isInfiniteLoop && numberOfItems > 1 { numberOfItems += numberOfAddedCells() } return numberOfItems } public func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { let index = changeIndex(indexPath.item) return (dataSource?.cycleScrollView(self, cellForItemAt: index))! } }