Chapters

Hide chapters

SwiftUI Animations by Tutorials

First Edition · iOS 16 · Swift 5.7 · Xcode 14

Section I: SwiftUI Animations by Tutorials

Section 1: 11 chapters
Show chapters Hide chapters

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

Heads up... You're reading this book for free, with parts of this chapter shown beyond this point as scrambled text.

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:

0 0 0 0 0 0 +3 -3 +2 -2 +1 -1 -1 +1 -2 +2 -3 +3 +1 +2 -3 +1 +1 -2 +2 +1 -3 -1 +3 -2 -1 +2 -1 -1 +1 0 -1 -1 +2 -1 -2 +3 -2 -1 +3 -2 +1 0 -2 +2 0 -2 +3 -1 -3 +3 0 -3 +2 +1 -3 +1 +2 +1 -1 0 +2 -1 -1 +2 -2 0 +1 -2 +1 +1 -3 +2 +2 -3 +1 +3 -3 0 +3 -2 -1 +3 -1 -2 +1 -1 +2 -2 +3 -3 -3 +3 -2 +2 -1 +1 q 0 0 0 0 0 0 r

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:

0, -1 +1,-1 +1, 0 0, +1 -1, +1 -1, 0 q, r

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.

0,0 +1,0 +1,+1 0,+1

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)
    .padding(4)
}
.shadow(radius: 4)
.padding(4)
.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:")
    .font(.subheadline)

// 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)
.padding(24)
.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.
© 2024 Kodeco Inc.

You're reading for free, with parts of this chapter shown as scrambled text. Unlock this book, and our entire catalogue of books and videos, with a Kodeco Personal Plan.

Unlock now