iOS Timer Tutorial

In this iOS Timer tutorial, you’ll learn how timers work, affect UI responsiveness and battery and how to work with animations using CADisplayLink.

5/5 1 Rating · Leave a Rating

Version

  • Swift 4.2, iOS 12, Xcode 10

Imagine that you’re working on an app where you need a certain action to be triggered in the future – maybe even repeatedly. It would be helpful to have a concept in Swift that offers this functionality, right? That’s exactly where Swift’s Timer class comes into the picture.

It is common to use Timer to schedule things in your app. This could be, for example, a one-time event or something that happens periodically.

In this tutorial, you’ll learn exactly how Timer works in iOS, how it affects the UI responsiveness, how to improve device power usage working with Timer and how to use CADisplayLink for animations.

This tutorial walks you through building a ToDo app. The ToDo app tracks the progression of time for a task until a task is completed. Once the task is completed, the app congratulates the user with a simple animation.

Ready to explore the magic world of timers in iOS? Time to dive in!

Getting Started

Download the project materials using the Download Materials button at the top or bottom of this tutorial.

Open the starter project and check out the project files. Build and run. You’ll see a simple ToDo app:

First, you’ll create a new task in your app. Tap the + tab bar button to add a new task. Enter a name for your task (e.g., “Buy Groceries”). Tap OK. Great!

Added tasks will reflect a time signature. The new task you created is labeled zero seconds. You’ll note that the seconds label does not increment at the moment.

In addition to adding tasks, you can mark them as complete. Tap on the task you created. Doing so crosses out a task name and labels the task as completed.

Creating Your First Timer

For your first task, you’ll create the app’s main timer. As mentioned above, Swift’s Timer class, otherwise known as NSTimer, is a flexible way to schedule work to happen at some point in the future, either periodically or just once.

Open TaskListViewController.swift and add the following variable to TaskListViewController:

var timer: Timer?

Then, declare an extension at the bottom of TaskListViewController.swift:

// MARK: - Timer
extension TaskListViewController {

}

And add the following code to the TaskListViewController extension:

@objc func updateTimer() {
  // 1
  guard let visibleRowsIndexPaths = tableView.indexPathsForVisibleRows else {
    return
  }

  for indexPath in visibleRowsIndexPaths {
    // 2
    if let cell = tableView.cellForRow(at: indexPath) as? TaskTableViewCell {
      cell.updateTime()
    }
  }
}

This method:

  1. Checks if there is any visible row in tableView containing tasks.
  2. Calls updateTime for every visible cell. This method updates the cell time. Have a look at what it does in the TaskTableViewCell.swift file.

Now, add the following code to the TaskListViewController extension:

func createTimer() {
  // 1
  if timer == nil {
    // 2
    timer = Timer.scheduledTimer(timeInterval: 1.0,
                                 target: self,
                                 selector: #selector(updateTimer),
                                 userInfo: nil,
                                 repeats: true)
  }
}

Here, you:

  1. Check if timer contains an instance of a Timer.
  2. If not, set timer to a repeating Timer that calls updateTimer() every second.

Next, you need to create a timer whenever a user adds a task. To do this, call the following method as the first line of code in presentAlertController(_:):

createTimer()

Build and run your app.

To test your work, create a couple of new tasks using the same steps as before.

You’ll note that the table view cell’s time label now updates the elapsed time every second.

Adding Timer Tolerance

Increasing the number of timers in your app increases the risk for less app responsiveness and more power usage. Every Timer tries to fire itself at a precise one-second mark each second. This is because a Timer‘s default tolerance value is zero.

Adding tolerance to a Timer is an easy way to reduce the energy impact it has on your app. It lets the system fire the timer any time between the scheduled fire date and the scheduled fire date plus tolerance time — never before the scheduled fire date.

For repeating timers, the system calculates the next fire date from the original fire date, ignoring the tolerance applied at individual fire times. This is to avoid time drift.

To avoid any side effects of decreased app responsiveness and increased power usage in your app, you can set the timer property’s tolerance to 0.1.

In createTimer(), right after setting the timer to a Timer, add this line of code:

timer?.tolerance = 0.1

Build and run.

The changes may not be visually obvious. However, your users will benefit from the app responsiveness and power efficiency.

Trying Out Timers in the Background

You may wonder what happens to a timer when your app goes to the background.

To investigate this, add the following code as the first line of updateTimer():

if let fireDateDescription = timer?.fireDate.description {
  print(fireDateDescription)
}

This lets you see when a Timer fires from the Console.

Build and run. Next, add a task as you have before. Return to your device home screen, then open the ToDo app again.

You will see something similar to this in the Console:

As you can see, when your app enters the background, iOS pauses any running timers. And when the app enters the foreground again, iOS resumes the timers.

Understanding Run Loops

A run loop is an event-processing loop that schedules work and manages the receiving of incoming event. A run loop keeps a thread busy when there is work, and it puts a thread to sleep when there is an absence of work.

Every time you launch your app on iOS, the system creates a Thread — the main thread. Each Thread has a RunLoop automatically created for it as needed.

But why is this relevant for you? Currently, each Timer fires on the main thread and is attached to a RunLoop. As you may know, the main thread is in charge of drawing the user interface, listening for touches and such. When the main thread is busy with other things your app’s UI may become unresponsive and behave unexpectedly.

Did you notice that the task cell’s elapsed time label pauses when you drag the table view? There is a lag in which the timer does not fire when you scroll the table view.

A solution to this problem is to set the RunLoop to run timers with a different mode. More on this next!

Utilizing Run Loop Modes

A run loop mode is a collection of input sources (such as screen touches or mouse clicks) and timers that can be observed, as well as a collection of run loop observers to be notified when events happen.

There are three run loop modes in iOS:

  1. default: Handles input sources that are not NSConnectionObjects.
  2. common: Handles a set of run loop modes for which you can define a set of sources, timers and observers.
  3. tracking: Handles the app’s responsive UI.

For the purposes of your app, the common run loop mode sounds like the best match. In order to use it, go to createTimer() and replace its contents with the following code:

if timer == nil {
  let timer = Timer(timeInterval: 1.0,
                    target: self,
                    selector: #selector(updateTimer),
                    userInfo: nil,
                    repeats: true)
  RunLoop.current.add(timer, forMode: .common)
  timer.tolerance = 0.1
  
  self.timer = timer
}

The main difference between this snippet and the previous code is that the new code adds the timer on the run loop in common mode before setting the TaskListViewController‘s timer.

Now, build and run!

Congratulations, your table view cells’ time labels are responsive even when you are scrolling the table view!

Adding a Task Completion Animation

Now, you’ll add a congratulations animation when your user completes all tasks.

You’ll create a custom animation — a balloon that goes from the bottom to the top of the screen!

Add the following variables to the top of TaskListViewController:

// 1
var animationTimer: Timer?
// 2
var startTime: TimeInterval?, endTime: TimeInterval?
// 3
let animationDuration = 3.0
// 4
var height: CGFloat = 0

These variables’ purposes are to:

  1. Handle the animation timer.
  2. Take care of the animation start time and end time.
  3. Specify the animation duration.
  4. Handle the animation height.

Now, add the following TaskListViewController extension code to the end of TaskListViewController.swift:

// MARK: - Animation
extension TaskListViewController {
  func showCongratulationAnimation() {
    // 1
    height = UIScreen.main.bounds.height + balloon.frame.size.height
    // 2
    balloon.center = CGPoint(x: UIScreen.main.bounds.width / 2,
      y: height + balloon.frame.size.height / 2)
    balloon.isHidden = false

    // 3
    startTime = Date().timeIntervalSince1970
    endTime = animationDuration + startTime!

    // 4
    animationTimer = Timer.scheduledTimer(withTimeInterval: 1 / 60, 
      repeats: true) { timer in
      // TODO: Animation here
    }
  }
}

In the code above, you:

  1. Calculate the right height for the animation based on the device’s screen height.
  2. Center the balloon outside of the screen and set its visibility.
  3. Create the startTime and calculate the endTime by adding the animationDuration to the startTime.
  4. Start the animation timer and have it update the progress of the animation 60 times per second with a block-based Timer API.

Next, you need to create the logic for updating the congratulations animation. To do this, add the following code after showCongratulationAnimation():

func updateAnimation() {
  // 1
  guard
    let endTime = endTime,
    let startTime = startTime 
    else {
      return
  }

  // 2
  let now = Date().timeIntervalSince1970

  // 3
  if now >= endTime {
    animationTimer?.invalidate()
    balloon.isHidden = true
  }

  // 4
  let percentage = (now - startTime) * 100 / animationDuration
  let y = height - ((height + balloon.frame.height / 2) / 100 * 
    CGFloat(percentage))

  // 5
  balloon.center = CGPoint(x: balloon.center.x + 
    CGFloat.random(in: -0.5...0.5), y: y)
}

Here, you:

  1. Check that the endTime and startTime are not nil.
  2. Save the current time to a constant.
  3. Ensure that the current time has not passed the end time. And in case it has, invalidate the timer and hide the balloon.
  4. Calculate the animation percentage and the desired y-coordinate the balloon should be moved to.
  5. Set the balloon’s center position based on previous calculations.

Now, replace // TODO: Animation here in the showCongratulationAnimation() with this code:

self.updateAnimation()

updateAnimation() is now called every time the animation timer fires.

Congratulations, you have created a custom animation! However, nothing new happens when you build and run the app…

Showing the Animation

As you may have guessed, nothing is triggering your newly created animation at the moment. To fire it off, you need just one more method. Add this code in the TaskListViewController animation extension:

func showCongratulationsIfNeeded() {
  if taskList.filter({ !$0.completed }).count == 0 {
    showCongratulationAnimation()
  }
}

This will be called every time a user completes a task; it checks if all the tasks have been completed. If so, it calls showCongratulationAnimation().

To finish up, add the following method as the last line of tableView(_:didSelectRowAt:):

showCongratulationsIfNeeded()

Build and run.

Create a couple of tasks.

Tap on all the tasks to mark them as completed.

You should see the balloon animation!

Stopping a Timer

If you’ve glanced at the Console, you may have noticed that, even if the user has marked all the tasks as completed, the timer still continues to fire. It’s better to stop the timer for completed tasks to reduce battery drain.

First, create a new method for canceling the timer by adding the following code inside the // MARK: - Timer extension:

func cancelTimer() {
  timer?.invalidate()
  timer = nil
}

This will invalidate the timer. And, it will set it to nil so you can correctly reinitialize it again later. invalidate() is the only way to remove a Timer from a RunLoop. The RunLoop removes its strong reference to the timer either before invalidate() returns or at a later point.

Next, replace showCongratulationsIfNeeded() with the following code:

func showCongratulationsIfNeeded() {
  if taskList.filter({ !$0.completed }).count == 0 {
    cancelTimer()
    showCongratulationAnimation()
  } else {
    createTimer()
  }
}

Now, if the user completes all tasks, the app will first invalidate the timer and then show the animation; otherwise, you will try to create a new timer if one isn’t already running. This will avoid a bug when the user completes all the tasks and then creates a new one.

Build and run!

Now, the timer stops and restarts as desired.

Using CADisplayLink for Smoother Animations

Timer may not be the ideal solution for animations. You may already have noticed some frame drops during the animation — especially if you are running the app on the Simulator.

You previously set the timer at 60Hz (1 / 60). Hence, your timer will call your animation every 16ms. Take a look at the time line below:

By using Timer, you can’t be sure of the exact time an action will be triggered. It may be at the start or the end of the frames. To keep things simple, say you set the timer at the middle of each frame (the blue dots). Because it’s hard to know where the exact time of the timer, you can only be sure that you’ll get the callback every 16ms.

You now have 8ms to do your animation; this may or may not be enough time for your animation frames. Look at the second frame from the time line above. The second frame can’t be executed in time for the frame rendering. Consequently, your app will drop the second frame. You are also currently using only 8ms instead of the available 16ms.

CADisplayLink to the Rescue!

CADisplayLink is called once per frame and will try to synchronize with the real screen frames as much as possible. With that, you’ll have full access to all 16ms available and you’ll be sure iOS won’t drop any frames. Even on the new iPads with a ProMotion display at 120Hz, you’ll not miss a frame!

To use CADisplayLink, you must replace animationTimer with a new type.

Replace the following code:

var animationTimer: Timer?

With the following code:

var displayLink: CADisplayLink?

You have replaced the Timer with CADisplayLink. CADisplayLink is a timer representation that is bound to the display’s vsync. This means that the GPU of the device will stall until the physical screen is ready to process more GPU commands. That way, you ensure a smoother animation.

Replace the following code:

var startTime: TimeInterval?, endTime: TimeInterval?

With the following code:

var startTime: CFTimeInterval?, endTime: CFTimeInterval?

You have replaced TimeInterval optionals with CFTimeInterval optionals to store time elapsed in seconds and work with CADisplayLink nicely.

Replace showCongratulationAnimation() with the following code:

func showCongratulationAnimation() {
  // 1
  height = UIScreen.main.bounds.height + balloon.frame.size.height
  balloon.center = CGPoint(x: UIScreen.main.bounds.width / 2, 
    y: height + balloon.frame.size.height / 2)
  balloon.isHidden = false

  // 2
  startTime = CACurrentMediaTime()
  endTime = animationDuration + startTime!

  // 3
  displayLink = CADisplayLink(target: self, 
    selector: #selector(updateAnimation))
  displayLink?.add(to: RunLoop.main, forMode: .common)
}

In the code above, you:

  1. Set the animation height, set the balloon center position, and make the animation visible – just like you previously did.
  2. Initialize startTime with CACurrentMediaTime() (instead of Date()).
  3. Set displayLink to a CADisplayLink. Then, add displayLink to the main RunLoop with common mode.

Next, replace the updateAnimation() with the following code:

// 1
@objc func updateAnimation() {
  guard
    let endTime = endTime,
    let startTime = startTime 
    else {
      return
  }
    
  // 2
  let now = CACurrentMediaTime()
  
  if now >= endTime {
    // 3
    displayLink?.isPaused = true
    displayLink?.invalidate()
    balloon.isHidden = true
  }
    
  let percentage = (now - startTime) * 100 / animationDuration
  let y = height - ((height + balloon.frame.height / 2) / 100 * 
    CGFloat(percentage))
    
  balloon.center = CGPoint(x: balloon.center.x + 
    CGFloat.random(in: -0.5...0.5), y: y)
}

Here, you:

  1. Add @objc to the method signature. This is because CADisplayLink has a selector parameter that requires an Objective-C selector.
  2. Replace the Date() initialization with a CoreAnimation date. CACurrentMediaTime returns the current absolute time in seconds.
  3. Change animationTimer.invalidate() call with the CADisplayLink‘s pause and invalidate. This will also remove the display link from all run loop modes and have the display link release its target.

Build and run one last time!

Good job! You have now successfully replaced a Timer based animation with a CADisplayLink in order to create a smoother animation. The difference is small, but users really enjoy smooth and seamless animations — even on older devices.

Where to Go From Here?

You can download the finished project using the Download Materials button at the top or bottom of this tutorial.

In this tutorial, you’ve learned how the Timer class works on iOS, what a RunLoop is and how they can help you with your app’s responsiveness, and to use CADisplayLink instead of Timer for smooth animations.

If you want to learn more about scheduling tasks, check out Grand Central Dispatch Tutorial for Swift 4: Part 1/2 and Grand Central Dispatch Tutorial for Swift 4: Part 2/2. Or, if you want to know more about animations, check out our book iOS Animations by Tutorials.

If you have any questions or comments about this tutorial, please join the forum discussion below!

Average Rating

5/5

Add a rating for this content

1 rating

Contributors

Comments