Max's Coding Blog

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.

  1. Create a cell for an item of our data with a set row height
  2. Remember it's resulting width
  3. Repeat this until there isn't enough width for the next element
  4. Move on to the next row
  5. 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:

Small CellsSmall Cells
Huge CellsHuge Cells

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.