Skip to content

Instantly share code, notes, and snippets.

@smswz
Last active July 3, 2023 15:51
Show Gist options
  • Star 19 You must be signed in to star a gist
  • Fork 8 You must be signed in to fork a gist
  • Save smswz/393b2d6237b7837234015805c600ada2 to your computer and use it in GitHub Desktop.
Save smswz/393b2d6237b7837234015805c600ada2 to your computer and use it in GitHub Desktop.
A simple custom grid UICollectionViewLayout
// 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
}
}
@omatrot
Copy link

omatrot commented Mar 17, 2020

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.

@sonam94
Copy link

sonam94 commented Sep 17, 2020

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

@MiteshiOS
Copy link

MiteshiOS commented Mar 11, 2021

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.

@smswz
Copy link
Author

smswz commented Mar 12, 2021

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment