Last active
September 7, 2023 01:10
-
-
Save jakehawken/654d8e9a3e9f31045748cacc7ff1216b to your computer and use it in GitHub Desktop.
Eschew delegation. Embrace composition.
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
// 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