| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537 |
- import UIKit
- public struct LNIndexSection: Equatable {
- public var index: Int
- public var section: Int
- public init(index: Int, section: Int) {
- self.index = index
- self.section = section
- }
- }
- public enum LNPagerScrollDirection {
- case left
- case right
- }
- public final class LNCyclePager: UIView {
- private let maxSectionCount = 200
- private let minSectionCount = 18
- private let transformLayout = LNCyclePagerTransformLayout()
- private(set) lazy var collectionView: UICollectionView = {
- let view = UICollectionView(frame: .zero, collectionViewLayout: transformLayout)
- view.backgroundColor = .clear
- view.showsHorizontalScrollIndicator = false
- view.showsVerticalScrollIndicator = false
- view.dataSource = self
- view.delegate = self
- view.decelerationRate = UIScrollView.DecelerationRate(rawValue: 1 - 0.0076)
- if #available(iOS 10.0, *) {
- view.isPrefetchingEnabled = false
- }
- return view
- }()
- public var numberOfItemsProvider: (() -> Int)?
- public var cellProvider: ((_ pagerView: LNCyclePager, _ index: Int) -> UICollectionViewCell)?
- public var layoutProvider: (() -> LNCyclePagerViewLayout)?
- public var didScrollFromIndexToIndex: ((_ from: Int, _ to: Int) -> Void)?
- public var didSelectItem: ((_ cell: UICollectionViewCell, _ index: Int) -> Void)?
- public var didSelectItemAtIndexSection: ((_ cell: UICollectionViewCell, _ indexSection: LNIndexSection) -> Void)?
- public var didScroll: (() -> Void)?
- public var initializeTransformAttributes: ((UICollectionViewLayoutAttributes) -> Void)? {
- didSet { updateTransformLayoutDelegateBinding() }
- }
- public var applyTransformAttributes: ((UICollectionViewLayoutAttributes) -> Void)? {
- didSet { updateTransformLayoutDelegateBinding() }
- }
- public var backgroundView: UIView? {
- get { collectionView.backgroundView }
- set { collectionView.backgroundView = newValue }
- }
- public var layout: LNCyclePagerViewLayout? {
- didSet {
- layout?.isInfiniteLoop = isInfiniteLoop
- updateTransformLayoutDelegateBinding()
- }
- }
- public var isInfiniteLoop = true {
- didSet {
- layout?.isInfiniteLoop = isInfiniteLoop
- reloadData()
- }
- }
- public var autoScrollInterval: TimeInterval = 0 {
- didSet {
- stopTimer()
- if autoScrollInterval > 0, superview != nil {
- startTimerIfNeeded()
- }
- }
- }
- public var reloadDataNeedResetIndex = false
- public var currentIndex: Int { indexSection.index }
- public private(set) var indexSection = LNIndexSection(index: -1, section: -1)
- public var contentOffset: CGPoint { collectionView.contentOffset }
- public var tracking: Bool { collectionView.isTracking }
- public var dragging: Bool { collectionView.isDragging }
- public var decelerating: Bool { collectionView.isDecelerating }
- private var timer: Timer?
- private var numberOfItems = 0
- private var dequeueSection = 0
- private var beginDragIndexSection = LNIndexSection(index: 0, section: 0)
- private var firstScrollIndex = -1
- private var needClearLayout = false
- private var didReloadData = false
- private var didLayout = false
- private var needResetIndex = false
- private weak var boundPageControl: LNCyclePageControl?
- public override init(frame: CGRect) {
- super.init(frame: frame)
- addSubview(collectionView)
- updateTransformLayoutDelegateBinding()
- }
- public required init?(coder: NSCoder) {
- super.init(coder: coder)
- addSubview(collectionView)
- updateTransformLayoutDelegateBinding()
- }
- deinit {
- stopTimer()
- }
- public override func didMoveToSuperview() {
- super.didMoveToSuperview()
- stopTimer()
- if superview != nil, autoScrollInterval > 0 {
- startTimerIfNeeded()
- }
- }
- public override func layoutSubviews() {
- super.layoutSubviews()
- let needUpdateLayout = collectionView.frame != bounds
- collectionView.frame = bounds
- if (indexSection.section < 0 || needUpdateLayout), (numberOfItems > 0 || didReloadData) {
- didLayout = true
- setNeedUpdateLayout()
- }
- }
- public func bindPageControl(_ pageControl: LNCyclePageControl?) {
- boundPageControl = pageControl
- guard let pageControl else { return }
- pageControl.numberOfPages = numberOfItems
- pageControl.currentPage = max(indexSection.index, 0)
- }
- public func register(_ cellClass: AnyClass, forCellWithReuseIdentifier identifier: String) {
- collectionView.register(cellClass, forCellWithReuseIdentifier: identifier)
- }
- public func register(_ nib: UINib, forCellWithReuseIdentifier identifier: String) {
- collectionView.register(nib, forCellWithReuseIdentifier: identifier)
- }
- public func dequeueReusableCell(withReuseIdentifier identifier: String, for index: Int) -> UICollectionViewCell {
- collectionView.dequeueReusableCell(withReuseIdentifier: identifier, for: IndexPath(item: index, section: dequeueSection))
- }
- public func reloadData() {
- didReloadData = true
- needResetIndex = true
- setNeedClearLayout()
- clearLayoutIfNeeded()
- updateData()
- }
- public func updateData() {
- updateLayout()
- numberOfItems = max(0, numberOfItemsProvider?() ?? 0)
- collectionView.reloadData()
- if !didLayout, !collectionView.frame.isEmpty, indexSection.index < 0 {
- didLayout = true
- }
- let shouldReset = needResetIndex && reloadDataNeedResetIndex
- needResetIndex = false
- if shouldReset {
- stopTimer()
- }
- let fallback = (indexSection.index < 0 && !collectionView.frame.isEmpty) || shouldReset ? 0 : indexSection.index
- resetPagerView(at: fallback)
- boundPageControl?.numberOfPages = numberOfItems
- boundPageControl?.setCurrentPage(max(indexSection.index, 0), animate: false)
- if shouldReset {
- startTimerIfNeeded()
- }
- }
- public func setNeedUpdateLayout() {
- guard effectiveLayout() != nil else { return }
- clearLayoutIfNeeded()
- updateLayout()
- collectionView.collectionViewLayout.invalidateLayout()
- resetPagerView(at: indexSection.index < 0 ? 0 : indexSection.index)
- }
- public func setNeedClearLayout() {
- needClearLayout = true
- }
- public func currentIndexCell() -> UICollectionViewCell? {
- guard indexSection.index >= 0, indexSection.section >= 0 else { return nil }
- return collectionView.cellForItem(at: IndexPath(item: indexSection.index, section: indexSection.section))
- }
- public func visibleCells() -> [UICollectionViewCell] {
- collectionView.visibleCells
- }
- public func visibleIndexes() -> [Int] {
- collectionView.indexPathsForVisibleItems.map(\.item)
- }
- public func scrollToItem(at index: Int, animated: Bool) {
- if !didLayout, didReloadData {
- firstScrollIndex = index
- } else {
- firstScrollIndex = -1
- }
- if !isInfiniteLoop {
- scrollToItem(at: LNIndexSection(index: index, section: 0), animated: animated)
- return
- }
- let targetSection = index >= currentIndex ? indexSection.section : indexSection.section + 1
- scrollToItem(at: LNIndexSection(index: index, section: targetSection), animated: animated)
- }
- public func scrollToItem(at target: LNIndexSection, animated: Bool) {
- guard numberOfItems > 0, isValid(target) else { return }
- let offset = calculateOffset(at: target)
- if resolvedScrollDirection() == .horizontal {
- collectionView.setContentOffset(CGPoint(x: offset, y: collectionView.contentOffset.y), animated: animated)
- } else {
- collectionView.setContentOffset(CGPoint(x: collectionView.contentOffset.x, y: offset), animated: animated)
- }
- }
- public func scrollToNearlyIndex(at direction: LNPagerScrollDirection, animated: Bool) {
- let target = nearlyIndexSection(from: indexSection, direction: direction)
- scrollToItem(at: target, animated: animated)
- }
- private func startTimerIfNeeded() {
- guard timer == nil, autoScrollInterval > 0 else { return }
- let timer = Timer(timeInterval: autoScrollInterval, repeats: true) { [weak self] _ in
- self?.timerFired()
- }
- RunLoop.main.add(timer, forMode: .common)
- self.timer = timer
- }
- private func stopTimer() {
- timer?.invalidate()
- timer = nil
- }
- private func timerFired() {
- guard superview != nil, window != nil, numberOfItems > 0, !tracking else { return }
- scrollToNearlyIndex(at: .right, animated: true)
- }
- private func updateTransformLayoutDelegateBinding() {
- transformLayout.transformDelegate = (applyTransformAttributes != nil || initializeTransformAttributes != nil) ? self : nil
- }
- private func effectiveLayout() -> LNCyclePagerViewLayout? {
- if layout == nil, let provider = layoutProvider {
- layout = provider()
- layout?.isInfiniteLoop = isInfiniteLoop
- }
- if let layout, layout.itemSize.width > 0, layout.itemSize.height > 0 {
- return layout
- }
- layout = nil
- return nil
- }
- private func updateLayout() {
- guard let layout = effectiveLayout() else { return }
- layout.isInfiniteLoop = isInfiniteLoop
- transformLayout.layoutConfig = layout
- }
- private func clearLayoutIfNeeded() {
- if needClearLayout {
- layout = nil
- transformLayout.layoutConfig = nil
- needClearLayout = false
- }
- }
- private func isValid(_ target: LNIndexSection) -> Bool {
- target.index >= 0 &&
- target.index < numberOfItems &&
- target.section >= 0 &&
- target.section < maxSectionCount
- }
- private func nearlyIndexSection(from current: LNIndexSection, direction: LNPagerScrollDirection) -> LNIndexSection {
- guard current.index >= 0, current.index < numberOfItems else { return current }
- if !isInfiniteLoop {
- if direction == .right, current.index == numberOfItems - 1 {
- return autoScrollInterval > 0 ? LNIndexSection(index: 0, section: 0) : current
- } else if direction == .right {
- return LNIndexSection(index: current.index + 1, section: 0)
- }
- if current.index == 0 {
- return autoScrollInterval > 0 ? LNIndexSection(index: numberOfItems - 1, section: 0) : current
- }
- return LNIndexSection(index: current.index - 1, section: 0)
- }
- if direction == .right {
- if current.index < numberOfItems - 1 {
- return LNIndexSection(index: current.index + 1, section: current.section)
- }
- if current.section >= maxSectionCount - 1 {
- return LNIndexSection(index: current.index, section: maxSectionCount - 1)
- }
- return LNIndexSection(index: 0, section: current.section + 1)
- }
- if current.index > 0 {
- return LNIndexSection(index: current.index - 1, section: current.section)
- }
- if current.section <= 0 {
- return LNIndexSection(index: current.index, section: 0)
- }
- return LNIndexSection(index: numberOfItems - 1, section: current.section - 1)
- }
- private func calculateIndexSection(with offset: CGPoint) -> LNIndexSection {
- guard numberOfItems > 0 else { return LNIndexSection(index: 0, section: 0) }
- guard let layout = effectiveLayout() else { return LNIndexSection(index: 0, section: 0) }
- let itemLength = (resolvedScrollDirection() == .horizontal ? transformLayout.itemSize.width : transformLayout.itemSize.height) + transformLayout.minimumInteritemSpacing
- guard itemLength > 0 else { return LNIndexSection(index: 0, section: 0) }
- let edge = isInfiniteLoop
- ? (resolvedScrollDirection() == .horizontal ? layout.sectionInset.left : layout.sectionInset.top)
- : (resolvedScrollDirection() == .horizontal ? layout.onlyOneSectionInset.left : layout.onlyOneSectionInset.top)
- let viewportLength = resolvedScrollDirection() == .horizontal ? collectionView.bounds.width : collectionView.bounds.height
- let middleOffset = (resolvedScrollDirection() == .horizontal ? offset.x : offset.y) + viewportLength * 0.5
- guard middleOffset - edge >= 0 else { return LNIndexSection(index: 0, section: 0) }
- let raw = Int((middleOffset - edge + transformLayout.minimumInteritemSpacing * 0.5) / itemLength)
- let maxRaw = max(numberOfItems * maxSectionCount - 1, 0)
- let itemIndex = max(0, min(raw, maxRaw))
- return LNIndexSection(index: itemIndex % numberOfItems, section: itemIndex / numberOfItems)
- }
- private func calculateOffset(at target: LNIndexSection) -> CGFloat {
- guard numberOfItems > 0, let layout = effectiveLayout() else { return 0 }
- let edge = isInfiniteLoop ? layout.sectionInset : layout.onlyOneSectionInset
- let itemLength = (resolvedScrollDirection() == .horizontal ? transformLayout.itemSize.width : transformLayout.itemSize.height) + transformLayout.minimumInteritemSpacing
- let viewportLength = resolvedScrollDirection() == .horizontal ? collectionView.bounds.width : collectionView.bounds.height
- let leftOrTop = resolvedScrollDirection() == .horizontal ? edge.left : edge.top
- let rightOrBottom = resolvedScrollDirection() == .horizontal ? edge.right : edge.bottom
- let logical = CGFloat(target.index + target.section * numberOfItems)
- let normal = leftOrTop + itemLength * logical - transformLayout.minimumInteritemSpacing * 0.5 - (viewportLength - itemLength) * 0.5
- if !isInfiniteLoop, target.index == numberOfItems - 1 {
- if resolvedScrollDirection() == .horizontal, !layout.itemHorizontalCenter {
- let alignedEnd = leftOrTop + itemLength * logical - (viewportLength - itemLength) - transformLayout.minimumInteritemSpacing + rightOrBottom
- return max(alignedEnd, 0)
- }
- if resolvedScrollDirection() == .vertical {
- let alignedEnd = leftOrTop + itemLength * logical - (viewportLength - itemLength) - transformLayout.minimumInteritemSpacing + rightOrBottom
- return max(alignedEnd, 0)
- }
- }
- return max(normal, 0)
- }
- private func resetPagerView(at index: Int) {
- var targetIndex = index
- if didLayout, firstScrollIndex >= 0 {
- targetIndex = firstScrollIndex
- firstScrollIndex = -1
- }
- if targetIndex < 0 { return }
- if targetIndex >= numberOfItems { targetIndex = 0 }
- let section = isInfiniteLoop ? (maxSectionCount / 3) : 0
- scrollToItem(at: LNIndexSection(index: targetIndex, section: section), animated: false)
- if !isInfiniteLoop, indexSection.index < 0 {
- scrollViewDidScroll(collectionView)
- }
- }
- private func recyclePagerViewIfNeeded() {
- guard isInfiniteLoop else { return }
- if indexSection.section > maxSectionCount - minSectionCount || indexSection.section < minSectionCount {
- resetPagerView(at: indexSection.index)
- }
- }
- private func resolvedScrollDirection() -> LNCyclePagerScrollDirection {
- effectiveLayout()?.scrollDirection ?? .horizontal
- }
- }
- extension LNCyclePager: UICollectionViewDataSource {
- public func numberOfSections(in collectionView: UICollectionView) -> Int {
- isInfiniteLoop ? maxSectionCount : 1
- }
- public func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
- numberOfItems = max(0, numberOfItemsProvider?() ?? 0)
- return numberOfItems
- }
- public func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
- dequeueSection = indexPath.section
- guard let cellProvider else {
- assertionFailure("LNCyclePager cellProvider is nil")
- return UICollectionViewCell()
- }
- return cellProvider(self, indexPath.item)
- }
- }
- extension LNCyclePager: UICollectionViewDelegateFlowLayout {
- public func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, insetForSectionAt section: Int) -> UIEdgeInsets {
- guard let layout = effectiveLayout() else { return .zero }
- if !isInfiniteLoop {
- return layout.onlyOneSectionInset
- }
- if section == 0 {
- return layout.firstSectionInset
- }
- if section == maxSectionCount - 1 {
- return layout.lastSectionInset
- }
- return layout.middleSectionInset
- }
- public func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
- guard let cell = collectionView.cellForItem(at: indexPath) else { return }
- didSelectItem?(cell, indexPath.item)
- didSelectItemAtIndexSection?(cell, LNIndexSection(index: indexPath.item, section: indexPath.section))
- }
- }
- extension LNCyclePager: UIScrollViewDelegate {
- public func scrollViewDidScroll(_ scrollView: UIScrollView) {
- guard didLayout else { return }
- let newIndexSection = calculateIndexSection(with: scrollView.contentOffset)
- guard numberOfItems > 0, isValid(newIndexSection) else { return }
- let old = indexSection
- indexSection = newIndexSection
- didScroll?()
- if old != indexSection {
- didScrollFromIndexToIndex?(max(old.index, 0), indexSection.index)
- boundPageControl?.setCurrentPage(indexSection.index, animate: true)
- }
- }
- public func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
- if autoScrollInterval > 0 {
- stopTimer()
- }
- beginDragIndexSection = calculateIndexSection(with: scrollView.contentOffset)
- }
- public func scrollViewWillEndDragging(_ scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer<CGPoint>) {
- let horizontal = resolvedScrollDirection() == .horizontal
- let velocityValue = horizontal ? abs(velocity.x) : abs(velocity.y)
- if velocityValue < 0.35 || beginDragIndexSection != indexSection {
- let target = calculateOffset(at: indexSection)
- if horizontal {
- targetContentOffset.pointee.x = target
- } else {
- targetContentOffset.pointee.y = target
- }
- return
- }
- let direction: LNPagerScrollDirection
- if horizontal {
- let isLeft = (scrollView.contentOffset.x < 0 && targetContentOffset.pointee.x <= 0) ||
- (targetContentOffset.pointee.x < scrollView.contentOffset.x &&
- scrollView.contentOffset.x < scrollView.contentSize.width - scrollView.frame.width)
- direction = isLeft ? .left : .right
- } else {
- let isUp = (scrollView.contentOffset.y < 0 && targetContentOffset.pointee.y <= 0) ||
- (targetContentOffset.pointee.y < scrollView.contentOffset.y &&
- scrollView.contentOffset.y < scrollView.contentSize.height - scrollView.frame.height)
- direction = isUp ? .left : .right
- }
- let targetIndexSection = nearlyIndexSection(from: indexSection, direction: direction)
- let target = calculateOffset(at: targetIndexSection)
- if horizontal {
- targetContentOffset.pointee.x = target
- } else {
- targetContentOffset.pointee.y = target
- }
- }
- public func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
- if autoScrollInterval > 0 {
- startTimerIfNeeded()
- }
- }
- public func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
- recyclePagerViewIfNeeded()
- }
- public func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) {
- recyclePagerViewIfNeeded()
- }
- }
- extension LNCyclePager: LNCyclePagerTransformLayoutDelegate {
- public func pagerTransformLayout(_ layout: LNCyclePagerTransformLayout, initialize attributes: UICollectionViewLayoutAttributes) {
- initializeTransformAttributes?(attributes)
- }
- public func pagerTransformLayout(_ layout: LNCyclePagerTransformLayout, apply attributes: UICollectionViewLayoutAttributes) {
- applyTransformAttributes?(attributes)
- }
- }
|