Customize a view layout by changing the size of cells in the flow or implementing a mosaic style.
To lay out UICollectionView cells in a simple grid, you can use UICollectionViewFlowLayout
directly. For more flexibility, you can subclass UICollectionViewLayout
to create advanced layouts.
This sample app demonstrates two custom layout subclasses:
-
ColumnFlowLayout
— AUICollectionViewFlowLayout
subclass that arranges cells in a list format for narrow screens, or as a grid for wider screens. See "For a Simple Grid, Size Cells Dynamically," below. -
MosaicLayout
— AUICollectionViewLayout
subclass that lays out cells in a mosaic-style, nonconforming grid. See "For a Complex Grid, Define Cell Sizes Explicitly," below.
The app opens to the Friends view controller, which uses a column flow layout to display a list of people. Tapping any cell takes you to the Feed view controller, which uses a mosaic layout to display photos from the user's photo library.
Tapping the cloud icon to the right of the navigation bar demonstrates batched animations for inserting, deleting, moving, and reloading items in the collection view. For more information, see "Perform Batch Updates," below. Using pull-to-refresh on the collection view resets the data.
ColumnFlowLayout
is a subclass of UICollectionViewFlowLayout
that uses the size of the collection view to determine the width of its cells. If only one cell will fit comfortably horizontally, the cells are arranged to occupy the entire width of the collection view. Otherwise, multiple columns of cells are displayed with a fixed width.
In practice, on iPhone devices in portrait mode, ColumnFlowLayout
displays a single vertical column of cells. In landscape mode, or on an iPad, it displays a grid layout.
Use the prepare
function to compute the available screen width of the device and set the itemSize
property accordingly.
override func prepare() {
super.prepare()
guard let collectionView = collectionView else { return }
let availableWidth = collectionView.bounds.inset(by: collectionView.layoutMargins).width
let maxNumColumns = Int(availableWidth / minColumnWidth)
let cellWidth = (availableWidth / CGFloat(maxNumColumns)).rounded(.down)
self.itemSize = CGSize(width: cellWidth, height: cellHeight)
self.sectionInset = UIEdgeInsets(top: self.minimumInteritemSpacing, left: 0.0, bottom: 0.0, right: 0.0)
self.sectionInsetReference = .fromSafeArea
}
View in Source
If you need more customization than is possible with a subclass of UICollectionViewFlowLayout
, subclass UICollectionViewLayout
instead.
MosaicLayout
is a UICollectionViewLayout
subclass that displays an arbitrary number of cells with differing sizes and aspect ratios. It's used by FeedViewController
to display images from the user's photo library. Cells are organized into rows in one of four styles, from a single cell to multiple cells in varying layouts.
Calculate Cell Dimensions
The prepare
method is called whenever a layout is invalidated. Override this method to calculate the position and size of every cell, as well as the total dimensions for the entire layout.
override func prepare() {
super.prepare()
guard let collectionView = collectionView else { return }
// Reset cached information.
cachedAttributes.removeAll()
contentBounds = CGRect(origin: .zero, size: collectionView.bounds.size)
// For every item in the collection view:
// - Prepare the attributes.
// - Store attributes in the cachedAttributes array.
// - Combine contentBounds with attributes.frame.
let count = collectionView.numberOfItems(inSection: 0)
var currentIndex = 0
var segment: MosaicSegmentStyle = .fullWidth
var lastFrame: CGRect = .zero
let cvWidth = collectionView.bounds.size.width
while currentIndex < count {
let segmentFrame = CGRect(x: 0, y: lastFrame.maxY + 1.0, width: cvWidth, height: 200.0)
var segmentRects = [CGRect]()
switch segment {
case .fullWidth:
segmentRects = [segmentFrame]
case .fiftyFifty:
let horizontalSlices = segmentFrame.dividedIntegral(fraction: 0.5, from: .minXEdge)
segmentRects = [horizontalSlices.first, horizontalSlices.second]
case .twoThirdsOneThird:
let horizontalSlices = segmentFrame.dividedIntegral(fraction: (2.0 / 3.0), from: .minXEdge)
let verticalSlices = horizontalSlices.second.dividedIntegral(fraction: 0.5, from: .minYEdge)
segmentRects = [horizontalSlices.first, verticalSlices.first, verticalSlices.second]
case .oneThirdTwoThirds:
let horizontalSlices = segmentFrame.dividedIntegral(fraction: (1.0 / 3.0), from: .minXEdge)
let verticalSlices = horizontalSlices.first.dividedIntegral(fraction: 0.5, from: .minYEdge)
segmentRects = [verticalSlices.first, verticalSlices.second, horizontalSlices.second]
}
// Create and cache layout attributes for calculated frames.
for rect in segmentRects {
let attributes = UICollectionViewLayoutAttributes(forCellWith: IndexPath(item: currentIndex, section: 0))
attributes.frame = rect
cachedAttributes.append(attributes)
contentBounds = contentBounds.union(lastFrame)
currentIndex += 1
lastFrame = rect
}
// Determine the next segment style.
switch count - currentIndex {
case 1:
segment = .fullWidth
case 2:
segment = .fiftyFifty
default:
switch segment {
case .fullWidth:
segment = .fiftyFifty
case .fiftyFifty:
segment = .twoThirdsOneThird
case .twoThirdsOneThird:
segment = .oneThirdTwoThirds
case .oneThirdTwoThirds:
segment = .fiftyFifty
}
}
}
}
View in Source
Provide the Content Size
Override the collectionViewContentSize
property, providing a size for the collection view.
override var collectionViewContentSize: CGSize {
return contentBounds.size
}
View in Source
Define the Layout Attributes
Override layoutAttributesForElements(in:)
, defining the layout attributes for a geometric region. The collection view calls this function periodically to display items, which is known as querying by geometric region.
override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
var attributesArray = [UICollectionViewLayoutAttributes]()
// Find any cell that sits within the query rect.
guard let lastIndex = cachedAttributes.indices.last,
let firstMatchIndex = binSearch(rect, start: 0, end: lastIndex) else { return attributesArray }
// Starting from the match, loop up and down through the array until all the attributes
// have been added within the query rect.
for attributes in cachedAttributes[..<firstMatchIndex].reversed() {
guard attributes.frame.maxY >= rect.minY else { break }
attributesArray.append(attributes)
}
for attributes in cachedAttributes[firstMatchIndex...] {
guard attributes.frame.minY <= rect.maxY else { break }
attributesArray.append(attributes)
}
return attributesArray
}
View in Source
Also provide the layout attributes for a specific item by implementing layoutAttributesForItem(at:)
. The collection view calls this function periodically to display one particular item, which is known as querying by index path.
override func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
return cachedAttributes[indexPath.item]
}
View in Source
Because these functions are called often, they can affect the performance of your app. To make them as efficient as possible, follow the example code as closely as you can.
Handle Bounds Changes
The shouldInvalidateLayout(forBoundsChange:)
function is called for every bounds change from the collection view, or whenever its size or origin changes. This function is also called frequently during scrolling. The default implementation returns false
, or, if the size and origin change, it returns true
.
override func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool {
guard let collectionView = collectionView else { return false }
return !newBounds.size.equalTo(collectionView.bounds.size)
}
View in Source
For optimum performance, this sample performs a binary search inside layoutAttributesForElements(in:)
instead of a linear search of the attributes it needs for each element in a given bounds area.
Tapping the top-right button in the navigation bar triggers the collection view to perform a batch update of multiple animated operations (insert, delete, move, and reload) of its collection view cells all at the same time.
Within a call to performBatchUpdates()
, all insert, delete, move, and reload operations are animated simultaneously. In this sample, batch updates are made by processing an array of PersonUpdate
objects, each of which encapsulates one update:
insert
with aPerson
object and insertion index.delete
with an index.move
from one index to another.reload
with an index.
First, the reload
operations are performed without animation because no cell movement is involved:
// Perform any cell reloads without animation because there is no movement.
UIView.performWithoutAnimation {
collectionView.performBatchUpdates({
for update in remoteUpdates {
if case let .reload(index) = update {
people[index].isUpdated = true
collectionView.reloadItems(at: [IndexPath(item: index, section: 0)])
}
}
})
}
View in Source
Next, the remaining operations are animated:
// Animate all other update types together.
collectionView.performBatchUpdates({
var deletes = [Int]()
var inserts = [(person:Person, index:Int)]()
for update in remoteUpdates {
switch update {
case let .delete(index):
collectionView.deleteItems(at: [IndexPath(item: index, section: 0)])
deletes.append(index)
case let .insert(person, index):
collectionView.insertItems(at: [IndexPath(item: index, section: 0)])
inserts.append((person, index))
case let .move(fromIndex, toIndex):
// Updates that move a person are split into an addition and a deletion.
collectionView.moveItem(at: IndexPath(item: fromIndex, section: 0),
to: IndexPath(item: toIndex, section: 0))
deletes.append(fromIndex)
inserts.append((people[fromIndex], toIndex))
default: break
}
}
// Apply deletions in descending order.
for deletedIndex in deletes.sorted().reversed() {
people.remove(at: deletedIndex)
}
// Apply insertions in ascending order.
let sortedInserts = inserts.sorted(by: { (personA, personB) -> Bool in
return personA.index <= personB.index
})
for insertion in sortedInserts {
people.insert(insertion.person, at: insertion.index)
}
// The update button is enabled only if the list still has people in it.
navigationItem.rightBarButtonItem?.isEnabled = !people.isEmpty
})
View in Source