Permalink
Switch branches/tags
Nothing to show
Find file Copy path
Fetching contributors…
Cannot retrieve contributors at this time
319 lines (273 sloc) 16 KB
//
// AKPFlowLayout.swift
// SwiftNetworkImages
//
// Created by Arseniy on 18/5/16.
// Copyright © 2016 Arseniy Kuznetsov. All rights reserved.
//
import UIKit
/**
Global / Sticky / Stretchy Headers using UICollectionViewFlowLayout.
Works for iOS8 and above.
*/
public final class AKPFlowLayout: UICollectionViewFlowLayout {
/// Layout configuration options
public var layoutOptions: AKPLayoutConfigOptions = [.firstSectionIsGlobalHeader,
.firstSectionStretchable,
.sectionsPinToGlobalHeaderOrVisibleBounds]
/// For stretchy headers, allowis limiting amount of stretch
public var firsSectionMaximumStretchHeight = CGFloat.greatestFiniteMagnitude
// MARK: - Initialization
override public init() {
super.init()
// For iOS9, needs to ensure the impl does not interfere with `sectionHeadersPinToVisibleBounds`
// Seems to be no reasonable way yet to use Swift property observers with conditional compilation,
// so falling back to KVO
if #available(iOS 9.0, *) {
addObserver(self, forKeyPath: "sectionHeadersPinToVisibleBounds",
options: .new, context: &AKPFlowLayoutKVOContext)
}
}
required public init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
}
deinit {
if #available(iOS 9.0, *) {
removeObserver(self, forKeyPath: "sectionHeadersPinToVisibleBounds", context: &AKPFlowLayoutKVOContext)
}
}
// MARK: - 📐Custom Layout
/// - returns: AKPFlowLayoutAttributes class for handling layout attributes
override public class var layoutAttributesClass : AnyClass {
return AKPFlowLayoutAttributes.self
}
/// Returns layout attributes for specified rectangle, with added custom headers
override public func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
guard shouldDoCustomLayout else { return super.layoutAttributesForElements(in: rect) }
guard var layoutAttributes = super.layoutAttributesForElements(in: rect) as? [AKPFlowLayoutAttributes],
// calculate custom headers that should be confined in the rect
let customSectionHeadersIdxs = customSectionHeadersIdxs(rect) else { return nil }
// add the custom headers to the regular UICollectionViewFlowLayout layoutAttributes
for idx in customSectionHeadersIdxs {
let indexPath = IndexPath(item: 0, section: idx)
if let attributes = super.layoutAttributesForSupplementaryView(
ofKind: UICollectionElementKindSectionHeader,
at: indexPath) as? AKPFlowLayoutAttributes {
layoutAttributes.append(attributes)
}
}
// for section headers, need to adjust their attributes
for attributes in layoutAttributes where
attributes.representedElementKind == UICollectionElementKindSectionHeader {
(attributes.frame, attributes.zIndex) = adjustLayoutAttributes(forSectionAttributes: attributes)
}
return layoutAttributes
}
/// Adjusts layout attributes for the custom section headers
override public func layoutAttributesForSupplementaryView(ofKind elementKind: String,
at indexPath: IndexPath)
-> UICollectionViewLayoutAttributes? {
guard shouldDoCustomLayout else {
return super.layoutAttributesForSupplementaryView(ofKind: elementKind, at: indexPath) }
guard let sectionHeaderAttributes = super.layoutAttributesForSupplementaryView(
ofKind: elementKind,
at: indexPath)
as? AKPFlowLayoutAttributes else { return nil }
// Adjust section attributes
(sectionHeaderAttributes.frame, sectionHeaderAttributes.zIndex) =
adjustLayoutAttributes(forSectionAttributes: sectionHeaderAttributes)
return sectionHeaderAttributes
}
// MARK: - 🎳Invalidation
/// - returns: `true`, unless running on iOS9 with `sectionHeadersPinToVisibleBounds` set to `true`
override public func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool {
guard shouldDoCustomLayout else { return super.shouldInvalidateLayout(forBoundsChange: newBounds) }
return true
}
/// Custom invalidation
override public func invalidationContext(forBoundsChange newBounds: CGRect)
-> UICollectionViewLayoutInvalidationContext {
guard shouldDoCustomLayout,
let invalidationContext = super.invalidationContext(forBoundsChange: newBounds)
as? UICollectionViewFlowLayoutInvalidationContext,
let oldBounds = collectionView?.bounds
else { return super.invalidationContext(forBoundsChange: newBounds) }
// Size changes?
if oldBounds.size != newBounds.size {
// re-query the collection view delegate for metrics such as size information etc.
invalidationContext.invalidateFlowLayoutDelegateMetrics = true
}
// Origin changes?
if oldBounds.origin != newBounds.origin {
// find and invalidate sections that would fall into the new bounds
guard let sectionIdxPaths = sectionsHeadersIDxs(forRect: newBounds) else {return invalidationContext}
// then invalidate
let invalidatedIdxPaths = sectionIdxPaths.map { IndexPath(item: 0, section: $0) }
invalidationContext.invalidateSupplementaryElements(
ofKind: UICollectionElementKindSectionHeader, at: invalidatedIdxPaths )
}
return invalidationContext
}
fileprivate var previousStretchFactor = CGFloat(0)
}
// MARK: - 🕶Private Helpers
extension AKPFlowLayout {
fileprivate var shouldDoCustomLayout: Bool {
var requestForCustomLayout = layoutOptions.contains(.firstSectionIsGlobalHeader) ||
layoutOptions.contains(.firstSectionStretchable) ||
layoutOptions.contains(.sectionsPinToGlobalHeaderOrVisibleBounds)
// iOS9 supports sticky headers natively, so we should not
// interfere with the the built-in functionality
if #available(iOS 9.0, *) {
requestForCustomLayout = requestForCustomLayout && !sectionHeadersPinToVisibleBounds
}
return requestForCustomLayout
}
fileprivate func zIndexForSection(_ section: Int) -> Int {
return section > 0 ? 128 : 256
}
// Given a rect, calculates indexes of all confined section headers
// _including_ the custom headers
fileprivate func sectionsHeadersIDxs(forRect rect: CGRect) -> Set<Int>? {
guard let layoutAttributes = super.layoutAttributesForElements(in: rect)
as? [AKPFlowLayoutAttributes] else {return nil}
let sectionsShouldPin = layoutOptions.contains(.sectionsPinToGlobalHeaderOrVisibleBounds)
var headersIdxs = Set<Int>()
for attributes in layoutAttributes
where attributes.visibleSectionHeader(sectionsShouldPin) {
headersIdxs.insert((attributes.indexPath as NSIndexPath).section)
}
if layoutOptions.contains(.firstSectionIsGlobalHeader) {
headersIdxs.insert(0)
}
return headersIdxs
}
// Given a rect, calculates the indexes of confined custom section headers
// _excluding_ the regular headers handled by UICollectionViewFlowLayout
fileprivate func customSectionHeadersIdxs(_ rect: CGRect) -> Set<Int>? {
guard let layoutAttributes = super.layoutAttributesForElements(in: rect),
var sectionIdxs = sectionsHeadersIDxs(forRect: rect) else {return nil}
// remove the sections that should already be taken care of by UICollectionViewFlowLayout
for attributes in layoutAttributes
where attributes.representedElementKind == UICollectionElementKindSectionHeader {
sectionIdxs.remove((attributes.indexPath as NSIndexPath).section)
}
return sectionIdxs
}
// Adjusts layout attributes of section headers
fileprivate func adjustLayoutAttributes(forSectionAttributes
sectionHeadersLayoutAttributes: AKPFlowLayoutAttributes)
-> (CGRect, Int) {
guard let collectionView = collectionView else { return (CGRect.zero, 0) }
let section = (sectionHeadersLayoutAttributes.indexPath as NSIndexPath).section
var sectionFrame = sectionHeadersLayoutAttributes.frame
// 1. Establish the section boundaries:
let (minY, maxY) = boundaryMetrics(forSectionAttributes: sectionHeadersLayoutAttributes)
// 2. Determine the height and insets of the first section,
// in case it's stretchable or serves as a global header
let (firstSectionHeight, firstSectionInsets) = firstSectionMetrics()
// 3. If within the above boundaries, the section should follow content offset
// (adjusting a few more things along the way)
var offset = collectionView.contentOffset.y + collectionView.contentInset.top
if (section > 0) {
// The global section
if layoutOptions.contains(.sectionsPinToGlobalHeaderOrVisibleBounds) {
if layoutOptions.contains(.firstSectionIsGlobalHeader) {
// A global header adjustment
offset += firstSectionHeight + firstSectionInsets.top
}
sectionFrame.origin.y = min(max(offset, minY), maxY)
}
} else {
if layoutOptions.contains(.firstSectionStretchable) && offset < 0 {
// Stretchy header
if firstSectionHeight - offset < firsSectionMaximumStretchHeight {
sectionFrame.size.height = firstSectionHeight - offset
sectionHeadersLayoutAttributes.stretchFactor = fabs(offset)
previousStretchFactor = sectionHeadersLayoutAttributes.stretchFactor
} else {
// need to limit the stretch
sectionFrame.size.height = firsSectionMaximumStretchHeight
sectionHeadersLayoutAttributes.stretchFactor = previousStretchFactor
}
sectionFrame.origin.y += offset + firstSectionInsets.top
} else if layoutOptions.contains(.firstSectionIsGlobalHeader) {
// Sticky header position needs to be relative to the global header
sectionFrame.origin.y += offset + firstSectionInsets.top
} else {
sectionFrame.origin.y = min(max(offset, minY), maxY)
}
}
return (sectionFrame, zIndexForSection(section))
}
fileprivate func boundaryMetrics(
forSectionAttributes sectionHeadersLayoutAttributes: UICollectionViewLayoutAttributes)
-> (CGFloat, CGFloat) {
// get attributes for first and last items in section
guard let collectionView = collectionView else { return (0, 0) }
let section = (sectionHeadersLayoutAttributes.indexPath as NSIndexPath).section
// Trying to use layoutAttributesForItemAtIndexPath for empty section would
// cause EXC_ARITHMETIC in simulator (division by zero items)
let lastInSectionIdx = collectionView.numberOfItems(inSection: section) - 1
if lastInSectionIdx < 0 { return (0, 0) }
guard let attributesForFirstItemInSection = layoutAttributesForItem(
at: IndexPath(item: 0, section: section)),
let attributesForLastItemInSection = layoutAttributesForItem(
at: IndexPath(item: lastInSectionIdx, section: section))
else {return (0, 0)}
let sectionFrame = sectionHeadersLayoutAttributes.frame
// Section Boundaries:
// The section should not be higher than the top of its first cell
let minY = attributesForFirstItemInSection.frame.minY - sectionFrame.height
// The section should not be lower than the bottom of its last cell
let maxY = attributesForLastItemInSection.frame.maxY - sectionFrame.height
return (minY, maxY)
}
fileprivate func firstSectionMetrics() -> (height: CGFloat, insets: UIEdgeInsets) {
guard let collectionView = collectionView else { return (0, UIEdgeInsets.zero) }
// height of the first section
var firstSectionHeight = headerReferenceSize.height
if let delegate = collectionView.delegate as? UICollectionViewDelegateFlowLayout
, firstSectionHeight == 0 {
firstSectionHeight = delegate.collectionView!(collectionView,
layout: self,
referenceSizeForHeaderInSection: 0).height
}
// insets of the first section
var theSectionInset = sectionInset
if let delegate = collectionView.delegate as? UICollectionViewDelegateFlowLayout
, theSectionInset == UIEdgeInsets.zero {
theSectionInset = delegate.collectionView!(collectionView,
layout: self,
insetForSectionAt: 0)
}
return (firstSectionHeight, theSectionInset)
}
}
// MARK: - KVO check for `sectionHeadersPinToVisibleBounds`
extension AKPFlowLayout {
/// KVO check for `sectionHeadersPinToVisibleBounds`.
/// For iOS9, needs to ensure the impl does not interfere with `sectionHeadersPinToVisibleBounds`
override public func observeValue(forKeyPath keyPath: String?, of object: Any?,
change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
if context == &AKPFlowLayoutKVOContext {
if let newValue = change?[NSKeyValueChangeKey.newKey],
let boolValue = newValue as? Bool , boolValue {
print("AKPFlowLayout supports sticky headers by default, therefore " +
"the built-in functionality via sectionHeadersPinToVisibleBounds has been disabled")
if #available(iOS 9.0, *) { sectionHeadersPinToVisibleBounds = false }
}
} else {
super.observeValue(forKeyPath: keyPath, of: object, change: change, context: context)
}
}
}
private extension AKPFlowLayoutAttributes {
// Determines if element is a section, or is a cell in a section with custom header
func visibleSectionHeader(_ sectionsShouldPin: Bool) -> Bool {
let isHeader = representedElementKind == UICollectionElementKindSectionHeader
let isCellInPinnedSection = sectionsShouldPin && ( representedElementCategory == .cell )
return isCellInPinnedSection || isHeader
}
}
private var AKPFlowLayoutKVOContext = 0