Using TimelineView and Canvas in SwiftUI

Learn how to use TimelineView and Canvas in SwiftUI and combine them to produce animated graphics. By Bill Morefield.

Leave a rating/review
Download materials
Save for later
Share
You are currently viewing page 2 of 4 of this article. Click here to view the first page.

Drawing Inside a Canvas

The Rectangle views inside the canvas don’t render because the closure to a canvas isn’t a view builder. This is different from almost every other SwiftUI closure. In exchange for losing the ability to use SwiftUI views inside a Canvas directly, you gain access to some powerful Core Graphics APIs that you can now mix and match with SwiftUI.

Replace the remaining two rectangle views with the following code:

let dayRect = CGRect(
  x: sunrisePosition,
  y: 0,
  width: sunsetPosition - sunrisePosition,
  height: size.height)
context.fill(
  Path(dayRect),
  with: .color(.blue))

let eveningRect = CGRect(
  x: sunsetPosition,
  y: 0,
  width: size.width - sunsetPosition,
  height: size.height)
context.fill(
  Path(eveningRect),
  with: .color(.black))

As before, the rectangle fills the full vertical space of the canvas. You move the offset previously applied to the Rectangle view to the rectangle’s x coordinate. As before, the width of the frame applied to Rectangle becomes the width of each rectangle.

The last code in this view draws a yellow line at midnight and noon on the graph. Replace the current ForEach view with:

// 1
for hour in [0, 12] {
  // 2
  var hourPath = Path()
  // 3
  let position = Double(hour) / 24.0 * size.width
  // 4
  hourPath.move(to: CGPoint(x: position, y: 0))
  // 5
  hourPath.addLine(to: CGPoint(x: position, y: size.height))
  // 6
  context.stroke(
    hourPath,
    with: .color(.yellow),
    lineWidth: 3.0)
}

Here’s how the code works, step by step:

  1. Since you’re not inside a view builder, you use a for-in loop to iterate over a collection of integers, with 0 representing midnight and 12 representing noon.
  2. You create an empty path that you’ll add inside the canvas.
  3. To determine the horizontal coordinate of the line, convert the hour to Double and then divide it by 24.0 to get the fraction of a full day that the hour represents. Then, multiply this fraction by the width of the canvas to get the horizontal position that represents the hour.
  4. move(to:) on the path moves the current position without adding to the path. It moves the current position to the horizontal position from step three and to the top of the view.
  5. addLine(to:) adds a line from the current position to the position specified to the path. This position is at the same horizontal coordinate at the bottom of the view.
  6. You now use stroke(_:with:lineWidth:) on the context to draw, not fill, the path. You specify a yellow color and a width of three points to help the line stand out.

Build and run. You’ll see the views look the same as before, but use Canvas instead of SwiftUI shape views:

World Clock app showing four cities' times and day/night bars

The main reason to use a canvas is performance. For complex drawings with many gradients or parts, you’ll see much better performance than with SwiftUI views. A canvas view also provides compatibility with Core Graphics, including access to a Core-Graphics-enabled wrapper. If you have existing code created using Core Graphics, like custom controls written for UIView and rendered in draw(_:), you can drop it inside a canvas without modification.

What do you lose in a canvas view? As you saw in this example, a canvas often needs more verbose code. The canvas exists as a single element, and you can’t address and modify the components individually like with SwiftUI views. You can add onTapGesture(count:perform:) to a canvas, but not to a path in the canvas.

A canvas also provides one more function. You can combine it with TimelineView to perform animations. You’ll explore that in the rest of this tutorial as you create an analog clock for the app.

Drawing a Clock Face

TimelineView provides a way to update a view regularly, while a canvas view offers a way to create high-performance graphics. In this section, you’ll do just that by creating an animated analog clock showing the selected city’s time on the details page.

Open AnalogClock.swift. Replace the body of the view with the following code:

Canvas { gContext, size in
  // 1
  let clockSize = min(size.width, size.height) * 0.9
  // 2
  let centerOffset = min(size.width, size.height) * 0.05
  // 3
  let clockCenter = min(size.width, size.height) / 2.0
  // 4
  let frameRect = CGRect(
    x: centerOffset,
    y: centerOffset,
    width: clockSize,
    height: clockSize)
}

This code defines a Canvas view and calculates the size of the clock face based on the size of the view:

  1. You first determine the smaller dimension between the width and height of the canvas. You multiply this value by 0.9 to set the size of the face to fill 90% of the smaller dimension.
  2. To center the clock in the canvas, determine the smaller dimension and multiply it by 0.05 to get half of the 10% remaining from step one. This value will be the top-left corner for the rectangle containing the clock face.
  3. You determine the clock’s center coordinate by dividing the smaller dimension by two. This gives you both the horizontal and vertical center position since the clock is symmetrical. You’ll use this value later in this tutorial.
  4. You define a rectangle using the offset from step two and the size from step one. This rectangle encloses the clock face.

Now, you’ll draw the clock face. Continue the closure of the canvas with the following code:

// 1
gContext.withCGContext { cgContext in
  // 2
  cgContext.setStrokeColor(
    location.isDaytime(at: time) ? 
      UIColor.black.cgColor : UIColor.white.cgColor)
  // 3
  cgContext.setFillColor(location.isDaytime(at: time) ? dayColor : nightColor)
  cgContext.setLineWidth(2.0)
  // 4
  cgContext.addEllipse(in: frameRect)
  // 5
  cgContext.drawPath(using: .fillStroke)
}

Here’s how the code works, step by step:

  1. As mentioned earlier, the Canvas view supports Core Graphics drawing. However, the gContext parameter you get inside the canvas closure is still a wrapper around Core Graphics. To get all the way down to Core Graphics, you call GraphicsContext.withCGContext(content:). This creates and passes a true Core Graphics context to the corresponding closure, where you can use all the Core Graphics code. Changes to the graphics state made in either the canvas or Core Graphics contexts persist until the end of the closure.
  2. You use the Core Graphics’ setStrokeColor(_:) to set the line color based on if it’s day at the specified time. For daytime, you set it to black, and for night, you set it to white. You use CGColor since this is a Core Graphics call.
  3. Then, you set the fill color using the dayColor and nightColor properties. You also set the line width to two points.
  4. To draw the clock face, call addEllipse(in:) on the Core Graphics context using the rectangle from earlier that defines the edges of the ellipse.
  5. Finally, you draw the path, consisting of the ellipse from step four, onto the view.

To view the clock, open LocationDetailsView.swift. Wrap VStack inside TimelineView like this:

TimelineView(.animation) { context in
  // Existing VStack
}

This creates TimelineView using the animation static identifier that updates the view as fast as possible. Change the reference to Date() in the second Text view to context.date:

Text(timeInLocalTimeZone(context.date, showSeconds: showSeconds))

Now, add the following code after the existing text fields, before the spacer:

AnalogClock(time: context.date, location: location)

This will show the new analog clock on the view. Build and run, and tap one of the cities to view its details page. You’ll see your new clock face:

App displaying info for New York with empty clock face

You’ll see a simple black or blue circle. Next, you’ll add static tick marks to help the user tell the displayed time.