The UIKit combination of UICollectionView and the UICollectionViewFlowLayout gives a lot of flexibility and control to build grid-like flow layouts. How do we do that with SwiftUI?
Self-Sizing Flow Layouts
The UICollectionViewFlowLayout
documentation describes a flow layout as laying out a collection of items using a fixed distance in one direction and a scrollable distance in the other. For example, a vertically scrolling layout where cells flow from one row to the next, each row containing as many cells as fit in the available width.
One such example I find useful is a card layout where the cards are of equal size, but sized to fit the largest content in the cells (within some minimum/maximum constraints).
For example, this card style layout on an 11″ iPad, shown in landscape. At default text sizes this produces a grid with seven columns and five rows. As the text size increases the card size increases and the layout adjusts. At the largest accessibility sizes this produces a grid with three columns and eleven rows.
The flow layout also adjusts for the size of the container so on an iPhone the grid reduces to two columns, scrolling vertically:
Let’s look at how we could build a similar layout with SwiftUI.
SwiftUI Layout Protocol (iOS 16)
Apple introduced the Layout
protocol back in iOS 16. It has two required methods:
// Return the size of the composite view, given a
// proposed size and the view's subviews.
sizeThatFits(proposal:subviews:cache:)
// Assigns positions to each of the layout's subviews.
placeSubviews(in:proposal:subviews:cache:)
The first method calculates the overall size of the layout. It roughly compares to the UICollectionViewDelegateFlowLayout
method to calculate the size of each item:
// Asks the delegate for the size of the specified item's cell.
collectionView(_:layout:sizeForItemAt:)
To get started, let’s create a conforming type with some view metrics for padding, margins, and width constraints:
struct CardLayout: Layout {
private enum ViewMetrics {
static let padding: CGFloat = 8
static let margin: CGFloat = 8
static let minimumWidth: CGFloat = 150
static let maximumWidth: CGFloat = 400
static let aspectRatio: CGFloat = 1.5
}
}
The Ideal Size
The sizeThatFits
method gets a proposed view size from the parent view, a collection of proxies for the subviews to layout, and an optional cache for calculated data. I’m going to ignore the cache for now, and return early with zero size if we have no subviews to layout:
func sizeThatFits(
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout Void) -> CGSize {
guard !subviews.isEmpty else {
return .zero
}
// calculate size...
}
The proposal from the parent view is of type ProposedViewSize
, a struct with an optional width and height. Apple’s documentation mentions that the parent view can call this method more than once with different proposals:
- A proposal of
.zero
size for the layout’s minimum size. - A proposal of
.infinity
size for the layout’s maximum size. - A proposal of
.unspecified
for the layout’s ideal size.
I’m always going to ask my subviews for their ideal size. Since I need to do that more than once I’ve created a utility method:
private func fittingSize(subviews: Subviews) -> CGSize {
// Return size of subviews
}
We start my mapping over our collection of subviews asking each for their ideal size:
let sizes = subviews.map { $0.sizeThatFits(.unspecified) }
Then I’ll use the maximum width and height from the ideal sizes of my subviews:
var fittingSize: CGSize = sizes.reduce(.zero) {
currentMax,
size in
CGSize(
width: max(currentMax.width, size.width),
height: max(currentMax.height, size.height)
)
}
Now I’ll apply some constraints on my card size. I’ve chosen suitable view metrics for my expected content but my card text can still be truncated. First enforcing the aspect ratio:
if fittingSize.width / fittingSize.height > ViewMetrics.aspectRatio {
fittingSize.height = fittingSize.width / ViewMetrics.aspectRatio
} else {
fittingSize.width = fittingSize.height * ViewMetrics.aspectRatio
}
Then the minimum width, maintaining the aspect ratio:
if fittingSize.width < ViewMetrics.minimumWidth {
fittingSize.width = ViewMetrics.minimumWidth
fittingSize.height = fittingSize.width / ViewMetrics.aspectRatio
}
Finally, I limit the card to a maximum width:
if fittingSize.width > ViewMetrics.maximumWidth {
fittingSize.width = ViewMetrics.maximumWidth
fittingSize.height = fittingSize.width / ViewMetrics.aspectRatio
}
We now have our “ideal” card size:
There’s one more constraint I want to apply to my cards which is enforce that they are never wider than any proposed width from the container view. If the parent has proposed a width, we limit our card size so that it fits within the margins, maintaining the aspect ratio:
private func maxSize(
fittingSize: CGSize,
containerProposal: ProposedViewSize) -> CGSize {
guard let containerWidth = containerProposal.width else {
return fittingSize
}
let maxWidth = containerWidth - 2 * ViewMetrics.margin
if fittingSize.width > maxWidth {
return CGSize(
width: maxWidth,
height: maxWidth / ViewMetrics.aspectRatio)
}
return fittingSize
}
Now we have the size of the cards we can calculate how many columns we need from the card width, desired padding and margins:
private func cardsInRow(
cardSize: CGSize,
proposal: ProposedViewSize) -> Int {
let bounds = proposal.replacingUnspecifiedDimensions()
let cardsInRow = Int(
(bounds.width + ViewMetrics.padding - 2 * ViewMetrics.margin) /
(cardSize.width + ViewMetrics.padding)
)
return cardsInRow
}
Note that to convert any optional height or width in the proposed size to a default value (10) using replacingUnspecifiedDimensions()
.
Then to find out how many rows we need:
private func cardsInColumn(
cardsInRow: Int,
totalCards: Int) -> Int {
Int(ceil(Double(totalCards) / Double(cardsInRow)))
}
With that work done we can complete our size that fits method, calculating the overall container size:
func sizeThatFits(
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout Void
) -> CGSize {
guard !subviews.isEmpty else {
return .zero
}
let fittingSize = fittingSize(subviews: subviews)
let maxSize = maxSize(
fittingSize: fittingSize,
containerProposal: proposal)
let cardsInRow: Int = cardsInRow(
cardSize: maxSize,
proposal: proposal)
let cardsInColumn = cardsInColumn(
cardsInRow: cardsInRow,
totalCards: subviews.count)
let width = 2 * ViewMetrics.margin +
CGFloat(cardsInRow) *
(maxSize.width + ViewMetrics.padding) - ViewMetrics.padding
let height = 2 * ViewMetrics.margin +
CGFloat(cardsInColumn) *
(maxSize.height + ViewMetrics.padding) -
ViewMetrics.padding
return CGSize(width: width, height: height)
}
Placing Views
The second required Layout
method positions each of the subviews in the bounds of the container:
func placeSubviews(
in bounds: CGRect,
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout Void) {
guard !subviews.isEmpty else { return }
// Position subviews
}
The bounds rectangle matches a size we returned from the sizeThatFits
method. We need to recalculate our fitting size and again check our card size fits in the container bounds:
let fittingSize = fittingSize(subviews: subviews)
let boundsProposal = ProposedViewSize(
width: bounds.width,
height: bounds.height)
let maxSize = maxSize(
fittingSize: fittingSize,
containerProposal: boundsProposal)
We inset our starting position to allow for the margin:
var nextX = bounds.minX + ViewMetrics.margin
var nextY = bounds.minY + ViewMetrics.margin
Now we iterate over the subviews calling the place(at:anchor:proposal:)
method to tell each subview where to position itself. We propose our ideal size to each card:
let placementProposal = ProposedViewSize(
width: maxSize.width,
height: maxSize.height)
for index in subviews.indices {
subviews[index].place(
at: CGPoint(x: nextX, y: nextY),
anchor: .topLeading,
proposal: placementProposal)
nextX += maxSize.width + ViewMetrics.padding
if nextX + maxSize.width + ViewMetrics.margin > bounds.maxX {
nextX = bounds.minX + ViewMetrics.margin
nextY += maxSize.height + ViewMetrics.padding
}
}
}
Note that I’m placing each subview at the top-leading anchor. I’m using a fixed padding between the views. You can ask each subview for its preferred spacing:
let spacing = subviews[index].spacing.distance(
to: subviews[index + 1].spacing,
along: .horizontal)
Caching
Apple recommends implementing caching only if profiling shows that it improves performance. For the small datasets I’m using I haven’t seen any need but you can implement the makeCache
method. For example, to cache the result of my cards fitting size:
struct CacheData {
let fittingSize: CGSize
}
func makeCache(subviews: Subviews) -> CacheData {
let fittingSize = fittingSize(subviews: subviews)
return CacheData(fittingSize: fittingSize)
}
There’s also an updateCache method that SwiftUI calls anytime the layout container or subviews change. The default implementation calls the makeCache method to recreate the cache.
Don’t forget to change the type of the cache parameter to both layout methods:
func sizeThatFits(
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout CacheData
) -> CGSize { ... }
func placeSubviews(
in bounds: CGRect,
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout CacheData
) { ... }
Finally, I can replace the call to fittingSize in each method to use the cached value:
// let fittingSize = fittingSize(subviews: subviews)
let fittingSize = cache.fittingSize
Using the Layout
To use my card layout I’ll place it in a vertical scroll view and pass it a collection of CardView views:
struct ContentView: View {
var body: some View {
ScrollView {
CardLayout {
ForEach(Cards.all) { card in
CardView(card: card)
}
}
}
}
}
Apple added some improvements to iOS 18 to build a custom container view but that’s for another time.