Building a Hexagonal Grid With the SwiftUI Layout Protocol

How to make a generalized SwiftUI container that shows subviews in a hexagonal grid.

Konstantin Semianov
Better Programming
Published in
5 min readDec 9, 2022

--

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

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

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
}

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

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

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

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)
}
}
}
}
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:

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.

HexGrid(cells) { cell in
AsyncImage(url: cell.url) { image in
image.resizable().aspectRatio(contentMode: .fill)
} placeholder: {
ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
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

Free

Distraction-free reading. No ads.

Organize your knowledge with lists and highlights.

Tell your story. Find your audience.

Membership

Read member-only stories

Support writers you read most

Earn money for your writing

Listen to audio narrations

Read offline with the Medium app

--

--

Konstantin Semianov
Konstantin Semianov

Written by Konstantin Semianov

CTO at Neatsy.ai, the world’s first app that detects orthopedic and podiatry health issues, utilizing AI & AR, with only an iPhone camera.

No responses yet

Write a response