# 10. Recreating a Real-World Animation Written by Irina Galata

Building a component based on an existing UI solution differs from implementing something from scratch following your idea or a designer’s prototype. The only thing you have at hand is an hours-long-polished, brought-to-perfection version of somebody’s vision of functionality. You can’t exactly see the steps they’ve taken or the iterations they’ve needed to get the result.

For example, take a look at Apple’s Honeycomb grid, the app launcher component on the Apple Watch:

The view offers an engaging and fun way of navigation while efficiently utilizing limited screen space on wearable devices. The concept can be helpful in various apps where a user is offered several options.

In this chapter, you’ll recreate it to help users pick their topics of interest when registering on an online social platform:

Note: The calculations for drawing the grid would not be possible without Amit Patel’s excellent work in his guide on hexagonal grids.

This time, you’ll start entirely from scratch, so don’t hesitate to create a new SwiftUI-based project yourself or grab an empty one from the resources for this chapter.

Back to the grid. The essential piece of the implementation is the container’s structure. In this case, it’s a hexagonal grid: each element has six edges and vertices and can have up to six neighbors.

First, you need to know the fundamentals of the grid, such as its coordinate system and the implementation of some basic operations on its elements.

## Applying Cube Coordinates to Building a Hexagonal Grid

While multiple coordinate systems can be applied for building a hexagonal grid, some are better known and easier to research. In contrast, others can be significantly more complex, obscure and rarer to find on the internet. Your choice will depend on your use case and the requirements for the structure.

Cube coordinates are the optimal approach for the component you’ll replicate.

For a better understanding, picture a 3-dimensional stack of cubes:

If you place this pile of cubes inside the standard coordinate system and then diagonally slice it by a `x + y + z = 0` plane, the shape of the sliced area of each cube will form a hexagon:

All the sliced cubes together build a hexagonal grid:

As you’re only interested in the grid itself, namely the area created by the plane slicing the pile of cubes, and not in all the cubes’ volume below or above the plane, from now on you will work with coordinates belonging to the `x + y + z = 0` area. That means, if `x` is 5, and `y` is -3, `z` can only be -2, to satisfy the equation, otherwise the said point doesn’t belong to the plane, or to the hexagonal grid.

There are a few advantages to the cubes coordinate system approach:

1. It allows most operations, like adding, subtracting or multiplying the hexagons, by manipulating their coordinates.
2. The produced grid can have a non-rectangular shape.
3. In terms of hexagonal grids, the cube coordinates are easily translatable to the axial coordinate system because the cube coordinates of each hexagon must follow the `x + y + z = 0` rule. Since you can always calculate the value of the third parameter from the first two, you can omit the `z` and operate with a pair of values - `x` and `y`. To avoid confusion between the coordinate system you’re working with in SwiftUI and the axial one, you’ll refer to them as `q`, `r` and `s` in this chapter. You may often see this same approach in many other resources on hexagonal grids’ math, but in the end the names are arbitrary and are up to you.

Now it’s time to turn the concept into code.

Create a new file named Hex.swift. Inside the file, declare `Hex` and add a property of type `Int` for each axis of the coordinate system:

``````struct Hex {
let q, r: Int
var s: Int { q - r }
}
``````

Since the value of `s` always equals `-q - r`, you use a computed property for its value.

Often, you’ll need to verify whether two hexagons are equal. Making `Hex` conform to `Equatable` is as easy as adding the protocol conformance to the type:

``````struct Hex: Equatable
``````

You can add two hexagons by adding their `q` and `r` properties, respectively. Swift includes another protocol you can use to naturally add and subtract two types together — `AdditiveArithmetic`. Add the following conformance to the bottom of the file:

``````extension Hex: AdditiveArithmetic {
static func - (lhs: Hex, rhs: Hex) -> Hex {
Hex(
q: lhs.q - rhs.q,
r: lhs.r - rhs.r
)
}

static func + (lhs: Hex, rhs: Hex) -> Hex {
Hex(
q: lhs.q + rhs.q,
r: lhs.r + rhs.r
)
}

static var zero: Hex {
.init(q: 0, r: 0)
}
}
``````

You have to provide three pieces to conform to `AdditiveArithmetic`: How to add hexagons, how to subtract hexagons, and what is considered the zero-value of a hexagon.

By incrementing or decrementing one of the two coordinates, you indicate a direction toward one of the neighbors of the current hexagon:

Since each of the directions from a hexagon piece has its own relative `q` and `r` coordinate, you can use `Hex` to represent them according to the chart above. Add the following code as an extension to Hex:

``````extension Hex {
enum Direction: CaseIterable {
case bottomRight
case bottom
case bottomLeft
case topLeft
case top
case topRight

var hex: Hex {
switch self {
case .top:
return Hex(q: 0, r: -1)
case .topRight:
return Hex(q: 1, r: -1)
case .bottomRight:
return Hex(q: 1, r: 0)
case .bottom:
return Hex(q: 0, r: 1)
case .bottomLeft:
return Hex(q: -1, r: 1)
case .topLeft:
return Hex(q: -1, r: 0)
}
}
}
}
``````

Now fetching one of the current hex’s neighbors is as easy as adding two `Hex` instances. Add the following method to your `Hex` struct:

``````func neighbor(at direction: Direction) -> Hex { // 1
return self + direction.hex // 2
}
``````

Here’s a code breakdown:

1. Using the `direction` enum, you indicate which neighbor you want to get.
2. Then, you get the direction’s coordinate and add it to the current coordinate.

Since obtaining a neighboring hexagon is now possible, you can also add a function to verify whether two hexagons are, in fact, neighbors:

``````func isNeighbor(of hex: Hex) -> Bool {
Direction.allCases.contains { neighbor(at: \$0) == hex }
}
``````

To check whether two hexagons stand side-to-side, you iterate over all six directions and check if a hexagon in the current direction equals the argument. Using `contains(where:)` will return `true` as soon as it finds a matching neighbor, or return `false` if `hex` isn’t a neighbor of the current coordinate.

Finally, you must obtain its center’s (x, y) coordinates to render each element.

To calculate the center’s position of a hexagon with the coordinates of (`q`, `r`) relative to the root hexagon in (`0`, `0`), you need to apply the green (pointing sideways) vector - `(3/2, sqrt(3)/2)`- `q` times and the blue (pointing down) vector - `(0, sqrt(3))` - `r` times. To allow for the scaling of a hexagon, you need to multiply the resulting values by the size of the hexagon.

First, in ContentView.swift, add the following constant above to the top of the file so you can change it later if you need to:

``````let diameter = 125.0
``````

Here, you add the value for the diameter of the circle you’ll draw in place of each hexagon on the grid. Where the size of a hexagon usually refers to the distance from its center to any of its corners:

Therefore, a regular hexagon’s width equals `2 * size`, and the height is `sqrt(3) * size`.

Add the following method calculate the `Hex`’s center, inside the `struct`:

``````func center() -> CGPoint {
let qVector = CGVector(dx: 3.0 / 2.0, dy: sqrt(3.0) / 2.0) // 1
let rVector = CGVector(dx: 0.0, dy: sqrt(3.0))
let size = diameter / sqrt(3.0) // 2
let x = qVector.dx * Double(q) * size // 3
let y = (qVector.dy * Double(q) +
rVector.dy * Double(r)) * size

return CGPoint(x: x, y: y)
}
``````

Here’s a code breakdown:

1. First, you construct the green and blue vectors from the diagram above.
2. Then, you calculate the size of the hexagon based on the formula for the height.
3. You calculate the total horizontal and vertical shifts by multiplying a vector’s coordinates by the hexagon’s coordinates and size. Because a regular hexagon has uneven height and width, you use the same value for both height and width to fit it into a “square” shape because you’re going to draw circles in place of hexagons, which would leave blank spaces on the sides otherwise.

## Constructing a Hexagonal Grid

To represent an element of a hexagonal grid, make a new file named HexData.swift and define a struct inside it named `HexData`:

``````struct HexData {
var hex: Hex
var center: CGPoint
var topic: String
}
``````
``````struct HexData: Hashable
``````
``````func hash(into hasher: inout Hasher) {
hasher.combine(topic)
}
``````

### Iterating Over the Grid

You need to develop a method to generate an array of `Hex` instances to build a honeycomb grid.

``````static func hexes(for topics: [String]) -> [Self] {
return []
}
``````
``````var ringIndex = 0
var currentHex = Hex(q: 0, r: 0)
``````
``````var hexes = [Hex(q: 0, r: 0)]
``````
``````let directions = Hex.Direction.allCases.enumerated()
``````
``````repeat {

} while hexes.count < topics.count
``````
``````directions.forEach { index, direction in // 1
let smallerSegment = index == 1 // 2
let segmentSize = smallerSegment ? ringIndex : ringIndex + 1 // 3
for _ in 0..<segmentSize {
// TODO
}
}

ringIndex += 1 // 4
``````
``````guard hexes.count != topics.count else { break } // 1
currentHex = currentHex + direction.hex // 2
hexes.append(currentHex)
``````
``````return hexes.enumerated().map { index, hex in
HexData(
hex: hex,
center: hex.center(),
topic: topics[index]
)
}
``````

### Rendering the Hexagons

You’re almost ready to display the first version of your grid view on the screen.

``````let hex: HexData
``````
``````ZStack {
Circle()
.fill(Color(uiColor: UIColor.purple))

Text(hex.topic)
.multilineTextAlignment(.center)
.font(.footnote)
}
.frame(width: diameter, height: diameter)
``````
``````HexView(
hex: HexData(
hex: .zero,
center: .zero,
topic: "Tech"
)
)
``````

``````VStack {
Text("Pick 5 or more topics you're most interested in:")

// TODO
}
``````
``````@State var hexes: [HexData] = []
private let topics = [
"Politics", "Science", "Animals",
"Plants", "Tech", "Music",
"Sports", "Books", "Cooking",
"Traveling", "TV-series", "Art",
"Finance", "Fashion"
]
``````

### Building a Custom Layout

At WWDC22, Apple introduced a new convenient way of composing more complex containers, the `Layout` protocol, which is available on iOS 16.

``````import SwiftUI

struct HoneycombGrid: Layout {
let hexes: [HexData]

}
``````
``````func sizeThatFits(
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout ()
) -> CGSize {
// TODO
}

func placeSubviews(
in bounds: CGRect,
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout ()
) {
// TODO
}
``````
``````CGSize(
width: proposal.width ?? .infinity,
height: proposal.height ?? .infinity
)
``````
``````subviews.enumerated().forEach { i, subview in
let hexagon = hexes[i]

// TODO
}
``````
``````let position = CGPoint( // 1
x: bounds.origin.x + hexagon.center.x + bounds.width / 2,
y: bounds.origin.y + hexagon.center.y + bounds.height / 2
)

// 2
subview.place(
at: position,
anchor: .center,
proposal: proposal
)
``````
``````HoneycombGrid(hexes: hexes) {
ForEach(hexes, id: \.self) { hex in
HexView(hex: hex)
}
}
``````
``````.onAppear {
hexes = HexData.hexes(for: topics)
}
``````

## Gesture Handling

Start with dragging gestures. Add a new `@GestureState` and `@State` properties to the `ContentView` to keep track of the offset:

``````@GestureState var drag: CGSize = .zero
@State var dragOffset: CGSize = .zero
``````
``````private func onDragEnded(with state: DragGesture.Value) {

}
``````
``````dragOffset = CGSize(
width: dragOffset.width + state.translation.width,
height: dragOffset.height + state.translation.height
)
``````
``````let initialOffset = dragOffset
``````
``````var endX = initialOffset.width +
state.predictedEndTranslation.width * 1.25
var endY = initialOffset.height +
state.predictedEndTranslation.height * 1.25
``````
``````let lastHex = hexes.last?.center ?? .zero
let maxDistance = sqrt(
pow((lastHex.x), 2) +
pow((lastHex.y), 2)
) * 0.7
if abs(endX) > maxDistance {
endX = endX > 0 ? maxDistance : -maxDistance
}
if abs(endY) > maxDistance {
endY = endY > 0 ? maxDistance : -maxDistance
}
``````
``````withAnimation(.spring()) {
dragOffset = CGSize(
width: endX,
height: endY
)
}
``````
``````.simultaneousGesture(DragGesture()
.updating(\$drag) { value, state, _ in
state = value.translation
}
.onEnded { state in
onDragEnded(with: state)
}
)
``````
``````.offset(
CGSize(
width: drag.width + dragOffset.width,
height: drag.height + dragOffset.height
)
)
``````

### Selecting a Grid’s Hexagon

To highlight the selected cells, add these properties in `HexView` below the `hex` property:

``````let isSelected: Bool
let onTap: () -> Void
``````
``````.fill(isSelected ? .green : Color(uiColor: .purple))
``````
``````.onTapGesture {
onTap()
}
``````
``````HexView(
hex: HexData(
hex: .zero,
center: .zero,
topic: "Tech"
),
isSelected: false,
onTap: {}
)
``````
``````HexView(
hex: hex,
isSelected: selectedHexes.contains(hex)
) {
select(hex: hex)
}
``````
``````@State var selectedHexes: Set<HexData> = []
``````
``````private func select(hex: HexData) {

}
``````
``````if !selectedHexes.insert(hex).inserted { // 1
selectedHexes.remove(hex)
}

withAnimation(.spring()) { // 2
dragOffset = CGSize(width: -hex.center.x, height: -hex.center.y)
}
``````
``````Text(
selectedHexes.count < 5
? "Pick \(5 - selectedHexes.count) more!"
: "You're all set!"
)

ProgressView(
value: Double(min(5, selectedHexes.count)),
total: 5
)
.scaleEffect(y: 3)
.tint(selectedHexes.count < 5 ? Color(uiColor: .purple) : .green)
``````
``````.animation(.easeInOut, value: selectedHexes.count)
``````

``````@Binding var touchedHexagon: HexData?
``````
``````.overlay(
Circle()
.fill(touchedHexagon == hex ? .black.opacity(0.25) : .clear)
)
``````
``````.simultaneousGesture(
DragGesture(minimumDistance: 0)
.onChanged { _ in // 1
withAnimation(.easeInOut(duration: 0.5)) {
touchedHexagon = hex
}
}
.onEnded { _ in // 2
withAnimation(.easeInOut(duration: 0.5)) {
touchedHexagon = nil
}
}
)
``````
``````HexView(
hex: HexData(hex: .zero, center: .zero, topic: "Tech"),
isSelected: false,
touchedHexagon: .constant(nil),
onTap: {}
)
``````
``````@State var touchedHexagon: HexData? = nil
``````
``````let hexOrNeighbor = touchedHexagon == hex ||
touchedHexagon?.hex.isNeighbor(of: hex.hex) == true
``````
``````HexView(
hex: hex,
isSelected: selectedHexes.contains(hex),
touchedHexagon: \$touchedHexagon
) {
select(hex: hex)
}
``````
``````.scaleEffect(hexOrNeighbor ? 0.9 : 1)
``````

### Expanding the Grid

The currently presented topics are rather generic. Once a user picks a topic, you could offer subtopics to them to be more specific in defining their interests.

``````static func hexes(
from source: Hex,
_ array: [HexData],
topics: [String]
) -> [HexData] {
var newHexData: [HexData] = []

//TODO

return newHexData
}
``````
``````for direction in Hex.Direction.allCases {
let newHex = source.neighbor(at: direction) // 1

if !array.contains(where: { \$0.hex == newHex }) { // 2
newHexData.append(HexData(
hex: newHex,
center: newHex.center(),
topic: topics[newHexData.count]
))
}

if newHexData.count == topics.count { // 3
return newHexData
}
}
``````
``````newHexData.append(contentsOf: hexes(
from: source.neighbor(at: Hex.Direction.allCases.randomElement()!),
array + newHexData,
topics: Array(topics.dropFirst(newHexData.count))
))
``````
``````private func appendHexesIfNeeded(for hex: HexData) {
let shouldAppend = !hex.topic.contains("subtopic") &&
!hexes.contains(where: { \$0.topic.contains("\(hex.topic)'s subtopic") })

if shouldAppend {
hexes.append(contentsOf: HexData.hexes(from: hex.hex, hexes, topics: [
"\(hex.topic)'s subtopic 1",
"\(hex.topic)'s subtopic 2",
"\(hex.topic)'s subtopic 3"
]))
}
}
``````
``````if selectedHexes.insert(hex).inserted {
appendHexesIfNeeded(for: hex)
} else {
selectedHexes.remove(hex)
}
``````
``````DispatchQueue.main.async {
withAnimation(.spring()) {
dragOffset = CGSize(width: -hex.center.x, height: -hex.center.y)
}
}
``````
``````.transition(.scale)
``````
``````.animation(.spring(), value: hexes)
``````

## Recreating the Fish Eye Effect

What makes Apple’s honeycomb grid so special and recognizable besides the grid structure is its “fish eye” effect. The cells closer to the center of the screen appear larger, while those at the corner shrink until they disappear entirely when reaching the screen’s borders.

``````GeometryReader { proxy in
HoneycombGrid { ... }
}
``````
``````private func size(
for hex: HexData,
_ proxy: GeometryProxy
) -> CGFloat {
return 0
}
``````
``````let offsetX = hex.center.x + drag.width + dragOffset.width
let offsetY = hex.center.y + drag.height + dragOffset.height
``````
``````let frame: CGRect = proxy.frame(in: .global)
let excessX = abs(offsetX) + diameter - frame.width / 2
let excessY = abs(offsetY) + diameter - frame.height / 2
``````
``````let excess = max(0, max(excessX, excessY)) // 1
let size = max(0, diameter - excess) // 2
return size
``````
``````let size = size(for: hex, proxy)
let scale = (hexOrNeighbor ? size * 0.9 : size) / diameter
``````
``````.scaleEffect(max(0.001, scale))
``````

``````private func measurement(
for hex: HexData,
_ proxy: GeometryProxy
) -> (size: CGFloat, shift: CGPoint) {
``````
``````let shift = CGPoint(
x: offsetX > 0
? -max(0, excessX) / 3.0
: max(0, excessX) / 3.0,
y: offsetY > 0
? -max(0, excessY) / 3.0
: max(0, excessY) / 3.0
)
return (size, shift)
``````
``````let size = max(0, diameter - 3.0 * abs(excess) / 4)
``````
``````let measurement = measurement(for: hex, proxy)
let scale = (hexOrNeighbor
? measurement.size * 0.9
: measurement.size) / diameter
``````
``````.offset(CGSize(
width: measurement.shift.x,
height: measurement.shift.y
))
``````

## Key Points

1. When recreating an existing UI component, it’s often helpful to break larger concepts into smaller ones. For instance, find a way to build the outer parts of the component, the parent container, recreate its layout and proceed with the smaller views or child controls.
2. One optimal way to build a hexagonal grid is cube or axial coordinates, with the third, `s`, parameter computed as `-q - r`.
3. Apple’s new `Layout` protocol offers a convenient way to build more complex containers. You only need two methods to implement it: `sizeThatFits(proposal:subviews:cache:)` and `placeSubviews(in:proposal:subviews:cache:)`.

## Where to Go From Here?

In this chapter, you implemented some basic hexagonal grid operations, which helped you recreate a beautiful and fun-to-use component.

Have a technical question? Want to report a bug? You can ask questions and report bugs to the book authors in our official book forum here.