Nick Kohrn Nick Kohrn - 1 month ago 8
iOS Question

Intermittent Missing Seconds with Timer

I have a timer, which is a singleton, that repeatedly fires every second. I allow the user to pause the timer as well as resume. I am keeping track of the start date of the timer, and I am subtracting any pauses from the elapsed time.

Unfortunately, I can't seem to fix an intermittent issue where pausing and resuming the timer causes a skipping of one second.

For instance, in the following code block, I start the timer and print the seconds:

1.0
2.0
3.0
4.0
5.0
6.0
7.0
8.0
9.0
10.0
11.0
12.0
13.0
14.0
15.0
16.0
17.0
18.0
19.0


In the following code block, I resume the timer, printing the seconds again. However, as you can see, the 20th second has not been printed:

21.0
22.0
23.0
24.0
25.0
26.0


I cannot seem to figure out where I am losing the second. It does not happen with each pause and resume cycle.

The properties that I am using to keep track of the aforementioned are as follows:

/// The start date of the timer.
private var startDate = Date()

/// The pause date of the timer.
private var pauseDate = Date()

/// The number of paused seconds.
private var paused = TimeInterval()

/// The number of seconds that have elapsed since the initial fire.
private var elapsed = TimeInterval()


I start the timer by creating the timer and setting the start date:

/// Starts the shower timer.
func startTimer() {
// Fire the timer every second.
timer = Timer.scheduledTimer(timeInterval: 1, target: self, selector: #selector(updateElapsedSeconds), userInfo: nil, repeats: true)
// Set the start time of the initial fire.
startDate = Date()
}


If the user pauses the timer, then the following method executes:

/// Pauses the shower timer.
func pauseTimer() {
// Pause the timer.
timer?.invalidate()
// Set the timer to `nil`, according to the documentation.
timer = nil
// Set the date of the pause.
pauseDate = Date()
}


Then, the following method executes when the user resumes the timer:

/// Resumes the timer.
func resumeTimer() {
// Recreate the timer.
timer = Timer.scheduledTimer(timeInterval: 1, target: self, selector: #selector(updateElapsedSeconds), userInfo: nil, repeats: true)
// Add the number of paused seconds to the `paused` property.
paused += Date().timeIntervalSince(pauseDate)
}


The following method, which is called by the method that executes when the timer fires, sets the number of elapsed seconds since the initial fire, less the sum of any pauses:

/// Sets the number of elapsed seconds since the timer has been started, accounting for pauses, if any.
private func updateElapsedTime() {
// Get the date for now.
let now = Date()
// Get the time that has elapsed since the initial fire of the timer, and subtract any pauses.
elapsed = now.timeIntervalSince(startDate).rounded(.down).subtracting(paused.rounded(.down))
}


Finally, the following method is the
Selector
that executes when the timer fires:

/// Updates the number of elapsed seconds since the timer has been firing.
@objc private func updateElapsedSeconds() {
// Configure the elapsed time with each fire.
updateElapsedTime()
// Post a notification when the timer fires, passing a dictionary that includes the number of elapsed seconds.
NotificationCenter.default.post(name: CustomNotification.showerTimerFiredNotification, object: nil, userInfo: nil)
}


What am I doing incorrectly to cause a missing second intermittently?

Answer

So the issue here is that Timer is not accurate in this way. Or rather, its timekeeping is reasonably accurate, but the actual rate of firing has some variance as it is dependent on the runloop.

From the documentation:

A timer is not a real-time mechanism; it fires only when one of the run loop modes to which the timer has been added is running and able to check if the timer’s firing time has passed.

To show this, I got rid of all of the rounding in your code and printed the output (you don't even need to pause to see this happen). Here is what this variance looked:

18.0004420280457
19.0005180239677
20.0004770159721
21.0005570054054
21.9997390508652
23.0003360509872
24.0003190040588
24.9993720054626
25.9991790056229

Sometimes it fires particularly late and this causes the whole thing to get thrown off. The rounding doesn't help because you are still depending on the timer for the actual reference time and eventually it will be off by more than a second.

There are a few ways to fix the situation here depending on what exactly you are trying to accomplish. If you absolutely need the actual time, you can adjust the timer to fire at fractions of a second and instead use that output to estimate the seconds a little more accurately. This is more work and will still not be totally right (there will always be a variance).

Based on your code, it seems like simply incrementing a number based on the timer should be enough to accomplish your goal. Here is a simple modification to your code making this work. This will count up simply and never skip a second in the count whether you pause or not:

/// The number of seconds that have elapsed since the initial fire.
private var elapsed = 0

private var timer: Timer?

/// Starts the shower timer.
func startTimer() {
    elapsed = 0
    // Fire the timer every second.
    timer = Timer.scheduledTimer(timeInterval: 1, target: self, selector: #selector(updateElapsedSeconds), userInfo: nil, repeats: true)
}

/// Pauses the shower timer.
func pauseTimer() {
    // Pause the timer.
    timer?.invalidate()
    // Set the timer to `nil`, according to the documentation.
    timer = nil
}

/// Resumes the timer.
func resumeTimer() {
    // Recreate the timer.
    timer = Timer.scheduledTimer(timeInterval: 1, target: self, selector: #selector(updateElapsedSeconds), userInfo: nil, repeats: true)
}

/// Sets the number of elapsed seconds since the timer has been started, accounting for pauses, if any.
private func updateElapsedTime() {
    // Get the time that has elapsed since the initial fire of the timer, and subtract any pauses.
    elapsed += 1
    // debug print
    print(elapsed)
}

/// Updates the number of elapsed seconds since the timer has been firing.
@objc private func updateElapsedSeconds() {
    // Configure the elapsed time with each fire.
    updateElapsedTime()
    // Post a notification when the timer fires, passing a dictionary that includes the number of elapsed seconds.
    NotificationCenter.default.post(name: CustomNotification.showerTimerFiredNotification, object: nil, userInfo: nil)
}