Flow Grid in SwiftUI
Want to know how to make this sweet looking grid in SwiftUI in under 5 minutes? Keep reading ;)
Tl;Dr
Use FlowGrid package. Or! Or! Keep reading to see how it was made and maybe you'll find some of it useful for whatever you're working on.
How It Started
I've been playing with SwiftUI in my spare time for a while. After working with React for 5 years, SwiftUI feels very exciting because it's basically React, but will all the missing stuff added in. And native!
So I wanted to have an image grid in SwiftUI. As of this writing, there are a few options for achieving grids:
- Use LazyVGrid
- Use a third party grid package
- Use a wrapped NSCollectionView
- ???
Let's go over these
LazyVGrid
Introduced in SwiftUI 2.0, LazyVGrid and LazyHGrid are "Apple Certified" ways to make a grid in SwiftUI. And they're great for general purpose grids where your layout is at least somewhat predictable.
Unfortunately for dynamic data, such as photos with variable dimensions, this method doesn't work.
Third Party Grids
There are a number of SwiftUI grid packages out there, the most popular being Exyte Grid. It offers quite a bit more control over the layout than LazyVGrid/LazyHGrid, but unfortunately it suffers a similar problem. All the customizability it provides requires you to know the size of your grid tracks ahead of time. We're trying to achieve something that's fully dynamically capable.
Wrapped NSCollectionView
Ew.
So, let's explore the "???"
So I needed another option. After some investigating, by which I mean googling dozens of combinations of "swiftui", "flow" and "grid", I came up on two resources that made it click for me. This Flexible Layouts in SwiftUI article and this CollectionView package by The Noun Project which has the exact implementation we need, but isn't SwiftUI. Luckily, we can combine the approaches! So let's get into it.
Getting Into It
First things first, we need to create a wrapper view. We need it to measure the dimensions the grid will operate within.
// FlowGrid.swift
import SwiftUI
public struct FlowGrid<Data: Collection, Content: View>: View where Data.Element: Hashable {
private let items: Data
private let rowHeight: CGFloat // Baseline row height
private let spacing: CGFloat // Spacing between cells
private let disableFill: Bool // Whether or not to fill horizontal space
private let content: (Data.Element) -> Content
@State private var viewportWidth: CGFloat = 0
public init(items: Data, rowHeight: CGFloat, spacing: CGFloat, disableFill: Bool, content: @escaping (Data.Element) -> Content) {
self.items = items
self.rowHeight = rowHeight
self.spacing = spacing
self.content = content
self.disableFill = disableFill
}
public var body: some View {
ZStack(alignment: Alignment(horizontal: .leading, vertical: .center)) {
// Read and store the viewport width before we get started
Color.clear
.frame(height: 1)
.readSize { size in
viewportWidth = size.width
}
// This view will be our actual implementation
FlowGridView(
viewportWidth: viewportWidth,
items: items,
rowHeight: rowHeight,
spacing: spacing,
disableFill: disableFill,
content: content
)
}
}
}
Size Reader Modifier
Now, you might be thinking "Hey, Max, there isn't a readSize modifier 🤔
And you're right. So let's write it!
// View+readSize.swift
import SwiftUI
extension View {
// The scale factor, once we know it, needs to be accounted for.
// Otherwise we will confuse the size reader when we apply the size adjustment
func readSize(_ scaleFactor: CGFloat = 1, onChange: @escaping (CGSize) -> Void) -> some View {
background(
GeometryReader { geometryProxy in
Color.clear
.preference(key: SizePreferenceKey.self, value: CGSize(width: geometryProxy.size.width / scaleFactor, height: geometryProxy.size.height / scaleFactor))
}
)
.onPreferenceChange(SizePreferenceKey.self, perform: onChange)
}
}
private struct SizePreferenceKey: PreferenceKey {
static var defaultValue: CGSize = .zero
static func reduce(value: inout CGSize, nextValue: () -> CGSize) {}
}
There's one important thing to note here that I wasn't able to solve, which is that, the grid will expect it's children to stay still. If there's a view that appears on hover that widens the frame of a cell, SwiftUI will be confused trying to re-measure and re-adjust and if the conditions are just right, your app will hang.
So make sure whatever views you render inside the grid all behave such that the bounds stay what they are on first render.
The Actual Grid
So here's what we need to do to make our grid happen.
- Create a cell for an item of our data with a set row height
- Remember it's resulting width
- Repeat this until there isn't enough width for the next element
- Move on to the next row
- Scale the previous row up to fill the leftover width
Sounds simple enough, right? Let's see
// FlowGridView.swift
import SwiftUI
// We don't know the scale factor before the row is built, it defaults to `1`
private struct FlowRow<T>: Hashable where T: Hashable {
var items: [T]
var scaleFactor: CGFloat = 1
}
// Main grid view
struct FlowGridView<Data: Collection, Content: View>: View where Data.Element: Hashable {
let viewportWidth: CGFloat
let items: Data
let rowHeight: CGFloat
let spacing: CGFloat
let disableFill: Bool
let content: (Data.Element) -> Content
// Store sizes of the items as we render them, it's going to be important later
@State var itemSizes: [Data.Element: CGSize] = [:]
var body : some View {
let rows = calculateRows()
// Sorry, <11.0 users, scroll performance is really shit otherwise :/
LazyVStack(alignment: .leading, spacing: spacing) {
ForEach(rows, id: \.self) { row in
HStack(spacing: spacing) {
ForEach(row.items, id: \.self) { element in
content(element)
.frame(height: disableFill ? rowHeight : rowHeight * row.scaleFactor)
.fixedSize()
.readSize(row.scaleFactor) { size in // Size reader needs to be aware of the scale factor
itemSizes[element] = size
}
}
}
}
}
}
}
Now you might be thinking, "Hey, Max, there isn't a calculateRows function.
And you're right. So let's write it. This is where all of the magic happens.
// FlowGridView.swift
func calculateRows() -> [FlowRow<Data.Element>] {
var rows: [FlowRow<Data.Element>] = [.init(items: [])] // Start with a blank array of grid items
var currentRow = 0 // Keep track of the row we're currently at
var remainingWidth = viewportWidth
for item in items {
// Get the element size we measured in the parent component
let elementSize = itemSizes[item, default: CGSize(width: viewportWidth, height: 1)]
// If we have space in the row to fit the width of the item, dew it
if remainingWidth - elementSize.width >= 0 {
rows[currentRow].items.append(item)
} else {
// Otherwise, we're done with the row, so let's do some math
// First, let's calculate the total width of all items in the row so far
let totalWidth = rows[currentRow].items.reduce(0) {
$0 + itemSizes[$1, default: CGSize(width: viewportWidth, height: 1)].width
}
// Now calculate the vertical scale factor, that will ensure that leftover horizontal space is filled completely
// and write it to the row
rows[currentRow].scaleFactor = viewportWidth / totalWidth
// Move on to the next row
currentRow = currentRow + 1
rows.append(.init(items: [item], scaleFactor: 1))
remainingWidth = viewportWidth // Remember to reset the remaining width on a new row
}
// Update remaining width
remainingWidth = remainingWidth - elementSize.width
}
return rows
}
Aaaaand, we're done!
The grid will nicely adjust to different scales, window resizing, etc. So long as the cell dimensions don't change, which they really shouldn't.
Peep this:
That's it!
Hope at least some of this was useful. Be sure to check out the FlowGrid package which implements everything that's written here + convenient initializers and hit me up on Twitter if you have any questions.