Drawing a Hex Grid in SwiftUI

Last time1 we put together a custom Hexagon Shape in SwiftUI. Now let’s see what it would take to make a grid filling the screen with these hexagons. This is useful for building a honeycomb or hexagonal tessellation pattern that’s visually interesting and useful for things like showing grids of photos or for overlaying on a TTRPG map.

The shape we built is a regular hexagon and has a flat top, meaning that the shape takes up less height than width. This informs the layout, but you can shift things around for a “pointy-topped” hexagon shape grid as well.

In our shape, we used size to mean the radius of the hexagon at the points, which also means that it’s the length of each side. This means that the height is 2 × size and that width is √3 × size. As you can see from the grid screenshot, while hexagons touch top to bottom in columns, they are staggered by 1/2 of their width in rows, touching instead at their left and right points.

This means that given a viewport height and width, we can calculate how many columns and rows will be needed to fill the screen as follows:

let columns = Int(ceil((width - offset.x) / (size * 2.5)))
let rows = Int(ceil((height - offset.y) / size * sqrt(3) * 2))

Positioning each shape requires that we know its column and row, so the easiest way to encapsulate this is to have a view containing the Hexagon shape with parameters for those as well as the size. With those, we can calculate the x and y offsets to place the Hexagon shape view.

Unlike tiled squares, tiled hexagonal rows begin halfway down the height of the prior row. The y offset is simple to calculate with the row and size: row × √3 × size.

As each row in a hexagonal tiling is staggered, the x offset is slightly more complicated. For even rows, size can be used as a multiplier with column, while odd rows should use size × 2.5 (the value used above for determining number of columns).

XCode simulator of an iPhone 15 Pro in portrait orientation showing a grid of hexagons filling the screen. The background is white.

With that as the “start” offset, we can use size × 2 × 1.5 as the multiplier. The width of the shape is size × 2 and multiplying by 1.5 lets us account for the spacing of ½ of the width of each shape between shapes as they are spaced out. This can be simplified to size × 3, but it’s left expanded in the code below for clarity.

You may note that the divisors we used for determining numbers of columns and rows above are very nearly the same as the multipliers we’re using now to determine where to place each column and row. This is a convenient upshot of division and multiplication being complimentary. When we convert width into columns, we can then multiply the resulting column width by some column number to get the start point of the column. The same goes for rows.

func yOffset() -> CGFloat {
  return CGFloat(row) * size * sqrt(3) / 2
}

func xOffset() -> CGFloat {
  let start = (Int(row) % 2 == 1) ? (size * 2.5) : (size)
  return CGFloat(column) * size * 2 * 1.5 + start
}

Using what we’ve put together so far, we can iterate over the sum of columns and rows in a ForEach, use the quotient of that and the number of columns to find the row and the modulus of the same to find the column:

GeometryReader { geometry in
  let columns = Int(ceil(geometry.size.width / (size * 2.5)))
  let rows = Int(ceil(geometry.size.height / size * sqrt(3) * 2))
  ForEach(0..<(columns*rows), id: \.self) { idx in
    let column = idx % columns
    let row = idx / columns
    HexGridCell(column: column, row: row, size: size)
  }
}

HexGridCell renders a Hexagon with some view modifiers to place, size, and display itself:

Hexagon()
  .stroke(.black)
  .scaledToFill()
  .frame(width: size * 2, height: size * sqrt(3))
  .offset(x: xOffset(), y: yOffset())

The result is a beautiful honeycomb pattern of Hexagon shapes, useful for RPG maps, tiling images or photos, or any other hex grid you might want.

  1. Yes, going on two years ago now. This post has been sitting mostly written since ~Summer 2022 

Webmentions