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

5. Applying Complex Transformations & Interactions
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.

In the previous chapter, you learned how to draw a custom seating chart with tribunes using SwiftUI’s Path. However, quite a few things are still missing. Users must be able to preview the seats inside a tribune and select them to purchase tickets. To make the user’s navigation through the chart effortless and natural, you’ll implement gesture handling, such as dragging, magnifying and rotating.

As usual, fetch the starter project for this chapter from the materials, or continue where you left off in the previous chapter.

Open SportFan.xcodeproj and head straight to SeatingChartView.

Manipulating SwiftUI Shapes Using CGAffineTransform

You need two things to display seats for each tribune: a Shape containing the Path drawing the seat and a CGRect representing its bounds. To accomplish the former, create a new struct named SeatShape:

struct SeatShape: Shape {
  func path(in rect: CGRect) -> Path {
    Path { path in

    }
  }
}

The shape you’re about to draw consists of a few parts: the seat’s back, squab, and rod connecting them. Start by defining a few essential properties right below inside the Path’s trailing closure:

let verticalSpacing = rect.height * 0.1
let cornerSize = CGSize(
  width: rect.width / 15.0,
  height: rect.height / 15.0
)
let seatBackHeight = rect.height / 3.0 - verticalSpacing
let squabHeight = rect.height / 2.0 - verticalSpacing
let seatWidth = rect.width

To emulate the top-to-bottom perspective, you calculate the seat back rectangle as slightly shorter vertically than the squab.

Then, right below these variables, define the CGRect’s for the back and squab and draw the corresponding rounded rectangles:

let backRect = CGRect(
  x: 0, y: verticalSpacing,
  width: seatWidth, height: seatBackHeight
)
let squabRect = CGRect(
  x: 0, y: rect.height / 2.0,
  width: seatWidth, height: squabHeight
)

path.addRoundedRect(in: backRect, cornerSize: cornerSize)
path.addRoundedRect(in: squabRect, cornerSize: cornerSize)

Now, draw the rod:

path.move(to: CGPoint(
  x: rect.width / 2.0,
  y: rect.height / 3.0
))
path.addLine(to: CGPoint(
  x: rect.width / 2.0,
  y: rect.height / 2.0
))

You still have a long way to go before looking at the seat’s shape as part of a tribune. To get a quick preview for the time being, create a new struct called SeatPreview:

struct SeatPreview: View {
  let seatSize = 100.0
  var body: some View {
    ZStack {
      SeatShape().path(in: CGRect(
        x: 0, y: 0,
        width: seatSize, height: seatSize
      ))
      .fill(.blue) // 1

      SeatShape().path(
        in: CGRect(
          x: 0, y: 0,
          width: seatSize, height: seatSize
        ))
        .stroke(lineWidth: 2) // 2
    }
    .frame(width: seatSize, height: seatSize)
  }
}

This process is similar to the shapes you’ve drawn in the previous chapter:

  1. Inside a ZStack, you use one instance of SeatShape as a background with .blue fill.
  2. You use the second shape’s instance to draw the seat’s stroke.

Finally, you must make Xcode show the SeatPreview in the previews window. Create a new PreviewProvider:

struct Seat_Previews: PreviewProvider {
  static var previews: some View {
    SeatPreview()
  }
}

Your seat preview should look like this, for the time being:

The seat is there but looks relatively flat. You’ll skew it back to give it a slightly more realistic perspective. Don’t forget that you drew the tribunes all around the stadium field, which means the seats should always face the center of the field. Head to the next section to learn how to transform shapes!

Matrices Transformations

Check Path‘s API, and you’ll notice there are many methods, such as addRoundedRect or addEllipse, accepting an argument of type CGAffineTransform called transform. Via just one argument, you can manipulate a subpath in 2D space in several ways: rotate, skew, scale or translate.

u s 5 v j 6 b ​v h y 2

1 5 3 4 2 2 5 5 2

3 0 8 7 2 3 l ​t l n 8

M ​g g 8 7 0 V 3 8 9 5

i tep o zet 5 e —wuz o nux 3 0 3 8

4 7 2 1 1 2 0 P ​t y M

Applying the Skewing Operation

Back in SeatShape’s Path, find the seatWidth you previously added, and add the following line above it:

let skewAngle = .pi / 4.0
let skewShift = seatBackHeight / tan(skewAngle)
jiolMathPoakgh rruqGjedh kwuxOtkcu

let seatWidth = rect.width - skewShift
path.addLine(to: CGPoint(
  x: rect.width / 2.0,
  y: rect.height / 2.0
))
path.addLine(to: CGPoint(
  x: rect.width / 2.0 - skewShift / 2,
  y: rect.height / 2.0
))
let skew = CGAffineTransform(
  a: 1, b: 0, c: -cos(skewAngle), // 1
  d: 1, tx: skewShift + verticalSpacing, ty: 0
) // 2
path.addRoundedRect(in: backRect, cornerSize: cornerSize)
path.addRoundedRect(
  in: backRect,
  cornerSize: cornerSize,
  transform: skew
)

Rotating the Seat

To allow SeatShape to rotate, add a new property to the struct:

let rotation: CGFloat
@State var rotation: Float = 0.0
SeatShape(rotation: CGFloat(-rotation))
VStack {

  // ZStack with SeatShape's

  Slider(value: $rotation, in: 0.0...(2 * .pi), step: .pi / 20)
  Text("\(rotation)")
}.padding()
path = path.applying(CGAffineTransform(rotationAngle: rotation))

Rotating an Object Around an Arbitrary Point

Applying a rotation matrix rotates an object around its origin (minX, minY). To perform the transformation around an arbitrary point like its center, you first need to shift the object to that point, perform the rotation and then translate the object back.

let rotationCenter = CGPoint(x: rect.width / 2, y: rect.height / 2)
let translationToCenter = CGAffineTransform(
  translationX: rotationCenter.x,
  y: rotationCenter.y
)
let initialTranslation = CGAffineTransform(
  translationX: rect.minX,
  y: rect.minY
)
var result = CGAffineTransformRotate(translationToCenter, rotation)
result = CGAffineTransformTranslate(result, -rotationCenter.x, -rotationCenter.y)
path = path.applying(result.concatenating(initialTranslation))

Locating Rectangular Tribunes’ Seats

With your animation’s performance in mind, you’ll ensure the seat locations are computed only once, assigned to the respective tribune and drawn only when a user picks a specific tribune. Otherwise, it would be a waste to draw each one when they’re barely visible due to the scale of the seating chart.

struct Seat: Hashable, Equatable {
  var path: Path

  public func hash(into hasher: inout Hasher) {
    hasher.combine(path.description)
  }
}
private func computeSeats(for tribune: CGRect, at rotation: CGFloat) -> [Seat] {
  var seats: [Seat] = []

  // TODO

  return seats
}
let seatSize = tribuneSize.height * 0.1
let columnsNumber = Int(tribune.width / seatSize)
let rowsNumber = Int(tribune.height / seatSize)
let spacingH = CGFloat(tribune.width - seatSize * CGFloat(columnsNumber)) / CGFloat(columnsNumber)
let spacingV = CGFloat(tribune.height - seatSize * CGFloat(rowsNumber)) / CGFloat(rowsNumber)
(0..<columnsNumber).forEach { column in
  (0..<rowsNumber).forEach { row in

  }
}
let x = tribune.minX + spacingH / 2.0 + (spacingH + seatSize) * CGFloat(column)
let y = tribune.minY + spacingV / 2.0 + (spacingV + seatSize) * CGFloat(row)

let seatRect = CGRect(
  x: x, y: y,
  width: seatSize, height: seatSize
)
seats.append(Seat(
  path: SeatShape(rotation: rotation)
    .path(in: seatRect)
  )
)

Displaying the Seats

To access each tribune’s seats when rendering the seating chart, add a new property to Tribune:

var seats: [Seat]
private func makeRectTribuneAt(
  x: CGFloat, y: CGFloat,
  vertical: Bool, rotation: CGFloat
) -> Tribune {
let rect = CGRect(
  x: x,
  y: y,
  width: vertical ? tribuneSize.height : tribuneSize.width,
  height: vertical ? tribuneSize.width : tribuneSize.height
)
return Tribune(
  path: RectTribune().path(in: rect),
  seats: computeSeats(for: rect, at: rotation)
)
tribunes.append(Tribune(path: ArcTribune(
  /* arc tribune's properties */
).path(in: CGRect.zero), seats: []))
tribunes.append(makeRectTribuneAt(
  x: x,
  y: rect.minY + offset,
  vertical: false,
  rotation: 0
))
tribunes.append(makeRectTribuneAt(
  x: x, y: rect.maxY - offset - tribuneSize.height,
  vertical: false,
  rotation: -.pi
))
tribunes.append(makeRectTribuneAt(
  x: rect.minX + offset,
  y: y,
  vertical: true,
  rotation: -.pi / 2.0
))
tribunes.append(makeRectTribuneAt(
  x: rect.maxX - offset - tribuneSize.height,
  y: y,
  vertical: true,
  rotation: 3.0 * -.pi / 2.0
))
if let selectedTribune {
  ForEach(selectedTribune.seats, id: \.self) { seat in
    ZStack {
      seat.path.fill(.blue)
      seat.path.stroke(.black, lineWidth: 0.05)
    }
  }
}

Computing Positions of the Arc Tribune’s Seats

Calculating the bounds of an arc tribune’s seats is similar to building an arc tribune’s Path. Since you move along an arc, not a straight line, you operate with angles. You used an angle value for a tribune and another for the spacing. In the same way, you’ll calculate the angle needed for a seat and the spacing between neighboring seats.

private func computeSeats(for arcTribune: ArcTribune) -> [Seat] {
  var seats: [Seat] = []

  // TODO

  return seats
}
let seatSize = tribuneSize.height * 0.1
let rowsNumber = Int(tribuneSize.height / seatSize)
let spacingV = CGFloat(tribuneSize.height - seatSize * CGFloat(rowsNumber)) / CGFloat(rowsNumber)
(0..<rowsNumber).forEach { row in

}
let radius = arcTribune.radius - CGFloat(row) * (spacingV + seatSize) - spacingV - seatSize / 2.0 // 1
let arcLength = abs(arcTribune.endAngle - arcTribune.startAngle) * radius // 2
let arcSeatsNum = Int(arcLength / (seatSize * 1.1)) // 3
let arcSpacing = (arcLength - seatSize * CGFloat(arcSeatsNum)) / CGFloat(arcSeatsNum) // 1
let seatAngle = seatSize / radius // 2
let spacingAngle = arcSpacing / radius // 3
var previousAngle = arcTribune.startAngle + spacingAngle + seatAngle / 2.0 // 4
(0..<arcSeatsNum).forEach { _ in

}
let seatCenter = CGPoint(
  x: arcTribune.center.x + radius * cos(previousAngle),
  y: arcTribune.center.y + radius * sin(previousAngle)
)
let seatRect = CGRect(
  x: seatCenter.x - seatSize / 2,
  y: seatCenter.y - seatSize / 2,
  width: seatSize,
  height: seatSize
)
seats.append(
  Seat(
    path: SeatShape(rotation: previousAngle + .pi / 2)
      .path(in: seatRect)
  )
)
previousAngle += spacingAngle + seatAngle
let arcTribune = ArcTribune(
  center: center,
  radius: radius,
  innerRadius: innerRadius,
  startingPoint: startingPoint,
  startingInnerPoint: startingInnerPoint,
  startAngle: previousAngle + spacingAngle,
  endAngle: previousAngle + spacingAngle + angle
)

let tribune = Tribune(
  path: arcTribune.path(in: CGRect.zero),
  seats: computeSeats(for: arcTribune)
)

Processing User Gestures

Navigating through the seating chart is somewhat cumbersome and extremely limited right now. Users should be as free with gestures as possible to speed up a tribune and seat selection.

Dragging

To obtain the offset value from the user’s drag gesture, you’ll use SwiftUI’s DragGesture. First, add these new properties to SeatingChartView:

@GestureState private var drag: CGSize = .zero
@State private var offset: CGSize = .zero
extension CGSize {
  static func +(left: CGSize, right: CGSize) -> CGSize {
    return CGSize(width: left.width + right.width, height: left.height + right.height)
  }
}
var dragging: some Gesture {
  DragGesture()
    .updating($drag) { currentState, gestureState, transaction in // 1
      gestureState = currentState.translation
    }
    .onEnded { // 2
      offset = offset + $0.translation
    }
}
.offset(offset + drag)
.simultaneousGesture(dragging)

Zooming

Like DragGesture, you can use MagnificationGesture to obtain the current gesture’s scale.

@GestureState private var manualZoom = 1.0
var magnification: some Gesture {
  MagnificationGesture()
    .updating($manualZoom) { currentState, gestureState, transaction in
      gestureState = currentState
    }
    .onEnded {
      zoom *= $0
    }
}
.scaleEffect(manualZoom * zoom, anchor: zoomAnchor)
.simultaneousGesture(magnification)

Rotating

The 2D rotating gesture is as easily implemented in SwiftUI as the others. You know what to do! Add another @GestureState property, and add a rotation property keep track of the applied rotation:

@GestureState private var currentRotation: Angle = .radians(0.0)
@State var rotation = Angle(radians: .pi / 2)
var rotationGesture: some Gesture {
  RotationGesture()
    .updating($currentRotation) { currentState, gestureState, transaction in
      gestureState = .radians(currentState.radians)
    }
    .onEnded {
      rotation += $0
    }
}
.rotationEffect(rotation + currentRotation, anchor: zoomAnchor)
.simultaneousGesture(rotationGesture)

Handling Seat Selection

To keep track of the selected seats, add a new property to SeatingChartView:

@State private var selectedSeats: [Seat] = []
.onTapGesture { tap in
  if let selectedTribune, selectedTribune.path.contains(tap) {
    // TODO pick a seat
  } else {
    // TODO pick a tribune
  }
}
private func findAndSelectSeat(at point: CGPoint, in selectedTribune: Tribune) {
  guard let seat = selectedTribune.seats
    .first(where: { $0.path.boundingRect.contains(point) }) else {
    return
  } // 1

  withAnimation(.easeInOut) {
    if let index = selectedSeats.firstIndex(of: seat) { // 2
      selectedSeats.remove(at: index)
    } else {
      selectedSeats.append(seat)
    }
  }
}
private func findAndSelectTribune(at point: CGPoint, with proxy: GeometryProxy) {
  let tribune = tribunes.flatMap(\.value)
    .first(where: { $0.path.boundingRect.contains(point) })
  let unselected = tribune == selectedTribune
  let anchor = UnitPoint(
    x: point.x / proxy.size.width,
    y: point.y / proxy.size.height
  )

  LinkedAnimation.easeInOut(for: 0.7) {
    zoom = unselected ? 1.25 : 25
  }
  .link(
    to: .easeInOut(for: 0.3) {
      selectedTribune = unselected ? nil : tribune
      zoomAnchor = unselected ? .center : anchor
      offset = .zero
    },
    reverse: !unselected
  )
}
if let selectedTribune, selectedTribune.path.contains(tap) {
  findAndSelectSeat(at: tap, in: selectedTribune)
} else {
  findAndSelectTribune(at: tap, with: proxy)
}
seat.path.fill(.blue)
seat.path.fill(selectedSeats.contains(seat) ? .green : .blue)

Final Animating Touches

Since a seat is essentially a Path, just like a tribune, it’s pretty easy to animate it by trimming it. Add a new property of type CGFloat to SeatingChartView:

@State private var seatsPercentage: CGFloat = .zero
seat.path
  .trim(from: 0, to: seatsPercentage)
  .fill(selectedSeats.contains(seat) ? .green : .blue)
seat.path
  .trim(from: 0, to: seatsPercentage)
  .stroke(.black, lineWidth: 0.05)
seatsPercentage = selectedTribune == nil || !unselected ? 0.0 : 1.0
seatsPercentage = unselected ? 0.0 : 1.0

@Binding var zoomed: Bool
@Binding var selectedTicketsNumber: Int
if let index = selectedSeats.firstIndex(of: seat) {
  selectedTicketsNumber -= 1
  selectedSeats.remove(at: index)
} else {
  selectedTicketsNumber += 1
  selectedSeats.append(seat)
}
withAnimation {
  zoomed = zoom > 1.25
}
zoomed = !unselected
.onChange(of: zoomed) {
  if !$0 && zoom > 1.25 {
    LinkedAnimation.easeInOut(for: 0.7) {
      zoom = 1.25
      seatsPercentage = 0.0
    }
    .link(
      to: .easeInOut(for: 0.3) {
        selectedTribune = nil
        zoomAnchor = .center
        offset = .zero
      },
      reverse: false
    )
  }
}
SeatingChartView(
  zoomed: Binding.constant(false),
  selectedTicketsNumber: Binding.constant(5)
)
@State private var stadiumZoomed = false
@State private var selectedTicketsNumber: Int = 0
@State private var ticketsPurchased: Bool = false
SeatingChartView(
  zoomed: $stadiumZoomed,
  selectedTicketsNumber: $selectedTicketsNumber
)
if !stadiumZoomed {
  VStack { ... }
    .transition(.move(edge: .top))
}
ZStack(alignment: .topLeading) {
  /* cart icon */
  if selectedTicketsNumber > 0 {
    Text("\(selectedTicketsNumber)")
      .foregroundColor(.white)
      .font(.caption)
      .background {
        Circle()
          .fill(.red)
          .frame(width: 16, height: 16)
      }
      .alignmentGuide(.leading) { _ in -20}
      .alignmentGuide(.top) { _ in 4 }
  }
}
HStack {
  /* Buy Tickets button */

  if stadiumZoomed {
    Button {
      withAnimation {
        stadiumZoomed = false
      }
    } label: {
      Image("zoom_out")
        .resizable()
        .scaledToFit()
        .frame(width: 48, height: Constants.iconSizeL)
        .clipped()
        .background {
          RoundedRectangle(cornerRadius: 36)
            .fill(.white)
            .frame(width: 48, height: 48)
            .shadow(radius: 2)
          }
          .padding(.trailing)
    }
  }
}
if selectedTicketsNumber > 0 {
  ticketsPurchased = true
}
.confirmationDialog(
  "You've bought \(selectedTicketsNumber) tickets.",
  isPresented: $ticketsPurchased,
  actions: { Button("Ok") {} },
  message: { Text("You've bought \(selectedTicketsNumber) tickets. Enjoy your time at the game!")}
)

Key Points

  1. CGAffineTransform represents a transformation matrix, which you can apply to a subpath to perform rotation, scaling, translating or skewing.
  2. A transformation matrix in 2D graphics is of size 3x3, where the first two columns are responsible for all the applied transformations. The last one is constant to preserve the matrices’ concatenation ability.
  3. An object rotates around its origin when manipulated by a rotation matrix. To use a different point as an anchor, move the object towards that point first, apply the desired rotation and then shift it back.
  4. SwiftUI can process multiple gestures, like DragGesture, MagnificationGesture, RotationGesture or TapGesture, simultaneously when you attach them with the .simultaneousGesture modifier.

Where to Go From Here?

Transformation matrices are still universally used in computer graphics regardless of the programming language, framework or platform. Learning them once will be handy when working with animations outside the Apple ecosystem. The Wikipedia article on the topic offers a good overview of transformation matrices as a mathematical concept, also in the context of 2D or 3D computer graphics.

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