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

8. Time-Based Animations
Written by Bill Morefield

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

Using SwiftUI effectively requires adapting an app’s UI based on its state. The animations you’ve used in the app for the last few chapters all animate based on state changes. The timer view you created in Chapter 6: Intro to Custom Animations used Combine to create a timer and publish events your view used to trigger changes to the timer. In earlier versions of SwiftUI, if you wanted to update a view at regular intervals, like a timer, then you had to use this method.

Then TimelineView arrived. Instead of providing layout or control, TimelineView acts only as a container that redraws its content at scheduled points in time, regardless of any state changes. The same version of SwiftUI also added the Canvas view, which provides a way to produce efficient 2D graphics inside a SwiftUI view. In this chapter, you’ll combine these elements to create an animated analog timer for your tea brewing app.

Exploring the TimelineView

Open the starter project for this chapter, and you’ll see the Brew Timer app from the past few chapters. Open AnalogTimerView.swift under the Timer group. Don’t worry about all the commented code. You’ll use it in a minute.

Now replace the view’s body with:

TimelineView(.periodic(
  from: .now,
  by: 1.0
)) { context in
  Text(context.date.formatted(
    date: .omitted,
    time: .complete
  ))
}

The argument to the Timeline view provides a schedule that SwiftUI uses to update its content. Here you use the periodic(from:by:) schedule to specify the view should start updating immediately and refresh once per second.

The context provided to the closure provides the date that triggered in its date property. You use formatted(date:time:) to show only the time component of the property. In the preview, you’ll see that you built a functional clock in just three lines of code.

Simple text based clock.
Simple text based clock.

Note that the view contains no state information and updates without any state to change. That’s the power of the TimelineView. It lets you create a view that updates based on time, not state.

Now add the following code to the top of the view:

@State var timerLength = 0.0
@State var timeLeft: Int?
@State var status: TimerStatus = .stopped
@State var timerEndTime: Date?

You will later set the initial value of timerLength to the value of the passed in timer and add a control letting the user adjust it. The other three properties will track the status and remaining time for the timer when active.

Next, uncomment the three methods in the view by selecting them and selecting Editor ▸ Structure ▸ Comment Selection, or pressing Command-/. These methods calculate the amount of time remaining on the timer based on the status and timerEndTime of the timer. Go to AnalogTimerView.swift and replace its body:

VStack {
  // 1
  Slider(value: $timerLength, in: 0...600, step: 15)
  // 2
  TimerControlView(
    timerLength: timerLength,
    timeLeft: $timeLeft,
    status: $status,
    timerEndTime: $timerEndTime,
    timerFinished: $timerFinished
  )
  .font(.title)
  // Place timeline here
}
.onAppear {
  // 3
  timerLength = Double(timer.timerLength)
}

Here’s how the start of your timer works:

  1. You provide a Slider so the user can adjust the default timer length. Note that the value bound to the slider must be a Double though you’ll convert it to an Int when used.

  2. You use the already provided TimerControlView, which manages the state of the timer and provides buttons to control it.

  3. When the VStack first appears, you set the timerLength. Notice the need to convert to a Double as mentioned in step one.

Next, add the new timer view in place of // Place timeline here:

TimelineView(.periodic(
  from: .now,
  by: 1
)) { context in
  let timeString = timeLeftString(timeLeftAt(context.date))
  Text(timeString)
    .font(.title)
}

This TimelineView updates once per second. Within the view, you use the timeLeftAt(_:) uncommented earlier to get the number of seconds remaining on the timer and then convert that to a formatted string using timeLeftString(_:). It then displays the string on the view.

Notice that you include only the part of the view affected by time change inside the closure. Since SwiftUI updates all views contained inside the closure of a TimelineView, including views that don’t change decreases performance without adding any benefit.

Run the app, and you’ll see it already uses the new AnalogTimerView. Start the timer. It works much like before and displays the remaining time as text.

Steeping Timer with New Timer View
Steeping Timer with New Timer View

Open TimerView.swift and notice how much less code you need now that SwiftUI updates the views based on time. In addition, you no longer need the TimerManager class. As you can see, TimelineView greatly simplifies updating a time-based app. In the next section, you’ll see how to draw graphics using a Canvas view.

Drawing With a Canvas

All animations are consecutive images that change over time to provide the illusion of movement. Most of the examples in this book change a view’s state and then allow SwiftUI to manage the process of translating that state change into animation.

func drawBorder(context: GraphicsContext, size: Int) {
  // 1
  let timerSize = CGSize(width: size, height: size)
  // 2
  let outerPath = Path(
    ellipseIn: CGRect(origin: .zero, size: timerSize)
  )
  // 3
  context.stroke(
    outerPath,
    with: .color(.black),
    lineWidth: 3
  )
}
// 1
ZStack {
  // 2
  Canvas { gContext, size in
    // 3
    let timerSize = Int(min(size.width, size.height))
    drawBorder(context: gContext, size: timerSize)
  }
}
.padding()
Timer with border.
Mabay heln vepden.

let timerSize = Int(min(size.width, size.height) * 0.95)
// 4
let xOffset = (size.width - Double(timerSize)) / 2.0
// 5
let yOffset = (size.height - Double(timerSize)) / 2.0
// 6
gContext.translateBy(x: xOffset, y: yOffset)
Timer centered in the containing view.
Pasiq vabqojud ow nle sagtiedexz geux.

Drawing Tick Marks

Tick marks help the user interpret the position of the timer’s hands. In this section, you’ll add tick marks to the timer.

func drawMinutes(context: GraphicsContext, size: Int) {
  // 1
  let center = Double(size / 2)

  // 2
  for minute in 0..<10 {
    // 3
    let minuteAngle = Double(minute) / 10 * 360.0
    // 4
    let minuteTickPath = Path { path in
      path.move(to: .init(x: center, y: 0))
      path.addLine(to: .init(x: center * 0.9, y: 0))
    }
  }
}
// 4
var tickContext = context
// 6
tickContext.rotate(by: .degrees(-minuteAngle))
// 7
tickContext.stroke(
  minuteTickPath,
  with: .color(.black)
)
gContext.translateBy(
  x: Double(timerSize / 2),
  y: Double(timerSize / 2)
)
gContext.rotate(by: .degrees(-90))
drawMinutes(context: gContext, size: timerSize)
Timer with tick marks.
Qutiq sanh diyb yermr.

Adding Text to a Canvas

While the tick marks help the user interpret the timer’s position, adding numbers increases understanding by clarifying the time for a given tick mark. Fortunately, adding text to a canvas isn’t much more complex than adding other elements.

// 1
let minuteString = "\(minute)"
let textSize = minuteString.calculateTextSizeFor(
  font: UIFont.preferredFont(forTextStyle: .title2)
)
// 2
let textRect = CGRect(
  origin: .init(
    x: -textSize.width / 2.0,
    y: -textSize.height / 2.0
  ),
  size: .zero
)
// 3
let minuteAngleRadians = Angle(degrees: minuteAngle - 90).radians
// 4
let xShift = sin(-minuteAngleRadians) * center * 0.8
let yShift = cos(-minuteAngleRadians) * center * 0.8
// 5
var stringContext = context
stringContext.translateBy(x: xShift, y: yShift)
stringContext.rotate(by: .degrees(90))
// 6
let resolvedText = stringContext.resolve(
  Text(minuteString).font(.title2)
)
// 7
stringContext.draw(resolvedText, in: textRect)
Timer with numbers added.
Betud moxc fohmazd uycum.

Letting the Timer… Time

You want to animate the hands of the timer, so once you draw them, you’ll also wrap them inside a TimelineView to control the timing of their movement.

// 1
TimelineView(.animation) { timeContext in
  // 2
  Canvas { gContext, size in
    // 3
    let timerSize = Int(min(size.width, size.height))
    gContext.translateBy(
      x: size.width / 2,
      y: size.height / 2
    )
    gContext.rotate(by: .degrees(-90))
  }
}
func createHandPath(
  length: Double,
  crossDistance: Double,
  middleDistance: Double,
  endDistance: Double,
  width: Double
) -> Path {
  // 1
  Path {
    path.move(to: .zero)

    // 2
    let halfWidth = width / 2.0
    let crossLength = length * crossDistance
    let middleLength = length * middleDistance
    let halfWidthLength = length * halfWidth

    // 3
    path.addCurve(
      to: .init(x: crossLength, y: 0),
      control1: .init(x: crossLength, y: -halfWidthLength),
      control2: .init(x: crossLength, y: -halfWidthLength)
    )
    path.addCurve(
      to: .init(x: length * endDistance, y: 0),
      control1: .init(x: middleDistance, y: halfWidthLength),
      control2: .init(x: middleDistance, y: halfWidthLength)
    )
    path.addCurve(
      to: .init(x: crossLength, y: 0),
      control1: .init(x: middleDistance, y: -halfWidthLength),
      control2: .init(x: middleDistance, y: -halfWidthLength)
    )
    path.addCurve(
      to: .zero,
      control1: .init(x: crossLength, y: halfWidthLength),
      control2: .init(x: crossLength, y: halfWidthLength)
    )
  }
}
func drawHands(
  context: GraphicsContext,
  size: Int,
  remainingTime: Double
) {
  // 1
  let length = Double(size / 2)
  // 2
  let secondsLeft = remainingTime.truncatingRemainder(dividingBy: 60)
  // 3
  let secondAngle = secondsLeft / 60 * 360

  // 4
  let minuteColor = Color("DarkOliveGreen")
  let secondColor = Color("BlackRussian")

  let secondHandPath = createHandPath(
    length: length,
    crossDistance: 0.4,
    middleDistance: 0.6,
    endDistance: 0.7,
    width: 0.07
  )
}
var secondContext = context
secondContext.rotate(by: .degrees(secondAngle))
secondContext.fill(
  secondHandPath,
  with: .color(secondColor)
)
secondContext.stroke(
  secondHandPath,
  with: .color(secondColor),
  lineWidth: 3
)
let remainingSeconds = decimalTimeLeftAt(timeContext.date)
drawHands(
  context: gContext,
  size: timerSize,
  remainingTime: remainingSeconds
)
Timer with sweeping second hand.
Hubir nozx vruonusx vucavc rezn.

Adding the Minute Hand

Add the following code to the end of drawHands(context:size:remainingTime:):

// 1
let minutesLeft = remainingTime / 60
// 2
let minuteAngle = minutesLeft / 10 * 360
// 3
let minuteHandPath = createHandPath(
  length: length,
  crossDistance: 0.3,
  middleDistance: 0.5,
  endDistance: 0.6,
  width: 0.1
)
// 4
var minuteContext = context
minuteContext.rotate(by: .degrees(minuteAngle))
minuteContext.fill(
  minuteHandPath,
  with: .color(minuteColor)
)
minuteContext.stroke(
  minuteHandPath,
  with: .color(minuteColor),
  lineWidth: 5
)
Timer with second and minute hands.
Vevoy duhd cebudv ijq bexiwi yuzzw.

Improving TimelineView Performance

A SwiftUI view should never update more often than it needs to. Right now, your TimelineView updates as often as SwiftUI can manage. In most cases, that’s more often than necessary for the desired user experience and wastes resources.

TimelineView(.periodic(
  from: .now,
  by: 1)
) { timeContext in
Timer with ticking second hand.
Zirip gadt qeqgops xenevh gaxw.

TimelineView(
  .animation(minimumInterval: 0.1)
) { timeContext in
TimelineView(
  .animation(
    minimumInterval: 0.1,
    paused: status != .running
  )
) { timeContext in
Timer after performance improvements.
Cekiq onpeg yekbujfibla ijfhasalupms.

Challenge

Using what you learned in this chapter, add tick marks and numbers for the second hand to the timer. See one solution in the challenge project for this chapter.

Key Points

  • A TimelineView redraws its content at scheduled points in time. You can specify this schedule in several ways or create a custom implementation for complex scenarios.
  • Canvas lets you produce two-dimensional graphics inside a view. It resembles the pre-SwiftUI Core Graphics framework, though it still works with SwiftUI elements. You can call Core Graphics for complex methods or legacy code if needed.
  • A Canvas also supplies a GraphicsContext within its closure. Methods that modify the GraphicsContext such as translateBy(x:y:) and rotate(by:) persist those changes to future drawing operations.
  • You can create a mutable copy of a GraphicsContext. Since it’s a value type, any changes you make to the copy won’t affect the original GraphicsContext. You can use this to change a GraphicsContext without affecting its initial state.
  • The resolve(_:) method on GraphicsContext helps you produce a text view that’s fixed with the current values of the graphics context’s environment. You can use this to change a SwiftUI Text view, including modifiers, to a format compatible with a GraphicsContext.

Where to Go From Here?

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