// // MultiColumnCollectionViewLayout.swift // Tusker // // Created by Shadowfacts on 9/29/23. // Copyright © 2023 Shadowfacts. All rights reserved. // import UIKit class MultiColumnCollectionViewLayout: UICollectionViewLayout { private let numberOfColumns: Int private let spacing: CGFloat private let minimumColumnWidth: CGFloat private var effectiveNumberOfColumns: Int! private var attributes: [MultiColumnLayoutAttributes] = [] private var invalidatedItemIndices: IndexSet = [] var showSectionHeader = false { didSet { if showSectionHeader != oldValue { invalidateLayout() } } } private var sectionHeaderAttributes: MultiColumnLayoutAttributes? init(numberOfColumns: Int, columnSpacing: CGFloat, minimumColumnWidth: CGFloat) { precondition(numberOfColumns >= 1) self.numberOfColumns = numberOfColumns self.effectiveNumberOfColumns = nil self.spacing = columnSpacing self.minimumColumnWidth = minimumColumnWidth super.init() } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override class var layoutAttributesClass: AnyClass { MultiColumnLayoutAttributes.self } override class var invalidationContextClass: AnyClass { MultiColumnLayoutInvalidationContext.self } override func prepare() { guard let collectionView else { return } precondition(collectionView.numberOfSections <= 1) guard collectionView.numberOfSections == 1 else { attributes = [] return } if effectiveNumberOfColumns == nil { updateEffectiveNumberOfColumns() } var lastAttributesInEachColumn: [MultiColumnLayoutAttributes?] = Array(repeating: nil, count: effectiveNumberOfColumns) func columnWithMinHeight() -> Int { var min: Int? for i in 0.. CGFloat { (CGFloat(column) * columnWidth) + ((CGFloat(column) + 1) * spacing) } let startY: CGFloat if showSectionHeader { let indexPath = IndexPath(item: 0, section: 0) if sectionHeaderAttributes == nil { sectionHeaderAttributes = .init(forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader, with: indexPath) } sectionHeaderAttributes!.frame = CGRect(x: 0, y: 0, width: collectionView.bounds.width, height: 0) if let view = collectionView.supplementaryView(forElementKind: UICollectionView.elementKindSectionHeader, at: indexPath) { let preferred = view.preferredLayoutAttributesFitting(sectionHeaderAttributes!) sectionHeaderAttributes!.frame.size.height = preferred.frame.height } startY = sectionHeaderAttributes!.frame.height } else { startY = spacing } let numberOfItems = collectionView.numberOfItems(inSection: 0) for item in 0.. item { itemAttrs = attributes[item] } else { itemAttrs = MultiColumnLayoutAttributes(forCellWith: IndexPath(item: item, section: 0)) // estimate itemAttrs.frame.size.height = 200 attributes.append(itemAttrs) } itemAttrs.column = column itemAttrs.frame.size.width = columnWidth if invalidatedItemIndices.contains(item) { if let cell = collectionView.cellForItem(at: IndexPath(item: item, section: 0)) { let preferred = cell.preferredLayoutAttributesFitting(itemAttrs.copy() as! UICollectionViewLayoutAttributes) itemAttrs.frame.size.height = preferred.frame.height } } if let lastInColumn = lastAttributesInEachColumn[column] { itemAttrs.frame.origin = CGPoint( x: lastInColumn.frame.minX, y: lastInColumn.frame.maxY + spacing ) } else { itemAttrs.frame.origin = CGPoint( x: minXForColumn(column), y: startY ) } lastAttributesInEachColumn[column] = itemAttrs } if attributes.count > numberOfItems { attributes.removeLast(attributes.count - numberOfItems) } invalidatedItemIndices = [] } override var collectionViewContentSize: CGSize { guard let collectionView else { return .zero } let maxY = attributes.lazy.map(\.frame.maxY).max() return CGSize(width: collectionView.bounds.width, height: (maxY ?? 0) + spacing) } override func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? { return attributes[indexPath.item] } override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? { // TODO: optimize this var attributes = attributes.filter { $0.frame.intersects(rect) } if showSectionHeader, let sectionHeaderAttributes, rect.minY <= sectionHeaderAttributes.frame.maxY { attributes.append(sectionHeaderAttributes) } return attributes } override func layoutAttributesForSupplementaryView(ofKind elementKind: String, at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? { if elementKind == UICollectionView.elementKindSectionHeader, showSectionHeader { return sectionHeaderAttributes } else { return nil } } override func indexPathsToInsertForSupplementaryView(ofKind elementKind: String) -> [IndexPath] { if elementKind == UICollectionView.elementKindSectionHeader { return [IndexPath(item: 0, section: 0)] } else { return [] } } override func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool { return newBounds.width != collectionView?.bounds.width } override func invalidationContext(forBoundsChange newBounds: CGRect) -> UICollectionViewLayoutInvalidationContext { let context = super.invalidationContext(forBoundsChange: newBounds) as! MultiColumnLayoutInvalidationContext context.updateEffectiveNumberOfColumns = true return context } // On landscape-to-portrait device rotations (though seemingly only when inside a UISplitViewController), the collection view // doesn't go through the bounds-change codepath. But we may still need to update the effective number of columns, // so override this private method (which is called) and configure the invalidation context appropriately. override func _invalidationContext(forUpdatedLayoutMargins margins: UIEdgeInsets) -> UICollectionViewLayoutInvalidationContext { let context = super._invalidationContext(forUpdatedLayoutMargins: margins) as! MultiColumnLayoutInvalidationContext context.updateEffectiveNumberOfColumns = true return context } override func shouldInvalidateLayout(forPreferredLayoutAttributes preferredAttributes: UICollectionViewLayoutAttributes, withOriginalAttributes originalAttributes: UICollectionViewLayoutAttributes) -> Bool { return preferredAttributes.frame.height != originalAttributes.frame.height } override func invalidationContext(forPreferredLayoutAttributes preferredAttributes: UICollectionViewLayoutAttributes, withOriginalAttributes originalAttributes: UICollectionViewLayoutAttributes) -> UICollectionViewLayoutInvalidationContext { let context = super.invalidationContext(forPreferredLayoutAttributes: preferredAttributes, withOriginalAttributes: originalAttributes) if let kind = preferredAttributes.representedElementKind { context.invalidateSupplementaryElements(ofKind: kind, at: [preferredAttributes.indexPath]) } else { context.invalidateItems(at: [preferredAttributes.indexPath]) } return context } override func invalidateLayout(with context: UICollectionViewLayoutInvalidationContext) { let context = context as! MultiColumnLayoutInvalidationContext for indexPath in context.invalidatedItemIndexPaths ?? [] { invalidatedItemIndices.insert(indexPath.item) } if context.invalidateEverything || context.updateEffectiveNumberOfColumns { updateEffectiveNumberOfColumns() } super.invalidateLayout(with: context) } private func columnWidthForColumnCount(_ count: Int) -> CGFloat { guard let collectionView else { return 0 } let spacingTotal = spacing * (CGFloat(count) + 1) return (collectionView.bounds.width - spacingTotal) / CGFloat(count) } private func updateEffectiveNumberOfColumns() { var n = numberOfColumns while n > 1 && columnWidthForColumnCount(n) < minimumColumnWidth { n -= 1 } effectiveNumberOfColumns = n } } private class MultiColumnLayoutAttributes: UICollectionViewLayoutAttributes { var column: Int = -1 override func copy(with zone: NSZone? = nil) -> Any { let copy = super.copy(with: zone) as! MultiColumnLayoutAttributes copy.column = column return copy } } private class MultiColumnLayoutInvalidationContext: UICollectionViewLayoutInvalidationContext { var updateEffectiveNumberOfColumns: Bool = false }