Building a hexagonal grid with the SwiftUI Layout protocol
Tags: swiftui, tutorial, swift, layout
Published: 2022-12-09 07:20:55

![HexGrid](https://user-images.githubusercontent.com/18328341/206620576-c51158ac-8f41-4dc5-83bb-6260b44c2255.jpg)

The component we are about to make is available as a [Swift Package](https://github.com/ksemianov/HexGrid).

## 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.

``` swift
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
    }
}
```

![Hexagon](https://ksemianov.github.io/images/hexgrid/hexagon.jpg)


## 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`.

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

protocol OffsetCoordinateProviding {
    var offsetCoordinate: OffsetCoordinate { get }
}

struct OffsetCoordinateLayoutValueKey: LayoutValueKey {
    static let defaultValue: OffsetCoordinate? = nil
}
```

## 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.

``` swift
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)
}
```

## `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.

``` swift
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)
}
```

## `placeSubviews`

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

``` swift
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)
    }
}
```

## 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.

``` swift
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)
            }
        }
    }
}
```

``` swift
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)
    }
}
```

## Usage

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

``` swift
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)
}
```

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.

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

<img src="https://ksemianov.github.io/images/hexgrid/gallery.jpg">
<figcaption>
Based on public domain photos downloaded from <a href="https://pixnio.com">PIXNIO</a>.
</figcaption>
</figure>

## 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](https://www.redblobgames.com/grids/hexagons/)

See the full code at [https://github.com/ksemianov/HexGrid](https://github.com/ksemianov/HexGrid)