-
-
Save smswz/393b2d6237b7837234015805c600ada2 to your computer and use it in GitHub Desktop.
// MIT License | |
// | |
// Copyright (c) 2016 stable|kernel | |
// | |
// Permission is hereby granted, free of charge, to any person obtaining a copy | |
// of this software and associated documentation files (the "Software"), to deal | |
// in the Software without restriction, including without limitation the rights | |
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell | |
// copies of the Software, and to permit persons to whom the Software is | |
// furnished to do so, subject to the following conditions: | |
// | |
// The above copyright notice and this permission notice shall be included in all | |
// copies or substantial portions of the Software. | |
// | |
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR | |
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, | |
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE | |
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER | |
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, | |
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE | |
// SOFTWARE. | |
import UIKit | |
protocol GridLayoutDelegate: class { | |
func scaleForItem(inCollectionView collectionView: UICollectionView, withLayout layout: UICollectionViewLayout, atIndexPath indexPath: IndexPath) -> UInt | |
func itemFlexibleDimension(inCollectionView collectionView: UICollectionView, withLayout layout: UICollectionViewLayout, fixedDimension: CGFloat) -> CGFloat | |
func headerFlexibleDimension(inCollectionView collectionView: UICollectionView, withLayout layout: UICollectionViewLayout, fixedDimension: CGFloat) -> CGFloat | |
} | |
extension GridLayoutDelegate { | |
func scaleForItem(inCollectionView collectionView: UICollectionView, withLayout layout: UICollectionViewLayout, atIndexPath indexPath: IndexPath) -> UInt { | |
return 1 | |
} | |
func itemFlexibleDimension(inCollectionView collectionView: UICollectionView, withLayout layout: UICollectionViewLayout, fixedDimension: CGFloat) -> CGFloat { | |
return fixedDimension | |
} | |
func headerFlexibleDimension(inCollectionView collectionView: UICollectionView, withLayout layout: UICollectionViewLayout, fixedDimension: CGFloat) -> CGFloat { | |
return 0 | |
} | |
} | |
class GridLayout: UICollectionViewLayout, GridLayoutDelegate { | |
override var collectionViewContentSize: CGSize { | |
return CGSize(width: contentWidth, height: contentHeight) | |
} | |
// User-configurable 'knobs' | |
var scrollDirection: UICollectionViewScrollDirection = .vertical | |
// Spacing between items | |
var itemSpacing: CGFloat = 0 | |
// Prevent the user from giving an invalid fixedDivisionCount | |
var fixedDivisionCount: UInt { | |
get { | |
return UInt(intFixedDivisionCount) | |
} | |
set { | |
intFixedDivisionCount = newValue == 0 ? 1 : Int(newValue) | |
} | |
} | |
weak var delegate: GridLayoutDelegate? | |
/// Backing variable for fixedDivisionCount, is an Int since indices don't like UInt | |
private var intFixedDivisionCount = 1 | |
private var contentWidth: CGFloat = 0 | |
private var contentHeight: CGFloat = 0 | |
private var itemFixedDimension: CGFloat = 0 | |
private var itemFlexibleDimension: CGFloat = 0 | |
/// This represents a 2 dimensional array for each section, indicating whether each block in the grid is occupied | |
/// It is grown dynamically as needed to fit every item into a grid | |
private var sectionedItemGrid: Array<Array<Array<Bool>>> = [] | |
/// The cache built up during the `prepare` function | |
private var itemAttributesCache: Array<UICollectionViewLayoutAttributes> = [] | |
/// The header cache built up during the `prepare` function | |
private var headerAttributesCache: Array<UICollectionViewLayoutAttributes> = [] | |
/// A convenient tuple for working with items | |
private typealias ItemFrame = (section: Int, flexibleIndex: Int, fixedIndex: Int, scale: Int) | |
// MARK: - UICollectionView Layout | |
override func prepare() { | |
// On rotation, UICollectionView sometimes calls prepare without calling invalidateLayout | |
guard itemAttributesCache.isEmpty, headerAttributesCache.isEmpty, let collectionView = collectionView else { return } | |
let fixedDimension: CGFloat | |
if scrollDirection == .vertical { | |
fixedDimension = collectionView.frame.width - (collectionView.contentInset.left + collectionView.contentInset.right) | |
contentWidth = fixedDimension | |
} else { | |
fixedDimension = collectionView.frame.height - (collectionView.contentInset.top + collectionView.contentInset.bottom) | |
contentHeight = fixedDimension | |
} | |
var additionalSectionSpacing: CGFloat = 0 | |
let headerFlexibleDimension = (delegate ?? self).headerFlexibleDimension(inCollectionView: collectionView, withLayout: self, fixedDimension: fixedDimension) | |
itemFixedDimension = (fixedDimension - (CGFloat(fixedDivisionCount) * itemSpacing) + itemSpacing) / CGFloat(fixedDivisionCount) | |
itemFlexibleDimension = (delegate ?? self).itemFlexibleDimension(inCollectionView: collectionView, withLayout: self, fixedDimension: itemFixedDimension) | |
for section in 0 ..< collectionView.numberOfSections { | |
let itemCount = collectionView.numberOfItems(inSection: section) | |
// Calculate header attributes | |
if headerFlexibleDimension > 0.0 && itemCount > 0 { | |
if headerAttributesCache.count > 0 { | |
additionalSectionSpacing += itemSpacing | |
} | |
let frame: CGRect | |
if scrollDirection == .vertical { | |
frame = CGRect(x: 0, y: additionalSectionSpacing, width: fixedDimension, height: headerFlexibleDimension) | |
} else { | |
frame = CGRect(x: additionalSectionSpacing, y: 0, width: headerFlexibleDimension, height: fixedDimension) | |
} | |
let headerLayoutAttributes = UICollectionViewLayoutAttributes(forSupplementaryViewOfKind: UICollectionElementKindSectionHeader, with: IndexPath(item: 0, section: section)) | |
headerLayoutAttributes.frame = frame | |
headerAttributesCache.append(headerLayoutAttributes) | |
additionalSectionSpacing += headerFlexibleDimension + itemSpacing | |
} | |
// Calculate item attributes | |
let sectionOffset = additionalSectionSpacing | |
sectionedItemGrid.append([]) | |
var flexibleIndex = 0, fixedIndex = 0 | |
for item in 0 ..< itemCount { | |
if fixedIndex >= intFixedDivisionCount { | |
// Reached end of row in .vertical or column in .horizontal | |
fixedIndex = 0 | |
flexibleIndex += 1 | |
} | |
let itemIndexPath = IndexPath(item: item, section: section) | |
let itemScale = indexableScale(forItemAt: itemIndexPath) | |
let intendedFrame = ItemFrame(section, flexibleIndex, fixedIndex, itemScale) | |
// Find a place for the item in the grid | |
let (itemFrame, didFitInOriginalFrame) = nextAvailableFrame(startingAt: intendedFrame) | |
reserveItemGrid(frame: itemFrame) | |
let itemAttributes = layoutAttributes(for: itemIndexPath, at: itemFrame, with: sectionOffset) | |
itemAttributesCache.append(itemAttributes) | |
// Update flexible dimension | |
if scrollDirection == .vertical { | |
if itemAttributes.frame.maxY > contentHeight { | |
contentHeight = itemAttributes.frame.maxY | |
} | |
if itemAttributes.frame.maxY > additionalSectionSpacing { | |
additionalSectionSpacing = itemAttributes.frame.maxY | |
} | |
} else { | |
// .horizontal | |
if itemAttributes.frame.maxX > contentWidth { | |
contentWidth = itemAttributes.frame.maxX | |
} | |
if itemAttributes.frame.maxX > additionalSectionSpacing { | |
additionalSectionSpacing = itemAttributes.frame.maxX | |
} | |
} | |
if (didFitInOriginalFrame) { | |
fixedIndex += 1 + itemFrame.scale | |
} | |
} | |
} | |
sectionedItemGrid = [] // Only used during prepare, free up some memory | |
} | |
override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? { | |
let headerAttributes = headerAttributesCache.filter { | |
$0.frame.intersects(rect) | |
} | |
let itemAttributes = itemAttributesCache.filter { | |
$0.frame.intersects(rect) | |
} | |
return headerAttributes + itemAttributes | |
} | |
override func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? { | |
return itemAttributesCache.first { | |
$0.indexPath == indexPath | |
} | |
} | |
override func layoutAttributesForSupplementaryView(ofKind elementKind: String, at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? { | |
guard elementKind == UICollectionElementKindSectionHeader else { return nil } | |
return headerAttributesCache.first { | |
$0.indexPath == indexPath | |
} | |
} | |
override func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool { | |
if scrollDirection == .vertical, let oldWidth = collectionView?.bounds.width { | |
return oldWidth != newBounds.width | |
} else if scrollDirection == .horizontal, let oldHeight = collectionView?.bounds.height { | |
return oldHeight != newBounds.height | |
} | |
return false | |
} | |
override func invalidateLayout() { | |
super.invalidateLayout() | |
itemAttributesCache = [] | |
headerAttributesCache = [] | |
contentWidth = 0 | |
contentHeight = 0 | |
} | |
// MARK: - Private | |
private func indexableScale(forItemAt indexPath: IndexPath) -> Int { | |
var itemScale = (delegate ?? self).scaleForItem(inCollectionView: collectionView!, withLayout: self, atIndexPath: indexPath) | |
if itemScale > fixedDivisionCount { | |
itemScale = fixedDivisionCount | |
} | |
return Int(itemScale - 1) // Using with indices, want 0-based | |
} | |
private func nextAvailableFrame(startingAt originalFrame: ItemFrame) -> (frame: ItemFrame, fitInOriginalFrame: Bool) { | |
var flexibleIndex = originalFrame.flexibleIndex, fixedIndex = originalFrame.fixedIndex | |
var newFrame = ItemFrame(originalFrame.section, flexibleIndex, fixedIndex, originalFrame.scale) | |
while !isSpaceAvailable(for: newFrame) { | |
fixedIndex += 1 | |
// Reached end of fixedIndex, restart on next flexibleIndex | |
if fixedIndex + originalFrame.scale >= intFixedDivisionCount { | |
fixedIndex = 0 | |
flexibleIndex += 1 | |
} | |
newFrame = ItemFrame(originalFrame.section, flexibleIndex, fixedIndex, originalFrame.scale) | |
} | |
// Fits iff we never had to walk the grid to find a position | |
return (newFrame, flexibleIndex == originalFrame.flexibleIndex && fixedIndex == originalFrame.fixedIndex) | |
} | |
/// Checks the grid from the origin to the origin + scale for occupied blocks | |
private func isSpaceAvailable(for frame: ItemFrame) -> Bool { | |
for flexibleIndex in frame.flexibleIndex ... frame.flexibleIndex + frame.scale { | |
// Ensure we won't go off the end of the array | |
while sectionedItemGrid[frame.section].count <= flexibleIndex { | |
sectionedItemGrid[frame.section].append(Array(repeating: false, count: intFixedDivisionCount)) | |
} | |
for fixedIndex in frame.fixedIndex ... frame.fixedIndex + frame.scale { | |
if fixedIndex >= intFixedDivisionCount || sectionedItemGrid[frame.section][flexibleIndex][fixedIndex] { | |
return false | |
} | |
} | |
} | |
return true | |
} | |
private func reserveItemGrid(frame: ItemFrame) { | |
for flexibleIndex in frame.flexibleIndex ... frame.flexibleIndex + frame.scale { | |
for fixedIndex in frame.fixedIndex ... frame.fixedIndex + frame.scale { | |
sectionedItemGrid[frame.section][flexibleIndex][fixedIndex] = true | |
} | |
} | |
} | |
private func layoutAttributes(for indexPath: IndexPath, at itemFrame: ItemFrame, with sectionOffset: CGFloat) -> UICollectionViewLayoutAttributes { | |
let layoutAttributes = UICollectionViewLayoutAttributes(forCellWith: indexPath) | |
let fixedIndexOffset = CGFloat(itemFrame.fixedIndex) * (itemSpacing + itemFixedDimension) | |
let longitudinalOffset = CGFloat(itemFrame.flexibleIndex) * (itemSpacing + itemFlexibleDimension) + sectionOffset | |
let itemScaledTransverseDimension = itemFixedDimension + (CGFloat(itemFrame.scale) * (itemSpacing + itemFixedDimension)) | |
let itemScaledLongitudinalDimension = itemFlexibleDimension + (CGFloat(itemFrame.scale) * (itemSpacing + itemFlexibleDimension)) | |
if scrollDirection == .vertical { | |
layoutAttributes.frame = CGRect(x: fixedIndexOffset, y: longitudinalOffset, width: itemScaledTransverseDimension, height: itemScaledLongitudinalDimension) | |
} else { | |
layoutAttributes.frame = CGRect(x: longitudinalOffset, y: fixedIndexOffset, width: itemScaledLongitudinalDimension, height: itemScaledTransverseDimension) | |
} | |
return layoutAttributes | |
} | |
} |
Hi can you show some usage code of this layout that sets the delegate.
I believe it is set by the consumer as it is never assigned in here.
let gridLayout = GridLayout()
gridLayout.fixedDivisionCount = 3 // Columns for .vertical, rows for .horizontal
collectionView.collectionViewLayout = gridLayout
I want a scale for height only. Is there any way to set it?
I have just 3 cells, cell widths are fixed (collection view width / 2).
Let's say my collection view height is 100, then my first two cells will be (50 * 50) but I want 3rd cell (50 * 100).
Please let me know how can I set it.
This gist is the accompanying code for this blog post: https://stablekernel.com/article/creating-a-custom-uicollectionviewlayout-in-swift/. Everything you need should be in the article. Sorry, I can't help more, I wrote that article 4 years ago.
Hi can you show some usage code of this layout that sets the delegate.
I believe it is set by the consumer as it is never assigned in here.