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 3 of 4 of this article. Click here to view the first page.

Drawing Tick Marks

Tick marks show twelve equal intervals around the clock face to indicate hours and five-minute increments. These help the user better tell the time displayed on the clock.

You’ll need a lot of trigonometry here, but thou shalt not be afraid! You’ll walk through all the steps required.

Go back to AnalogClock.swift and add the following new method above the body of the view:

func drawTickMarks(context: CGContext, size: Double, offset: Double) {
  // 1
  let clockCenter = size / 2.0 + offset
  let clockRadius = size / 2.0
  // 2
  for hourMark in 0..<12 {
    // 3
    let angle = Double(hourMark) / 12.0 * 2.0 * Double.pi
    // 4
    let startX = cos(angle) * clockRadius + clockCenter
    let startY = sin(angle) * clockRadius + clockCenter
    // 5
    let endX = cos(angle) * clockRadius * 0.9 + clockCenter
    let endY = sin(angle) * clockRadius * 0.9 + clockCenter
    // 6
    context.move(to: CGPoint(x: startX, y: startY))
    // 7
    context.addLine(to: CGPoint(x: endX, y: endY))
    // 8
    context.strokePath()
  }
}

Separating the components of the clock into different methods helps reduce clutter. You'll pass in the Core Graphics context to the method along with the size and offset you calculated in the view's body. Here are the steps for the rest of the method:

  1. You calculate the clock face's center position by dividing the size of the clock face by two and then adding the offset you passed in.
  2. Next, set up a loop through the integers from zero to 11, one for each tick mark. Notice, again, you use a standard for-in loop instead of ForEach since you're not in a view builder.
  3. You divide the clock face into twelve equal segments. For each segment, you calculate the fraction of the full circle's diameter the current hourMark represents. Trigonometric calculations in Swift use radians. The conventional 360 degrees of a circle equals 2π radians. To determine the number of radians equivalent to the current fraction of the circle, multiply the fraction by two and by the Double.pi constant.
    Note: Technically, you must now subtract π/2 to shift the angle a quarter-circle counterclockwise. Without this adjustment, the zero angle will be to the right and not upward. For these marks, it doesn't make a difference, but if you change it to display numbers, then they would appear in the wrong positions.
  4. You use trigonometry here, but don't panic. All you need to know is that the cosine of an angle gives you the position of the horizontal part of the full radius for a point at a given angle. Sine provides the same information for the vertical position. Since you want the points positioned at an equal distance around the center of the clock face, you add the offset calculated in step one. This gives you the x and y points for the angle calculated in step three.
  5. This is the same as step four, except you multiply the radius by 0.9 to bring the point inside the clock face. The resulting tick mark runs in from the edge of the face inside to this point.
  6. With the points calculated, you move the context to the start point from step four.
  7. Next, add a line to the endpoint from step five.
  8. Draw a line along the path on the canvas.
Note: Technically, you must now subtract π/2 to shift the angle a quarter-circle counterclockwise. Without this adjustment, the zero angle will be to the right and not upward. For these marks, it doesn't make a difference, but if you change it to display numbers, then they would appear in the wrong positions.

Now, add the call to the method at the bottom of the closure, where you got the Core Graphics context:

drawTickMarks(
  context: cgContext,
  size: clockSize,
  offset: centerOffset)

Run the app, and tap any city to see the clock face with tick marks:

App showing details for Cairo with tick marks on clock face

With the tick marks in place, you can now add the hands for the clock.

Drawing Clock Hands

You'll first create a reusable method that draws all three clock hands. Add the following code after drawTickMarks(context:size:offset:):

func drawClockHand(
  context: CGContext,
  angle: Double,
  width: Double,
  length: Double
) {
  // 1
  context.saveGState()
  // 2
  context.rotate(by: angle)
  // 3
  context.move(to: CGPoint(x: 0, y: 0))
  context.addLine(to: CGPoint(x: -width, y: -length * 0.67))
  context.addLine(to: CGPoint(x: 0, y: -length))
  context.addLine(to: CGPoint(x: width, y: -length * 0.67))
  context.closePath()
  // 4
  context.fillPath()
  // 5
  context.restoreGState()
}

This method draws a clock hand at the angle, width and length specified. Make the hour, minute and second hands different by changing the width and length. Here's how the method works:

  1. saveGState() pushes a copy of the current graphics state onto a stack. You can restore the current state at a later time from the stack. Saving the state lets you easily undo the changes made during this method.
  2. When creating the tick marks, you calculated the positions of lines using trigonometry. For situations where you want to show multiple lines or shapes, this can get tedious. rotate(by:) rotates every path that follows by a specified angle in radians. Using this method, you can now draw the clock hand vertically and let this rotation handle the math to make it appear at the desired angle. Let computers do the hard work!
  3. These lines move to the center of the canvas — hold that question for a moment. It then draws a line of the specified width to the left and upward two-thirds of the full length. It continues back to the center the full length upward before mirroring the first line to the right of the center. closePath() adds a line back to the initial point at the center.
  4. You fill the shape you just defined with the current fill color.
  5. This restores the graphics state you stored in step one. It undoes the change to the angle from the rotation in step two.

Now that you have a method to draw a hand, you can draw the hour hand. Add the following code to the end of the Core Graphics closure just after the call to drawTickMarks(context:size:offset:):

// 1
cgContext.setFillColor(location.isDaytime(at: time) ?
  UIColor.black.cgColor : UIColor.white.cgColor)
// 2
cgContext.translateBy(x: clockCenter, y: clockCenter)
// 3
let angle = clockDecimalHourInLocalTz / 12.0 * 2 * Double.pi
let hourRadius = clockSize * 0.65 / 2.0
// 4
drawClockHand(
  context: cgContext,
  angle: angle,
  width: 7.5,
  length: hourRadius)

You change the fill color and calculate the information needed for the hand. Here are the details:

  1. Change the fill color to match the current line color — black for daytime and white for night.
  2. When drawing before, you added an offset for the tick marks and clock face to center them on the canvas. As with rotate(by:) above, you can also change the graphics state. translateBy(x:y:) shifts the origin of the drawing surface to the point you want to be the center of the clock. This change affects all drawing operations that follow. This shift lets you use the origin in drawClockHand(context:angle:width:length:).
  3. Calculate the angle for the given hour. Note that clockDecimalHourInLocalTz includes a fraction, so 1:30 would be 1.5. Including fractions supports the smooth motion of the clock's hands. Using rotate(by:) while drawing the hand vertically before the rotation means you don't need the shift by π/2 like you did when manually calculating angles.
  4. Call the method that draws the clock hand.

Run the app, and you see your clock hand on the clock face:

App displaying info for Shibuya with hour hand on clock

Now, use the same process to draw the other hands. After the code to draw the hour hand, add:

let minuteRadius = clockSize * 0.75 / 2.0
let minuteAngle = clockMinuteInLocalTz / 60.0 * 2 * Double.pi
drawClockHand(
  context: cgContext,
  angle: minuteAngle,
  width: 5.0,
  length: minuteRadius)

cgContext.saveGState()
cgContext.setFillColor(UIColor.red.cgColor)
let secondRadius = clockSize * 0.85 / 2.0
let secondAngle = clockSecondInLocalTz / 60.0 * 2 * Double.pi
drawClockHand(
  context: cgContext,
  angle: secondAngle,
  width: 2.0,
  length: secondRadius)
cgContext.restoreGState()

You use a larger radius multiplier and narrower width to draw the minute hand. Then, you change the fill color to red and draw a longer, narrower second hand. Save and restore the graphics state around drawing the second hand to restore the original fill color.

Run the app, and you'll see the minute and second hands along with the hour hand:

App displaying details for New York with all hands on the clock