Skip to content

Instantly share code, notes, and snippets.

@jakehawken
Last active September 7, 2023 01:10
Show Gist options
  • Save jakehawken/654d8e9a3e9f31045748cacc7ff1216b to your computer and use it in GitHub Desktop.
Save jakehawken/654d8e9a3e9f31045748cacc7ff1216b to your computer and use it in GitHub Desktop.
Eschew delegation. Embrace composition.
// ComposableCollectionView.swift
// Created by Jake Hawken
import UIKit
/// Represents the data for a section of a table view or collection view.
///
/// If different sections have different element types, it's recommended to
/// create an enum with a case for each element type, and use that enum as
/// the Item type of the ComposableCollectionView.
struct SectionViewModel<Item, SectionTypeID:Hashable> {
/// Used to identify the type of section to the compositional layout
let sectionType: SectionTypeID
/// Optional title text for section header
let title: String?
/// Optional image for the section header
let image: UIImage?
/// The backing data items for the section.
///
/// If array this is empty, the section will still be shown. Plan your data accordingly.
let items: [Item]
}
/// A type for declaratively building collection views to allow for easier composition and encapsulation.
///
/// Allows you to
class ComposableCollectionView<Item, SectionTypeID:Hashable>: UIView {
// MARK: - properties
typealias Section = SectionViewModel<Item, SectionTypeID>
private var collectionView = UICollectionView(frame: .zero, collectionViewLayout: .init()) // This is immediately overridden in the init
private var sections = [Section]()
private lazy var delegate = CollectionViewDelegate()
private lazy var dataSouce = CollectionViewDataSouce()
.numberOfSections { [weak self] _ in
self?.sections.count ?? 0
}
.numberOfItemsInSection { [weak self] _, sectionIndex in
self?.sections[sectionIndex].items.count ?? 0
}
.cellForItemAtIndexPath { _, _ in
fatalError("cellForItemAtIndexPath not implemented.")
}
.viewForSupplementaryElementOfKindAtIndexPath { _, _, _ in
fatalError("viewForSupplementaryElementOfKindAtIndexPath not implemented.")
}
private lazy var lastContentHeight = contentHeight
private var contentHeightDidChange: ((CGFloat) -> Void)?
// MARK: initialization
/// - parameter headerViewTypes: The list of header view types to register. Must conform to `ReuseIdentifiableReusableView`
/// - parameter cellTypes: The list of cell types to register. Must conform to `ReuseIdentifiableCollectionViewCell`
/// - parameter layoutForSection: Callback used determining the layout for a given SectionTypeID, section index, and
/// `NSCollectionLayoutEnvironment`.
required init(
headerViewTypes: [ReuseIdentifiableReusableView.Type] = [],
cellTypes: [ReuseIdentifiableCollectionViewCell.Type],
layoutForSection: @escaping (SectionTypeID, Int, NSCollectionLayoutEnvironment) -> NSCollectionLayoutSection?
) {
super.init(frame: .zero)
self.collectionView = UICollectionView(
frame: .zero,
collectionViewLayout: UICollectionViewCompositionalLayout { [weak self] sectionIndex, environment in
guard let self else { return nil }
let sectionID = self.sections[sectionIndex].sectionType
return layoutForSection(sectionID, sectionIndex, environment)
}
)
cellTypes.forEach {
self.collectionView.registerCellType($0)
}
headerViewTypes.forEach {
self.collectionView.registerReusableViewType($0, ofKind: UICollectionView.elementKindSectionHeader)
}
collectionView.dataSource = dataSouce
collectionView.delegate = delegate
collectionView.backgroundColor = .clear
collectionView.isPagingEnabled = false
collectionView.decelerationRate = .normal
collectionView.translatesAutoresizingMaskIntoConstraints = false
addSubview(collectionView)
NSLayoutConstraint.activate([
collectionView.topAnchor.constraint(equalTo: topAnchor),
collectionView.leadingAnchor.constraint(equalTo: leadingAnchor),
collectionView.trailingAnchor.constraint(equalTo: trailingAnchor),
collectionView.bottomAnchor.constraint(equalTo: bottomAnchor),
])
}
/// Convenience init that automatically generates the layoutForSection. This requires that the cell types
/// conform to `ComposableCollectionViewCell`.
/// - parameter cellTypeMapping: A mapping of `SectionTypeID` to `ComposableCollectionViewCell`-conforming
/// cell types. This allows the layoutForSection to be synthesized by returning the cell's `sectionLayout`
/// property, based on a given section's section type. This does mean, however, that each section that
/// uses cells of a certain type will have the same section layout.
/// - parameter headerViewTypes: The list of header view types to register. Must conform to
/// `ReuseIdentifiableReusableView`.
convenience init(
cellTypeMapping: [SectionTypeID: ComposableCollectionViewCell.Type],
headerViewTypes: [ReuseIdentifiableReusableView.Type] = []
) {
self.init(
headerViewTypes: headerViewTypes,
cellTypes: cellTypeMapping.values.map { $0 }
) { sectionType, _, _ -> NSCollectionLayoutSection? in
return cellTypeMapping[sectionType]?.sectionLayout
}
}
override init(frame: CGRect) {
Self.failInit()
}
required init?(coder: NSCoder) {
print("Do not use ComposableCollectionView from interface builder.")
Self.failInit()
}
override var layer: CALayer {
get {
collectionView.layer
}
}
override func layoutSubviews() {
super.layoutSubviews()
publishContentHeightChangeIfNeeded()
}
}
extension ComposableCollectionView {
@discardableResult func isPagingEnabled(_ newValue: Bool) -> Self {
collectionView.isPagingEnabled = newValue
return self
}
@discardableResult func decelerationRate(_ newValue: UIScrollView.DecelerationRate) -> Self {
collectionView.decelerationRate = newValue
return self
}
@discardableResult func contentInset(_ newValue: UIEdgeInsets) -> Self {
collectionView.contentInset = newValue
return self
}
/// Reloads the collectionView.
func reloadData() {
collectionView.reloadData()
}
func forceEndRefreshing() {
collectionView.refreshControl?.endRefreshing()
}
// The height of the content view.
var contentHeight: CGFloat {
get {
collectionView.contentSize.height
}
set {
collectionView.contentSize = .init(
width: collectionView.contentSize.width,
height: newValue
)
}
}
var visibleCells: [UICollectionViewCell] {
collectionView.visibleCells
}
/// Adds a subview to the scrollview and then executes a configuration block.
/// - parameter subview: The view to be added
/// - parameter config: Non-escaping configuration block. Receives a reference to the
/// scrollview to which the subview is being added.
func addSubviewToScrollView(_ subview: UIView, withConfig config: (UIScrollView) -> Void) {
collectionView.addSubview(subview)
config(collectionView)
}
}
// MARK: - Main interface
extension ComposableCollectionView {
/// The number of sections in the collectionView
var sectionCount: Int {
return sections.count
}
/// The total number of items in the collectionView.
var itemCount: Int {
return sections.reduce(0) { $0 + $1.items.count }
}
/// Updates the backing data and reloads the collectionView.
func updateSections(_ sections: [Section]) {
self.sections = sections
collectionView.reloadData()
collectionView.refreshControl?.endRefreshing()
}
/// Updates the data for a specific section then reloads the collectionView. Crashes if
/// the index is greater than the current count of sections.
func updateSection(atIndex sectionIndex: Int, _ section: Section) {
if sections.indices.contains(sectionIndex) {
sections[sectionIndex] = section
}
else if sectionIndex == sections.count {
sections.append(section)
}
else {
assertionFailure("Invalid index passed to \(#function).")
}
collectionView.reloadData()
collectionView.refreshControl?.endRefreshing()
}
/// Sets the implementation of `collectionView(_:cellForItemAt:)` to be used by the collectionView.
///
/// - parameter callback: The implementation closure. Takes in the `Item` for the index path,
/// the `IndexPath` itself, and returns the cell. The cell returned must be an instance of
/// one of the classes passed in the `cellTypes` argument in the initializer.
/// - returns: `@discardableResult self` to allow for operator chaining, to aid in composition.
///
/// This must be set before `updateSections(_:)` or `updateSection(atIndex:_:)` are called,
/// otherwise it will result in a crash. The cell returned must be an instance of one of the
/// classes passed into the initializer.
@discardableResult func cellForItemWithSectionTypeAtIndexPath(_ callback: @escaping (Item, SectionTypeID, IndexPath, UICollectionView) -> UICollectionViewCell) -> Self {
dataSouce.cellForItemAtIndexPath { [weak self] cv, indexPath in
guard let self else {
let selfString = String(describing: ComposableCollectionView<Item, SectionTypeID>.self)
fatalError(
"Instance of \(selfString) accessed by underlying collectionView after being released from memory."
)
}
let section = self.sections[indexPath.section]
let item = section.items[indexPath.item]
return callback(item, section.sectionType, indexPath, cv)
}
return self
}
/// Sets the implementation of `collectionView(_:viewForSupplementaryElementOfKind:at:)`
/// to be used by the collectionView.
///
/// - parameter callback: The implementation closure. Takes in the `Section` struct and the
/// indexPath, and returns the header view. The header returned must be an instance of one
/// of the classes passed into the `headerViewTypes` argument in the initializer.
/// - returns: `@discardableResult self` to allow for operator chaining, to aid in composition.
///
/// This must be set before `updateSections(_:)` or `updateSection(atIndex:_:)` are called,
/// otherwise it will result in a crash.
@discardableResult func headerViewForSectionAtIndexPath(_ callback: @escaping (Section, IndexPath, UICollectionView) -> UICollectionReusableView) -> Self {
dataSouce.viewForSupplementaryElementOfKindAtIndexPath { [weak self] cv, _, indexPath -> UICollectionReusableView in
guard let self else {
let selfString = String(describing: ComposableCollectionView<Item, SectionTypeID>.self)
fatalError(
"Instance of \(selfString) accessed by underlying collectionView after being released from memory."
)
}
let section = self.sections[indexPath.section]
return callback(section, indexPath, cv)
}
return self
}
/// Sets the implementation of `collectionView(_:shouldSelectItemAt:)` to be used by the
/// collectionView.
///
/// - parameter callback: The implementation closure. Takes in the item and the indexPath,
/// and returns whether or not the user should be able to select that Item at that IndexPath.
/// - returns: `@discardableResult self` to allow for operator chaining, to aid in composition.
///
/// Default behavior is to return false, so if this is not called before the data is loaded,
/// no cells will be selectable.
@discardableResult func shouldSelectItemAtIndexPath(_ callback: @escaping (Item, IndexPath, UICollectionView) -> Bool) -> Self {
delegate.shouldSelectItemAtIndexPath { [weak self] cv, indexPath in
guard let item = self?.item(forIndexPath: indexPath) else {
return false
}
return callback(item, indexPath, cv)
}
return self
}
/// Sets the implementation of `collectionView(_:didSelectItemAt:)` to be used by the
/// collectionView.
///
/// - parameter callback: The implementation closure, executed when a user has selected a cell
/// at a given IndexPath. Is passed the Item, IndexPath, and a reference to the collection view.
/// - returns: `@discardableResult self` to allow for operator chaining, to aid in composition.
@discardableResult func didSelectItemAtIndexPath(_ callback: @escaping (Item, IndexPath, UICollectionView) -> Void) -> Self {
delegate.didSelectItemAtIndexPath { [weak self] cv, indexPath in
guard let item = self?.item(forIndexPath: indexPath) else {
return
}
callback(item, indexPath, cv)
}
return self
}
/// Sets the implementation of `scrollViewDidScroll(_:)` to be used by the collectionView.
///
/// - parameter callback: The implementation closure, executed when the scrollview scrols,
/// i.e. when the yOffset changes. Is passed a reference to the scrolLView.
/// - returns: `@discardableResult self` to allow for operator chaining, to aid in composition.
@discardableResult func didScroll(_ callback: @escaping (UIScrollView) -> Void) -> Self {
delegate.didScroll { [weak self] scrollView in
self?.publishContentHeightChangeIfNeeded()
callback(scrollView)
}
return self
}
@discardableResult func onContentHeightChanged(_ callback: ((CGFloat) -> Void)?) -> Self {
contentHeightDidChange = callback
return self
}
/// Adds a refresh control and sets the action to be triggered when the user pulls to refresh.
/// - parameter refreshAction: The closure that will be executed on pull to refresh. A nil value
/// will remove any existing refresh control. A non-nil value will add a refresh control if one
/// does not exist, or will replace the action of an existing refresh control.
/// - returns: `@discardableResult self` to allow for operator chaining, to aid in composition.
@discardableResult func onPullToRefresh(_ refreshAction: (() -> Void)?) -> Self {
guard let refreshAction else {
collectionView.refreshControl?.removeFromSuperview()
collectionView.refreshControl = nil
return self
}
if let control = collectionView.refreshControl as? ClosureBasedRefreshControl {
control.refreshAction = refreshAction
} else {
collectionView.addRefreshControlWithBlock(refreshAction)
}
return self
}
}
private extension ComposableCollectionView {
func item(forIndexPath indexPath: IndexPath) -> Item? {
sections[safe: indexPath.section]?.items[safe: indexPath.item]
}
func publishContentHeightChangeIfNeeded() {
let newContentHeight = contentHeight
if lastContentHeight == newContentHeight {
return
}
lastContentHeight = newContentHeight
contentHeightDidChange?(newContentHeight)
}
static func failInit(initName: String = #function) -> Never {
return fatalError(
"\(initName) has not been implemented. Please use .init(cellTypes:headerViewTypes:layoutForSection:) or .init(cellTypeMapping:headerViewTypes:) instead."
)
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment