Last active
May 13, 2023 16:39
-
-
Save cjnevin/c96f3f6b32087fbee7a1fbe8cbed5f85 to your computer and use it in GitHub Desktop.
Infinite horizontal collection view that clips items when scroll view comes to a rest
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import UIKit | |
class InfiniteCell: UICollectionViewCell, UICollectionViewDataSource, UICollectionViewDelegate { | |
private var total: Int { additionalItemCount + items.count } | |
private var additionalItemCount: Int = 0 | |
private let threshold: Int = 3 // will need to be based on orientation and screen size to perform correctly | |
private var items: [UIColor] = [] | |
private let itemSize: CGSize = .init(width: 150, height: 150) | |
private var itemsWidth: CGFloat { CGFloat(items.count) * itemSize.width } | |
private lazy var flowLayout = UICollectionViewFlowLayout() | |
private lazy var collectionView = UICollectionView(frame: .zero, collectionViewLayout: flowLayout) | |
override func willMove(toSuperview newSuperview: UIView?) { | |
super.willMove(toSuperview: newSuperview) | |
if newSuperview != nil { | |
collectionView.translatesAutoresizingMaskIntoConstraints = false | |
contentView.addSubview(collectionView) | |
NSLayoutConstraint.activate([ | |
collectionView.topAnchor.constraint(equalTo: contentView.topAnchor), | |
collectionView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), | |
collectionView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), | |
collectionView.heightAnchor.constraint(greaterThanOrEqualToConstant: itemSize.height), | |
collectionView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), | |
]) | |
collectionView.register(Cell.self, forCellWithReuseIdentifier: "Cell") | |
collectionView.dataSource = self | |
collectionView.delegate = self | |
collectionView.showsHorizontalScrollIndicator = false | |
flowLayout.scrollDirection = .horizontal | |
flowLayout.itemSize = itemSize | |
flowLayout.minimumInteritemSpacing = 0 | |
flowLayout.minimumLineSpacing = 0 | |
} | |
} | |
func configure(with newItems: [UIColor]) { | |
items = newItems | |
collectionView.reloadData() | |
} | |
func numberOfSections(in collectionView: UICollectionView) -> Int { | |
1 | |
} | |
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { | |
total | |
} | |
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { | |
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "Cell", for: indexPath) as! Cell | |
cell.configure( | |
with: items[indexPath.row % items.count], | |
text: "\(indexPath.row)" | |
) | |
return cell | |
} | |
func collectionView(_ collectionView: UICollectionView, willDisplay cell: UICollectionViewCell, forItemAt indexPath: IndexPath) { | |
if indexPath.row == total - threshold { | |
DispatchQueue.main.async { | |
self.additionalItemCount += self.items.count | |
self.collectionView.reloadData() | |
} | |
} | |
} | |
func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) { | |
removeAdditionalItems() | |
} | |
func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) { | |
if !decelerate { | |
removeAdditionalItems() | |
} | |
} | |
private func removeAdditionalItems() { | |
guard additionalItemCount > 0 else { return } | |
let triggerEnd = total - items.count | |
let triggerStart = triggerEnd - threshold | |
let range = triggerStart..<triggerEnd | |
let visibleIndices = collectionView.indexPathsForVisibleItems.map(\.row) | |
let triggerOnScreen = visibleIndices.contains(where: range.contains) | |
if triggerOnScreen { | |
guard total > items.count * 2 else { return } | |
resetAdditionalItems(to: items.count) | |
} else { | |
resetAdditionalItems(to: 0) | |
} | |
} | |
private func resetAdditionalItems(to count: Int) { | |
var contentOffset = collectionView.contentOffset | |
contentOffset.x = contentOffset.x.truncatingRemainder(dividingBy: itemsWidth) | |
DispatchQueue.main.async { | |
self.additionalItemCount = count | |
self.collectionView.contentOffset = contentOffset | |
self.collectionView.reloadData() | |
} | |
} | |
} | |
class Header: UICollectionReusableView { | |
lazy var textLabel = UILabel() | |
override func willMove(toSuperview newSuperview: UIView?) { | |
super.willMove(toSuperview: newSuperview) | |
textLabel.translatesAutoresizingMaskIntoConstraints = false | |
addSubview(textLabel) | |
NSLayoutConstraint.activate([ | |
textLabel.leadingAnchor.constraint(equalTo: leadingAnchor), | |
textLabel.trailingAnchor.constraint(equalTo: trailingAnchor), | |
textLabel.topAnchor.constraint(equalTo: topAnchor), | |
textLabel.bottomAnchor.constraint(equalTo: bottomAnchor), | |
]) | |
} | |
} | |
class ViewController: UIViewController { | |
private var colors: [UIColor] = [ | |
.red, | |
.blue, | |
.green, | |
.orange, | |
// .cyan, | |
// .magenta, | |
// .brown, | |
// .purple, | |
// .yellow, | |
// .black, | |
// .darkGray, | |
// .systemMint, | |
// .systemIndigo, | |
// .systemGray6 | |
] | |
private lazy var collectionView = UICollectionView(frame: .zero, collectionViewLayout: UICollectionViewFlowLayout()) | |
struct Section: Hashable, Identifiable { | |
let id: UUID = UUID() | |
let name: String | |
} | |
struct Item: Hashable, Identifiable { | |
let id: UUID = UUID() | |
let colors: [UIColor] | |
} | |
lazy var dataSource = UICollectionViewDiffableDataSource<Section, Item>( | |
collectionView: collectionView, | |
cellProvider: cellRegistration.cellProvider | |
) | |
lazy var cellRegistration = UICollectionView.CellRegistration<InfiniteCell, Item> { cell, indexPath, item in | |
cell.configure(with: item.colors) | |
} | |
lazy var sectionRegistration = UICollectionView.SupplementaryRegistration<Header>(elementKind: UICollectionView.elementKindSectionHeader) { [self] supplementaryView, elementKind, indexPath in | |
supplementaryView.textLabel.text = self.dataSource.sectionIdentifier(for: indexPath.section)?.name | |
} | |
lazy var layoutConfig = UICollectionLayoutListConfiguration(appearance: .plain) | |
lazy var layout = UICollectionViewCompositionalLayout.list(using: layoutConfig) | |
override func viewDidLoad() { | |
super.viewDidLoad() | |
collectionView.translatesAutoresizingMaskIntoConstraints = false | |
view.addSubview(collectionView) | |
NSLayoutConstraint.activate([ | |
collectionView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), | |
collectionView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor), | |
collectionView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor), | |
collectionView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor), | |
]) | |
layoutConfig.showsSeparators = false | |
layoutConfig.headerMode = .supplementary | |
layoutConfig.footerMode = .none | |
collectionView.collectionViewLayout = layout | |
dataSource.supplementaryViewProvider = sectionRegistration.supplementaryProvider | |
} | |
override func viewWillAppear(_ animated: Bool) { | |
super.viewWillAppear(animated) | |
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>() | |
let a = Section(name: "AAA") | |
let b = Section(name: "BBB") | |
let c = Section(name: "CCC") | |
let d = Section(name: "DDD") | |
let e = Section(name: "EEE") | |
let f = Section(name: "FFF") | |
snapshot.appendSections([a, b, c, d, e, f]) | |
snapshot.appendItems([Item(colors: colors)], toSection: a) | |
snapshot.appendItems([Item(colors: colors)], toSection: b) | |
snapshot.appendItems([Item(colors: colors)], toSection: c) | |
snapshot.appendItems([Item(colors: colors)], toSection: d) | |
snapshot.appendItems([Item(colors: colors)], toSection: e) | |
snapshot.appendItems([Item(colors: colors)], toSection: f) | |
dataSource.apply(snapshot) | |
} | |
} | |
class Cell: UICollectionViewCell { | |
private lazy var label = UILabel() | |
override func willMove(toSuperview newSuperview: UIView?) { | |
super.willMove(toSuperview: newSuperview) | |
label.translatesAutoresizingMaskIntoConstraints = false | |
label.textColor = .white | |
label.textAlignment = .center | |
contentView.addSubview(label) | |
NSLayoutConstraint.activate([ | |
label.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), | |
label.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), | |
label.topAnchor.constraint(equalTo: contentView.topAnchor), | |
label.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), | |
]) | |
} | |
func configure(with color: UIColor, text: String) { | |
label.text = text | |
contentView.backgroundColor = color | |
backgroundColor = color | |
} | |
} | |
extension UICollectionView.CellRegistration { | |
var cellProvider: (UICollectionView, IndexPath, Item) -> Cell { | |
return { collectionView, indexPath, item in | |
collectionView.dequeueConfiguredReusableCell( | |
using: self, | |
for: indexPath, | |
item: item | |
) | |
} | |
} | |
} | |
extension UICollectionView.SupplementaryRegistration { | |
var supplementaryProvider: (UICollectionView, String, IndexPath) -> Supplementary { | |
return { collectionView, kind, indexPath in | |
return collectionView.dequeueConfiguredReusableSupplementary( | |
using: self, | |
for: indexPath | |
) | |
} | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment