Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Save cjnevin/c96f3f6b32087fbee7a1fbe8cbed5f85 to your computer and use it in GitHub Desktop.
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
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