deep-linking-sample/Apps/Wikipedia/WMF Framework/ColumnarCollectionViewLayout.swift
Javier Cicchelli 9bcdaa697b [Setup] Basic project structure (#1)
This PR contains all the work related to setting up this project as required to implement the [Assignment](https://repo.rock-n-code.com/rock-n-code/deep-linking-assignment/wiki/Assignment) on top, as intended.

To summarise this work:
- [x] created a new **Xcode** project;
- [x] cloned the `Wikipedia` app and inserted it into the **Xcode** project;
- [x] created the `Locations` app and also, its `Libraries` package;
- [x] created the `Shared` package to share dependencies between the apps;
- [x] added a `Makefile` file and implemented some **environment** and **help** commands.

Co-authored-by: Javier Cicchelli <javier@rock-n-code.com>
Reviewed-on: rock-n-code/deep-linking-assignment#1
2023-04-08 18:37:13 +00:00

319 lines
14 KiB
Swift

public protocol ColumnarCollectionViewLayoutDelegate {
func collectionView(_ collectionView: UICollectionView, estimatedHeightForItemAt indexPath: IndexPath, forColumnWidth columnWidth: CGFloat) -> ColumnarCollectionViewLayoutHeightEstimate
func collectionView(_ collectionView: UICollectionView, estimatedHeightForHeaderInSection section: Int, forColumnWidth columnWidth: CGFloat) -> ColumnarCollectionViewLayoutHeightEstimate
func collectionView(_ collectionView: UICollectionView, estimatedHeightForFooterInSection section: Int, forColumnWidth columnWidth: CGFloat) -> ColumnarCollectionViewLayoutHeightEstimate
func collectionView(_ collectionView: UICollectionView, shouldShowFooterForSection section: Int) -> Bool
func metrics(with boundsSize: CGSize, readableWidth: CGFloat, layoutMargins: UIEdgeInsets) -> ColumnarCollectionViewLayoutMetrics
}
public class ColumnarCollectionViewLayout: UICollectionViewLayout {
var info: ColumnarCollectionViewLayoutInfo? {
didSet {
oldInfo = oldValue
}
}
var oldInfo: ColumnarCollectionViewLayoutInfo?
var metrics: ColumnarCollectionViewLayoutMetrics?
var isLayoutValid: Bool = false
let defaultColumnWidth: CGFloat = 315
let maxColumnWidth: CGFloat = 740
public var slideInNewContentFromTheTop: Bool = false
public var animateItems: Bool = false
override public class var layoutAttributesClass: Swift.AnyClass {
return ColumnarCollectionViewLayoutAttributes.self
}
override public class var invalidationContextClass: Swift.AnyClass {
return ColumnarCollectionViewLayoutInvalidationContext.self
}
private var delegate: ColumnarCollectionViewLayoutDelegate? {
return collectionView?.delegate as? ColumnarCollectionViewLayoutDelegate
}
override public func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
guard let sections = info?.sections else {
return []
}
var attributes: [UICollectionViewLayoutAttributes] = []
for section in sections {
guard rect.intersects(section.frame) else {
continue
}
for item in section.headers {
guard rect.intersects(item.frame) else {
continue
}
attributes.append(item)
}
for item in section.items {
guard rect.intersects(item.frame) else {
continue
}
attributes.append(item)
}
for item in section.footers {
guard rect.intersects(item.frame) else {
continue
}
attributes.append(item)
}
}
return attributes
}
override public func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
return info?.layoutAttributesForItem(at: indexPath)
}
public override func layoutAttributesForDecorationView(ofKind elementKind: String, at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
return nil
}
public override func layoutAttributesForSupplementaryView(ofKind elementKind: String, at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
return info?.layoutAttributesForSupplementaryView(ofKind: elementKind, at: indexPath)
}
public var itemLayoutMargins: UIEdgeInsets {
guard let metrics = metrics else {
return .zero
}
return metrics.itemLayoutMargins
}
override public var collectionViewContentSize: CGSize {
guard let info = info else {
return .zero
}
return info.contentSize
}
public func layoutHeight(forWidth width: CGFloat) -> CGFloat {
guard let collectionView = collectionView, let delegate = delegate, width >= 1 else {
return 0
}
let oldMetrics = metrics
let newInfo = ColumnarCollectionViewLayoutInfo()
let newMetrics = delegate.metrics(with: CGSize(width: width, height: 100), readableWidth: width, layoutMargins: .zero)
metrics = newMetrics // needs to be set so that layout margins can be queried. probably not the best solution.
newInfo.layout(with: newMetrics, delegate: delegate, collectionView: collectionView, invalidationContext: nil)
metrics = oldMetrics
return newInfo.contentSize.height
}
override public func prepare() {
defer {
super.prepare()
}
guard let collectionView = collectionView else {
return
}
let size = collectionView.bounds.size
guard size.width > 0 && size.height > 0 else {
return
}
let readableWidth: CGFloat = collectionView.readableContentGuide.layoutFrame.size.width
if let metrics = metrics, !metrics.readableWidth.isEqual(to: readableWidth) {
isLayoutValid = false
}
guard let delegate = delegate, !isLayoutValid else {
return
}
let delegateMetrics = delegate.metrics(with: size, readableWidth: readableWidth, layoutMargins: collectionView.layoutMargins)
metrics = delegateMetrics
let newInfo = ColumnarCollectionViewLayoutInfo()
newInfo.layout(with: delegateMetrics, delegate: delegate, collectionView: collectionView, invalidationContext: nil)
info = newInfo
isLayoutValid = true
}
// MARK: - Invalidation
override public func invalidateLayout(with context: UICollectionViewLayoutInvalidationContext) {
defer {
super.invalidateLayout(with: context)
}
guard let context = context as? ColumnarCollectionViewLayoutInvalidationContext else {
return
}
guard context.invalidateEverything || context.invalidateDataSourceCounts || context.boundsDidChange else {
return
}
isLayoutValid = false
}
override public func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool {
guard let metrics = metrics else {
return true
}
return !newBounds.size.width.isEqual(to: metrics.boundsSize.width)
}
override public func invalidationContext(forBoundsChange newBounds: CGRect) -> UICollectionViewLayoutInvalidationContext {
let superContext = super.invalidationContext(forBoundsChange: newBounds)
let context = superContext as? ColumnarCollectionViewLayoutInvalidationContext ?? ColumnarCollectionViewLayoutInvalidationContext()
context.boundsDidChange = true
return context
}
override public func shouldInvalidateLayout(forPreferredLayoutAttributes preferredAttributes: UICollectionViewLayoutAttributes, withOriginalAttributes originalAttributes: UICollectionViewLayoutAttributes) -> Bool {
return !preferredAttributes.frame.equalTo(originalAttributes.frame)
}
override public func invalidationContext(forPreferredLayoutAttributes preferredAttributes: UICollectionViewLayoutAttributes, withOriginalAttributes originalAttributes: UICollectionViewLayoutAttributes) -> UICollectionViewLayoutInvalidationContext {
let superContext = super.invalidationContext(forPreferredLayoutAttributes: preferredAttributes, withOriginalAttributes: originalAttributes)
let context = superContext as? ColumnarCollectionViewLayoutInvalidationContext ?? ColumnarCollectionViewLayoutInvalidationContext()
context.preferredLayoutAttributes = preferredAttributes
context.originalLayoutAttributes = originalAttributes
if let delegate = delegate, let metrics = metrics, let info = info, let collectionView = collectionView {
info.update(with: metrics, invalidationContext: context, delegate: delegate, collectionView: collectionView)
}
return context
}
// MARK: - Animation
var maxNewSection: Int = -1
var newSectionDeltaY: CGFloat = 0
var appearingIndexPaths: Set<IndexPath> = []
var disappearingIndexPaths: Set<IndexPath> = []
override public func prepare(forCollectionViewUpdates updateItems: [UICollectionViewUpdateItem]) {
super.prepare(forCollectionViewUpdates: updateItems)
guard animateItems, let info = info else {
appearingIndexPaths.removeAll(keepingCapacity: true)
disappearingIndexPaths.removeAll(keepingCapacity: true)
maxNewSection = -1
newSectionDeltaY = 0
return
}
if slideInNewContentFromTheTop {
var maxSection = -1
for updateItem in updateItems {
guard let after = updateItem.indexPathAfterUpdate, after.item == NSNotFound, updateItem.indexPathBeforeUpdate == nil else {
continue
}
let section: Int = after.section
guard section == maxSection + 1 else {
continue
}
maxSection = section
}
guard maxSection > -1 && maxSection < info.sections.count else {
maxNewSection = -1
return
}
maxNewSection = maxSection
let sectionFrame = info.sections[maxSection].frame
newSectionDeltaY = 0 - sectionFrame.maxY
appearingIndexPaths.removeAll(keepingCapacity: true)
disappearingIndexPaths.removeAll(keepingCapacity: true)
} else {
appearingIndexPaths.removeAll(keepingCapacity: true)
disappearingIndexPaths.removeAll(keepingCapacity: true)
newSectionDeltaY = 0
maxNewSection = -1
for updateItem in updateItems {
if let after = updateItem.indexPathAfterUpdate, updateItem.indexPathBeforeUpdate == nil {
appearingIndexPaths.insert(after)
} else if let before = updateItem.indexPathBeforeUpdate, updateItem.indexPathAfterUpdate == nil {
disappearingIndexPaths.insert(before)
}
}
}
}
private func adjustAttributesIfNecessary(_ attributes: UICollectionViewLayoutAttributes, forItemOrElementAppearingAtIndexPath indexPath: IndexPath) {
guard indexPath.section <= maxNewSection else {
guard animateItems, appearingIndexPaths.contains(indexPath) else {
return
}
attributes.zIndex = -1
attributes.alpha = 0
return
}
attributes.frame.origin.y += newSectionDeltaY
attributes.alpha = 1
}
public override func initialLayoutAttributesForAppearingSupplementaryElement(ofKind elementKind: String, at elementIndexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
guard let attributes = super.initialLayoutAttributesForAppearingSupplementaryElement(ofKind: elementKind, at: elementIndexPath) else {
return nil
}
adjustAttributesIfNecessary(attributes, forItemOrElementAppearingAtIndexPath: elementIndexPath)
return attributes
}
public override func initialLayoutAttributesForAppearingItem(at itemIndexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
guard let attributes = super.initialLayoutAttributesForAppearingItem(at: itemIndexPath) else {
return nil
}
adjustAttributesIfNecessary(attributes, forItemOrElementAppearingAtIndexPath: itemIndexPath)
return attributes
}
public override func initialLayoutAttributesForAppearingDecorationElement(ofKind elementKind: String, at decorationIndexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
return super.initialLayoutAttributesForAppearingDecorationElement(ofKind: elementKind, at: decorationIndexPath)
}
public override func finalLayoutAttributesForDisappearingItem(at itemIndexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
guard let attributes = super.finalLayoutAttributesForDisappearingItem(at: itemIndexPath) else {
return nil
}
guard animateItems, disappearingIndexPaths.contains(itemIndexPath) else {
return attributes
}
attributes.zIndex = -1
attributes.alpha = 0
return attributes
}
// MARK: Scroll View
public var currentSection: Int?
public override func targetContentOffset(forProposedContentOffset proposedContentOffset: CGPoint) -> CGPoint {
var superTarget = super.targetContentOffset(forProposedContentOffset: proposedContentOffset)
if let currentSection = currentSection,
let oldInfo = oldInfo,
let info = info,
oldInfo.sections.indices.contains(currentSection),
info.sections.indices.contains(currentSection) {
let oldY = oldInfo.sections[currentSection].frame.origin.y
let newY = info.sections[currentSection].frame.origin.y
let deltaY = newY - oldY
superTarget.y += deltaY
}
return superTarget
}
}
extension ColumnarCollectionViewLayout: NSCopying {
public func copy(with zone: NSZone? = nil) -> Any {
let newLayout = ColumnarCollectionViewLayout()
newLayout.info = info
newLayout.oldInfo = oldInfo
newLayout.metrics = metrics
newLayout.isLayoutValid = isLayoutValid
newLayout.slideInNewContentFromTheTop = slideInNewContentFromTheTop
newLayout.animateItems = animateItems
newLayout.maxNewSection = maxNewSection
newLayout.newSectionDeltaY = newSectionDeltaY
newLayout.appearingIndexPaths = appearingIndexPaths
newLayout.disappearingIndexPaths = disappearingIndexPaths
return newLayout
}
}