LNCyclePager.swift 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537
  1. import UIKit
  2. public struct LNIndexSection: Equatable {
  3. public var index: Int
  4. public var section: Int
  5. public init(index: Int, section: Int) {
  6. self.index = index
  7. self.section = section
  8. }
  9. }
  10. public enum LNPagerScrollDirection {
  11. case left
  12. case right
  13. }
  14. public final class LNCyclePager: UIView {
  15. private let maxSectionCount = 200
  16. private let minSectionCount = 18
  17. private let transformLayout = LNCyclePagerTransformLayout()
  18. private(set) lazy var collectionView: UICollectionView = {
  19. let view = UICollectionView(frame: .zero, collectionViewLayout: transformLayout)
  20. view.backgroundColor = .clear
  21. view.showsHorizontalScrollIndicator = false
  22. view.showsVerticalScrollIndicator = false
  23. view.dataSource = self
  24. view.delegate = self
  25. view.decelerationRate = UIScrollView.DecelerationRate(rawValue: 1 - 0.0076)
  26. if #available(iOS 10.0, *) {
  27. view.isPrefetchingEnabled = false
  28. }
  29. return view
  30. }()
  31. public var numberOfItemsProvider: (() -> Int)?
  32. public var cellProvider: ((_ pagerView: LNCyclePager, _ index: Int) -> UICollectionViewCell)?
  33. public var layoutProvider: (() -> LNCyclePagerViewLayout)?
  34. public var didScrollFromIndexToIndex: ((_ from: Int, _ to: Int) -> Void)?
  35. public var didSelectItem: ((_ cell: UICollectionViewCell, _ index: Int) -> Void)?
  36. public var didSelectItemAtIndexSection: ((_ cell: UICollectionViewCell, _ indexSection: LNIndexSection) -> Void)?
  37. public var didScroll: (() -> Void)?
  38. public var initializeTransformAttributes: ((UICollectionViewLayoutAttributes) -> Void)? {
  39. didSet { updateTransformLayoutDelegateBinding() }
  40. }
  41. public var applyTransformAttributes: ((UICollectionViewLayoutAttributes) -> Void)? {
  42. didSet { updateTransformLayoutDelegateBinding() }
  43. }
  44. public var backgroundView: UIView? {
  45. get { collectionView.backgroundView }
  46. set { collectionView.backgroundView = newValue }
  47. }
  48. public var layout: LNCyclePagerViewLayout? {
  49. didSet {
  50. layout?.isInfiniteLoop = isInfiniteLoop
  51. updateTransformLayoutDelegateBinding()
  52. }
  53. }
  54. public var isInfiniteLoop = true {
  55. didSet {
  56. layout?.isInfiniteLoop = isInfiniteLoop
  57. reloadData()
  58. }
  59. }
  60. public var autoScrollInterval: TimeInterval = 0 {
  61. didSet {
  62. stopTimer()
  63. if autoScrollInterval > 0, superview != nil {
  64. startTimerIfNeeded()
  65. }
  66. }
  67. }
  68. public var reloadDataNeedResetIndex = false
  69. public var currentIndex: Int { indexSection.index }
  70. public private(set) var indexSection = LNIndexSection(index: -1, section: -1)
  71. public var contentOffset: CGPoint { collectionView.contentOffset }
  72. public var tracking: Bool { collectionView.isTracking }
  73. public var dragging: Bool { collectionView.isDragging }
  74. public var decelerating: Bool { collectionView.isDecelerating }
  75. private var timer: Timer?
  76. private var numberOfItems = 0
  77. private var dequeueSection = 0
  78. private var beginDragIndexSection = LNIndexSection(index: 0, section: 0)
  79. private var firstScrollIndex = -1
  80. private var needClearLayout = false
  81. private var didReloadData = false
  82. private var didLayout = false
  83. private var needResetIndex = false
  84. private weak var boundPageControl: LNCyclePageControl?
  85. public override init(frame: CGRect) {
  86. super.init(frame: frame)
  87. addSubview(collectionView)
  88. updateTransformLayoutDelegateBinding()
  89. }
  90. public required init?(coder: NSCoder) {
  91. super.init(coder: coder)
  92. addSubview(collectionView)
  93. updateTransformLayoutDelegateBinding()
  94. }
  95. deinit {
  96. stopTimer()
  97. }
  98. public override func didMoveToSuperview() {
  99. super.didMoveToSuperview()
  100. stopTimer()
  101. if superview != nil, autoScrollInterval > 0 {
  102. startTimerIfNeeded()
  103. }
  104. }
  105. public override func layoutSubviews() {
  106. super.layoutSubviews()
  107. let needUpdateLayout = collectionView.frame != bounds
  108. collectionView.frame = bounds
  109. if (indexSection.section < 0 || needUpdateLayout), (numberOfItems > 0 || didReloadData) {
  110. didLayout = true
  111. setNeedUpdateLayout()
  112. }
  113. }
  114. public func bindPageControl(_ pageControl: LNCyclePageControl?) {
  115. boundPageControl = pageControl
  116. guard let pageControl else { return }
  117. pageControl.numberOfPages = numberOfItems
  118. pageControl.currentPage = max(indexSection.index, 0)
  119. }
  120. public func register(_ cellClass: AnyClass, forCellWithReuseIdentifier identifier: String) {
  121. collectionView.register(cellClass, forCellWithReuseIdentifier: identifier)
  122. }
  123. public func register(_ nib: UINib, forCellWithReuseIdentifier identifier: String) {
  124. collectionView.register(nib, forCellWithReuseIdentifier: identifier)
  125. }
  126. public func dequeueReusableCell(withReuseIdentifier identifier: String, for index: Int) -> UICollectionViewCell {
  127. collectionView.dequeueReusableCell(withReuseIdentifier: identifier, for: IndexPath(item: index, section: dequeueSection))
  128. }
  129. public func reloadData() {
  130. didReloadData = true
  131. needResetIndex = true
  132. setNeedClearLayout()
  133. clearLayoutIfNeeded()
  134. updateData()
  135. }
  136. public func updateData() {
  137. updateLayout()
  138. numberOfItems = max(0, numberOfItemsProvider?() ?? 0)
  139. collectionView.reloadData()
  140. if !didLayout, !collectionView.frame.isEmpty, indexSection.index < 0 {
  141. didLayout = true
  142. }
  143. let shouldReset = needResetIndex && reloadDataNeedResetIndex
  144. needResetIndex = false
  145. if shouldReset {
  146. stopTimer()
  147. }
  148. let fallback = (indexSection.index < 0 && !collectionView.frame.isEmpty) || shouldReset ? 0 : indexSection.index
  149. resetPagerView(at: fallback)
  150. boundPageControl?.numberOfPages = numberOfItems
  151. boundPageControl?.setCurrentPage(max(indexSection.index, 0), animate: false)
  152. if shouldReset {
  153. startTimerIfNeeded()
  154. }
  155. }
  156. public func setNeedUpdateLayout() {
  157. guard effectiveLayout() != nil else { return }
  158. clearLayoutIfNeeded()
  159. updateLayout()
  160. collectionView.collectionViewLayout.invalidateLayout()
  161. resetPagerView(at: indexSection.index < 0 ? 0 : indexSection.index)
  162. }
  163. public func setNeedClearLayout() {
  164. needClearLayout = true
  165. }
  166. public func currentIndexCell() -> UICollectionViewCell? {
  167. guard indexSection.index >= 0, indexSection.section >= 0 else { return nil }
  168. return collectionView.cellForItem(at: IndexPath(item: indexSection.index, section: indexSection.section))
  169. }
  170. public func visibleCells() -> [UICollectionViewCell] {
  171. collectionView.visibleCells
  172. }
  173. public func visibleIndexes() -> [Int] {
  174. collectionView.indexPathsForVisibleItems.map(\.item)
  175. }
  176. public func scrollToItem(at index: Int, animated: Bool) {
  177. if !didLayout, didReloadData {
  178. firstScrollIndex = index
  179. } else {
  180. firstScrollIndex = -1
  181. }
  182. if !isInfiniteLoop {
  183. scrollToItem(at: LNIndexSection(index: index, section: 0), animated: animated)
  184. return
  185. }
  186. let targetSection = index >= currentIndex ? indexSection.section : indexSection.section + 1
  187. scrollToItem(at: LNIndexSection(index: index, section: targetSection), animated: animated)
  188. }
  189. public func scrollToItem(at target: LNIndexSection, animated: Bool) {
  190. guard numberOfItems > 0, isValid(target) else { return }
  191. let offset = calculateOffset(at: target)
  192. if resolvedScrollDirection() == .horizontal {
  193. collectionView.setContentOffset(CGPoint(x: offset, y: collectionView.contentOffset.y), animated: animated)
  194. } else {
  195. collectionView.setContentOffset(CGPoint(x: collectionView.contentOffset.x, y: offset), animated: animated)
  196. }
  197. }
  198. public func scrollToNearlyIndex(at direction: LNPagerScrollDirection, animated: Bool) {
  199. let target = nearlyIndexSection(from: indexSection, direction: direction)
  200. scrollToItem(at: target, animated: animated)
  201. }
  202. private func startTimerIfNeeded() {
  203. guard timer == nil, autoScrollInterval > 0 else { return }
  204. let timer = Timer(timeInterval: autoScrollInterval, repeats: true) { [weak self] _ in
  205. self?.timerFired()
  206. }
  207. RunLoop.main.add(timer, forMode: .common)
  208. self.timer = timer
  209. }
  210. private func stopTimer() {
  211. timer?.invalidate()
  212. timer = nil
  213. }
  214. private func timerFired() {
  215. guard superview != nil, window != nil, numberOfItems > 0, !tracking else { return }
  216. scrollToNearlyIndex(at: .right, animated: true)
  217. }
  218. private func updateTransformLayoutDelegateBinding() {
  219. transformLayout.transformDelegate = (applyTransformAttributes != nil || initializeTransformAttributes != nil) ? self : nil
  220. }
  221. private func effectiveLayout() -> LNCyclePagerViewLayout? {
  222. if layout == nil, let provider = layoutProvider {
  223. layout = provider()
  224. layout?.isInfiniteLoop = isInfiniteLoop
  225. }
  226. if let layout, layout.itemSize.width > 0, layout.itemSize.height > 0 {
  227. return layout
  228. }
  229. layout = nil
  230. return nil
  231. }
  232. private func updateLayout() {
  233. guard let layout = effectiveLayout() else { return }
  234. layout.isInfiniteLoop = isInfiniteLoop
  235. transformLayout.layoutConfig = layout
  236. }
  237. private func clearLayoutIfNeeded() {
  238. if needClearLayout {
  239. layout = nil
  240. transformLayout.layoutConfig = nil
  241. needClearLayout = false
  242. }
  243. }
  244. private func isValid(_ target: LNIndexSection) -> Bool {
  245. target.index >= 0 &&
  246. target.index < numberOfItems &&
  247. target.section >= 0 &&
  248. target.section < maxSectionCount
  249. }
  250. private func nearlyIndexSection(from current: LNIndexSection, direction: LNPagerScrollDirection) -> LNIndexSection {
  251. guard current.index >= 0, current.index < numberOfItems else { return current }
  252. if !isInfiniteLoop {
  253. if direction == .right, current.index == numberOfItems - 1 {
  254. return autoScrollInterval > 0 ? LNIndexSection(index: 0, section: 0) : current
  255. } else if direction == .right {
  256. return LNIndexSection(index: current.index + 1, section: 0)
  257. }
  258. if current.index == 0 {
  259. return autoScrollInterval > 0 ? LNIndexSection(index: numberOfItems - 1, section: 0) : current
  260. }
  261. return LNIndexSection(index: current.index - 1, section: 0)
  262. }
  263. if direction == .right {
  264. if current.index < numberOfItems - 1 {
  265. return LNIndexSection(index: current.index + 1, section: current.section)
  266. }
  267. if current.section >= maxSectionCount - 1 {
  268. return LNIndexSection(index: current.index, section: maxSectionCount - 1)
  269. }
  270. return LNIndexSection(index: 0, section: current.section + 1)
  271. }
  272. if current.index > 0 {
  273. return LNIndexSection(index: current.index - 1, section: current.section)
  274. }
  275. if current.section <= 0 {
  276. return LNIndexSection(index: current.index, section: 0)
  277. }
  278. return LNIndexSection(index: numberOfItems - 1, section: current.section - 1)
  279. }
  280. private func calculateIndexSection(with offset: CGPoint) -> LNIndexSection {
  281. guard numberOfItems > 0 else { return LNIndexSection(index: 0, section: 0) }
  282. guard let layout = effectiveLayout() else { return LNIndexSection(index: 0, section: 0) }
  283. let itemLength = (resolvedScrollDirection() == .horizontal ? transformLayout.itemSize.width : transformLayout.itemSize.height) + transformLayout.minimumInteritemSpacing
  284. guard itemLength > 0 else { return LNIndexSection(index: 0, section: 0) }
  285. let edge = isInfiniteLoop
  286. ? (resolvedScrollDirection() == .horizontal ? layout.sectionInset.left : layout.sectionInset.top)
  287. : (resolvedScrollDirection() == .horizontal ? layout.onlyOneSectionInset.left : layout.onlyOneSectionInset.top)
  288. let viewportLength = resolvedScrollDirection() == .horizontal ? collectionView.bounds.width : collectionView.bounds.height
  289. let middleOffset = (resolvedScrollDirection() == .horizontal ? offset.x : offset.y) + viewportLength * 0.5
  290. guard middleOffset - edge >= 0 else { return LNIndexSection(index: 0, section: 0) }
  291. let raw = Int((middleOffset - edge + transformLayout.minimumInteritemSpacing * 0.5) / itemLength)
  292. let maxRaw = max(numberOfItems * maxSectionCount - 1, 0)
  293. let itemIndex = max(0, min(raw, maxRaw))
  294. return LNIndexSection(index: itemIndex % numberOfItems, section: itemIndex / numberOfItems)
  295. }
  296. private func calculateOffset(at target: LNIndexSection) -> CGFloat {
  297. guard numberOfItems > 0, let layout = effectiveLayout() else { return 0 }
  298. let edge = isInfiniteLoop ? layout.sectionInset : layout.onlyOneSectionInset
  299. let itemLength = (resolvedScrollDirection() == .horizontal ? transformLayout.itemSize.width : transformLayout.itemSize.height) + transformLayout.minimumInteritemSpacing
  300. let viewportLength = resolvedScrollDirection() == .horizontal ? collectionView.bounds.width : collectionView.bounds.height
  301. let leftOrTop = resolvedScrollDirection() == .horizontal ? edge.left : edge.top
  302. let rightOrBottom = resolvedScrollDirection() == .horizontal ? edge.right : edge.bottom
  303. let logical = CGFloat(target.index + target.section * numberOfItems)
  304. let normal = leftOrTop + itemLength * logical - transformLayout.minimumInteritemSpacing * 0.5 - (viewportLength - itemLength) * 0.5
  305. if !isInfiniteLoop, target.index == numberOfItems - 1 {
  306. if resolvedScrollDirection() == .horizontal, !layout.itemHorizontalCenter {
  307. let alignedEnd = leftOrTop + itemLength * logical - (viewportLength - itemLength) - transformLayout.minimumInteritemSpacing + rightOrBottom
  308. return max(alignedEnd, 0)
  309. }
  310. if resolvedScrollDirection() == .vertical {
  311. let alignedEnd = leftOrTop + itemLength * logical - (viewportLength - itemLength) - transformLayout.minimumInteritemSpacing + rightOrBottom
  312. return max(alignedEnd, 0)
  313. }
  314. }
  315. return max(normal, 0)
  316. }
  317. private func resetPagerView(at index: Int) {
  318. var targetIndex = index
  319. if didLayout, firstScrollIndex >= 0 {
  320. targetIndex = firstScrollIndex
  321. firstScrollIndex = -1
  322. }
  323. if targetIndex < 0 { return }
  324. if targetIndex >= numberOfItems { targetIndex = 0 }
  325. let section = isInfiniteLoop ? (maxSectionCount / 3) : 0
  326. scrollToItem(at: LNIndexSection(index: targetIndex, section: section), animated: false)
  327. if !isInfiniteLoop, indexSection.index < 0 {
  328. scrollViewDidScroll(collectionView)
  329. }
  330. }
  331. private func recyclePagerViewIfNeeded() {
  332. guard isInfiniteLoop else { return }
  333. if indexSection.section > maxSectionCount - minSectionCount || indexSection.section < minSectionCount {
  334. resetPagerView(at: indexSection.index)
  335. }
  336. }
  337. private func resolvedScrollDirection() -> LNCyclePagerScrollDirection {
  338. effectiveLayout()?.scrollDirection ?? .horizontal
  339. }
  340. }
  341. extension LNCyclePager: UICollectionViewDataSource {
  342. public func numberOfSections(in collectionView: UICollectionView) -> Int {
  343. isInfiniteLoop ? maxSectionCount : 1
  344. }
  345. public func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
  346. numberOfItems = max(0, numberOfItemsProvider?() ?? 0)
  347. return numberOfItems
  348. }
  349. public func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
  350. dequeueSection = indexPath.section
  351. guard let cellProvider else {
  352. assertionFailure("LNCyclePager cellProvider is nil")
  353. return UICollectionViewCell()
  354. }
  355. return cellProvider(self, indexPath.item)
  356. }
  357. }
  358. extension LNCyclePager: UICollectionViewDelegateFlowLayout {
  359. public func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, insetForSectionAt section: Int) -> UIEdgeInsets {
  360. guard let layout = effectiveLayout() else { return .zero }
  361. if !isInfiniteLoop {
  362. return layout.onlyOneSectionInset
  363. }
  364. if section == 0 {
  365. return layout.firstSectionInset
  366. }
  367. if section == maxSectionCount - 1 {
  368. return layout.lastSectionInset
  369. }
  370. return layout.middleSectionInset
  371. }
  372. public func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
  373. guard let cell = collectionView.cellForItem(at: indexPath) else { return }
  374. didSelectItem?(cell, indexPath.item)
  375. didSelectItemAtIndexSection?(cell, LNIndexSection(index: indexPath.item, section: indexPath.section))
  376. }
  377. }
  378. extension LNCyclePager: UIScrollViewDelegate {
  379. public func scrollViewDidScroll(_ scrollView: UIScrollView) {
  380. guard didLayout else { return }
  381. let newIndexSection = calculateIndexSection(with: scrollView.contentOffset)
  382. guard numberOfItems > 0, isValid(newIndexSection) else { return }
  383. let old = indexSection
  384. indexSection = newIndexSection
  385. didScroll?()
  386. if old != indexSection {
  387. didScrollFromIndexToIndex?(max(old.index, 0), indexSection.index)
  388. boundPageControl?.setCurrentPage(indexSection.index, animate: true)
  389. }
  390. }
  391. public func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
  392. if autoScrollInterval > 0 {
  393. stopTimer()
  394. }
  395. beginDragIndexSection = calculateIndexSection(with: scrollView.contentOffset)
  396. }
  397. public func scrollViewWillEndDragging(_ scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer<CGPoint>) {
  398. let horizontal = resolvedScrollDirection() == .horizontal
  399. let velocityValue = horizontal ? abs(velocity.x) : abs(velocity.y)
  400. if velocityValue < 0.35 || beginDragIndexSection != indexSection {
  401. let target = calculateOffset(at: indexSection)
  402. if horizontal {
  403. targetContentOffset.pointee.x = target
  404. } else {
  405. targetContentOffset.pointee.y = target
  406. }
  407. return
  408. }
  409. let direction: LNPagerScrollDirection
  410. if horizontal {
  411. let isLeft = (scrollView.contentOffset.x < 0 && targetContentOffset.pointee.x <= 0) ||
  412. (targetContentOffset.pointee.x < scrollView.contentOffset.x &&
  413. scrollView.contentOffset.x < scrollView.contentSize.width - scrollView.frame.width)
  414. direction = isLeft ? .left : .right
  415. } else {
  416. let isUp = (scrollView.contentOffset.y < 0 && targetContentOffset.pointee.y <= 0) ||
  417. (targetContentOffset.pointee.y < scrollView.contentOffset.y &&
  418. scrollView.contentOffset.y < scrollView.contentSize.height - scrollView.frame.height)
  419. direction = isUp ? .left : .right
  420. }
  421. let targetIndexSection = nearlyIndexSection(from: indexSection, direction: direction)
  422. let target = calculateOffset(at: targetIndexSection)
  423. if horizontal {
  424. targetContentOffset.pointee.x = target
  425. } else {
  426. targetContentOffset.pointee.y = target
  427. }
  428. }
  429. public func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
  430. if autoScrollInterval > 0 {
  431. startTimerIfNeeded()
  432. }
  433. }
  434. public func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
  435. recyclePagerViewIfNeeded()
  436. }
  437. public func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) {
  438. recyclePagerViewIfNeeded()
  439. }
  440. }
  441. extension LNCyclePager: LNCyclePagerTransformLayoutDelegate {
  442. public func pagerTransformLayout(_ layout: LNCyclePagerTransformLayout, initialize attributes: UICollectionViewLayoutAttributes) {
  443. initializeTransformAttributes?(attributes)
  444. }
  445. public func pagerTransformLayout(_ layout: LNCyclePagerTransformLayout, apply attributes: UICollectionViewLayoutAttributes) {
  446. applyTransformAttributes?(attributes)
  447. }
  448. }