Building a hexagonal grid with the SwiftUI Layout protocol

ksemianov

Konstantin Semianov

Posted on December 9, 2022

Building a hexagonal grid with the SwiftUI Layout protocol

HexGrid

The component we are about to make is available as a Swift Package.

Intro

SwiftUI is really good at building a hierarchy of rectangular frames. With the recent addition of Grid it became even better. However, today we want to build a crazy hexagonal layout. Of course, there is no dedicated layout type for this. So we build our own with the Layout protocol!

Drawing one hexagon

Let's first define a shape for our grid cell. For this, we need to implement func path(in rect: CGRect) -> Path to satisfy Shape protocol requirement. We basically need to find the largest size of a hexagon that fits inside the rect, compute its vertices and draw lines between them. Here is the complete code to do a flat-top hexagon.

struct Hexagon: Shape {
    static let aspectRatio: CGFloat = 2 / sqrt(3)

    func path(in rect: CGRect) -> Path {
        var path = Path()

        let center = CGPoint(x: rect.midX, y: rect.midY)
        let width = min(rect.width, rect.height * Self.aspectRatio)
        let size = width / 2
        let corners = (0..<6)
            .map {
                let angle = -CGFloat.pi / 3 * CGFloat($0)
                let dx = size * cos(angle)
                let dy = size * sin(angle)

                return CGPoint(x: center.x + dx, y: center.y + dy)
            }

        path.move(to: corners[0])
        corners[1..<6].forEach { point in
            path.addLine(to: point)
        }

        path.closeSubpath()

        return path
    }
}
Enter fullscreen mode Exit fullscreen mode

Hexagon

Coordinates

We'll need to place our hexagons somewhere. And for that, we need a coordinate system. The easiest to understand is the offset coordinate system, but other coordinates could be used with the same success (e.g. axial coordinates). We'll take an odd-q variation of the offset coordinates. It basically just defines cells as pairs of rows and columns. And each odd column is shifted by 1/2 down. We will need to provide these coordinates to the layout system and it's done by creating a key conforming to LayoutValueKey.

struct OffsetCoordinate: Hashable {
    var row: Int
    var col: Int
}

protocol OffsetCoordinateProviding {
    var offsetCoordinate: OffsetCoordinate { get }
}

struct OffsetCoordinateLayoutValueKey: LayoutValueKey {
    static let defaultValue: OffsetCoordinate? = nil
}
Enter fullscreen mode Exit fullscreen mode

Layout protocol

The protocol has 2 requirements:

  • sizeThatFits controls how much space the view needs
  • placeSubviews controls the placement of subviews within the available space

And optionally:

  • makeCache to avoid extra computations

Caching

Let's define our cached data for the layout protocol. First, we'll need to know the top left coordinates of the grid to correctly calculate offsets from the bounds' top left corner. Then we'll need to know how big is the grid in terms of full rows and columns of cells.

struct CacheData {
    let offsetX: Int
    let offsetY: Int
    let width: CGFloat
    let height: CGFloat
}

func makeCache(subviews: Subviews) -> CacheData? {
    let coordinates = subviews.compactMap { $0[OffsetCoordinateLayoutValueKey.self] }

    if coordinates.isEmpty { return nil }

    let offsetX = coordinates.map { $0.col }.min()!
    let offsetY = coordinates.map { $0.row }.min()!

    let coordinatesX = coordinates.map { CGFloat($0.col) }
    let minX: CGFloat = coordinatesX.min()!
    let maxX: CGFloat = coordinatesX.max()!
    let width = maxX - minX + 4 / 3

    let coordinatesY = coordinates.map { CGFloat($0.row) + 1 / 2 * CGFloat($0.col & 1) }
    let minY: CGFloat = coordinatesY.min()!
    let maxY: CGFloat = coordinatesY.max()!
    let height = maxY - minY + 1

    return CacheData(offsetX: offsetX, offsetY: offsetY, width: width, height: height)
}
Enter fullscreen mode Exit fullscreen mode

sizeThatFits

This one is pretty straightforward. We just need to take the width of the hex cell such that it fits inside the proposal. And then multiply it by the corresponding width and height of the grid in terms of cell width.

func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout CacheData?) -> CGSize {
    guard let cache else { return .zero }

    let size = proposal.replacingUnspecifiedDimensions()
    let step = min(size.width / cache.width, size.height / cache.height / Hexagon.aspectRatio)

    return CGSize(width: step * cache.width, height: step * cache.height * Hexagon.aspectRatio)
}
Enter fullscreen mode Exit fullscreen mode

placeSubviews

Here we compute the step between subsequent hexagons. And then placing each hexagon at its corresponding place with the correct size.

func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout CacheData?) {
    guard let cache else { return }

    let size = proposal.replacingUnspecifiedDimensions()
    let step = min(size.width / cache.width, size.height / cache.height / Hexagon.aspectRatio)
    let width = step * 4 / 3
    let proposal = ProposedViewSize(width: width, height: width / Hexagon.aspectRatio)
    let x = width / 2 + bounds.minX
    let y = width / Hexagon.aspectRatio / 2 + bounds.minY

    for subview in subviews {
        guard let coord = subview[OffsetCoordinateLayoutValueKey.self] else { continue }

        let dx: CGFloat = step * CGFloat(coord.col - cache.offsetX)
        let dy: CGFloat = step * Hexagon.aspectRatio * (CGFloat(coord.row - cache.offsetY) + 1 / 2 * CGFloat(coord.col & 1))
        let point = CGPoint(x: x + dx, y: y + dy)

        subview.place(at: point, anchor: .center, proposal: proposal)
    }
}
Enter fullscreen mode Exit fullscreen mode

HexGrid

At this point, the HexLayout is already usable. However, the rule that all subviews should have a coordinate is not enforced. So it's better to do a thin wrapper that will provide this compile-time guarantee to component consumers. While at it, we'll clip the subviews with the shape of the hexagon to make the call site even cleaner.

struct HexGrid<Data, ID, Content>: View where Data: RandomAccessCollection, Data.Element: OffsetCoordinateProviding, ID: Hashable, Content: View {
    let data: Data
    let id: KeyPath<Data.Element, ID>
    let content: (Data.Element) -> Content

    init(_ data: Data,
         id: KeyPath<Data.Element, ID>,
         @ViewBuilder content: @escaping (Data.Element) -> Content) {
        self.data = data
        self.id = id
        self.content = content
    }

    var body: some View {
        HexLayout {
            ForEach(data, id: id) { element in
                content(element)
                    .clipShape(Hexagon())
                    .layoutValue(key: OffsetCoordinateLayoutValueKey.self,
                                 value: element.offsetCoordinate)
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode
extension HexGrid where ID == Data.Element.ID, Data.Element: Identifiable {
    init(_ data: Data,
         @ViewBuilder content: @escaping (Data.Element) -> Content) {
        self.init(data, id: \.id, content: content)
    }
}
Enter fullscreen mode Exit fullscreen mode

Usage

Now we can finally define our data model and use the ready component to get the image from the beginning of the article:

struct HexCell: Identifiable, OffsetCoordinateProviding {
    var id: Int { offsetCoordinate.hashValue }
    var offsetCoordinate: OffsetCoordinate
    var colorName: String
}

let cells: [HexCell] = [
    .init(offsetCoordinate: .init(row: 0, col: 0), colorName: "color1"),
    .init(offsetCoordinate: .init(row: 0, col: 1), colorName: "color2"),
    .init(offsetCoordinate: .init(row: 0, col: 2), colorName: "color3"),
    .init(offsetCoordinate: .init(row: 1, col: 0), colorName: "color4"),
    .init(offsetCoordinate: .init(row: 1, col: 1), colorName: "color5")
]

HexGrid(cells) { cell in
    Color(cell.colorName)
}
Enter fullscreen mode Exit fullscreen mode

But you can put images or literally any view into subviews! Just be aware that the layout assumes subviews fill the contents of the hexagon cell.

HexGrid(cells) { cell in
    AsyncImage(url: cell.url) { image in
        image.resizable().aspectRatio(contentMode: .fill)
    } placeholder: {
        ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity)
    }
}
Enter fullscreen mode Exit fullscreen mode

Based on public domain photos downloaded from PIXNIO.

Final thoughts

We've learned how to provide values to LayoutSubview proxy and build a fun non-trivial layout.

For more information on hexagonal grids see this fantastic guide

See the full code at https://github.com/ksemianov/HexGrid

💖 💪 🙅 🚩
ksemianov
Konstantin Semianov

Posted on December 9, 2022

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related