Tusker/Tusker/MultiColumnCollectionViewLa...

261 lines
10 KiB
Swift

//
// 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..<effectiveNumberOfColumns {
guard let attrs = lastAttributesInEachColumn[i] else {
return i
}
if min == nil || attrs.frame.maxY < lastAttributesInEachColumn[min!]!.frame.maxY {
min = i
}
}
return min!
}
let columnWidth = columnWidthForColumnCount(effectiveNumberOfColumns)
func minXForColumn(_ column: Int) -> 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..<numberOfItems {
let column = columnWithMinHeight()
let itemAttrs: MultiColumnLayoutAttributes
if attributes.count > 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
}