Behind the Timer: Building a Reliable Pomodoro Countdown in Swift
Because sometimes your timer takes a nap without your consent.
TL;DR:
In this post, I take you through my journey of implementing a reliable timer for my Pomodoro app. Here’s a quick overview:
Basic Timer Implementation: The initial approach using Swift’s Timer class and why it fell short.
Lifecycle Management Issues: The challenges faced when the app transitions between foreground and background states.
State Preservation Challenges: My futile attempt to handle app background transitions with UserDefaults.
Alternative Solutions: Why DispatchSourceTimer seemed promising but wasn’t the right fit.
Final Approach: How leveraging timestamps provided a reliable solution, even with background interruptions.
Introduction
Ever wondered what goes on behind the scenes of a seemingly simple timer in your app? Probably not. I certainly didn’t - until I had to build one myself. It turns out that what we often take for granted as a basic feature can actually involve quite a bit of thought and problem-solving.
When I started developing FocusPasta, my pasta-themed Pomodoro app, the timer was one of the first features I implemented. I soon realized that this task wasn’t as simple as merely counting down seconds. Instead, it turned into a deep dive into life cycle management, forcing me to learn it the hard way.
In this post, I'll walk you through the timer strategies I explored, the challenges I faced, and the final approach that worked for FocusPasta.
1. Simple Timer with Timer
When building a timer, the most straightforward approach is to use Swift’s built-in Timer
class. This simple solution involves creating a TimerManager
class to handle the countdown functionality, allowing us to start and stop the timer, track the elapsed time, and stop once the countdown is complete.
Implementation
import Foundation | |
class TimerManager { | |
var timer: Timer? | |
let countdownDuration: Int | |
var timeElapsed: Int = 0 | |
init(countdownDuration: Int) { | |
self.countdownDuration = countdownDuration | |
} | |
func start() { | |
if timer != nil { return } | |
timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] timer in | |
guard let self = self else { return } | |
self.timeElapsed += 1 | |
if self.timeElapsed == self.countdownDuration { | |
self.stop() | |
} | |
} | |
} | |
func stop() { | |
timer?.invalidate() | |
timer = nil | |
} | |
} |
Here’s how the TimerManager
class above works:
Initialization: The class is initialized with a
countdownDuration
that sets how long the timer should run.Starting the Timer: The
start()
function creates a newTimer
instance that fires every second, increments thetimeElapsed
counter, and callsstop()
oncetimeElapsed
reachescountdownDuration
.Stopping the Timer: The
stop()
function invalidates the timer and sets it tonil
, ensuring no additional updates occur.
Sweet! Now, all there’s left to do is to create a TimerManager
instance, set countdownDuration
based on the user’s selection, and call start()
when the Start button is pressed. We have a basic timer up and running!
Unfortunately, life is not always a walk in the park.
“I used the app yesterday, and it didn’t work!!! 😡“
Rating: ★☆☆☆☆
Reviewer: Delfina (me), 22 June 2024
(an actual quote)
Why it didn’t work
Here’s an issue I didn’t expect to run into:
When the screen is locked, the timer continues running for a few more seconds and then stops! Imagine setting a timer for 60 minutes, gearing up for an intense productive session, eagerly anticipating those 2 pastas (FocusPasta rewards you a pasta every 30 minutes), only to return an hour later and find that the timer shows 59:57. The betrayal!
After digging deeper, I discovered the problem: Timer.scheduledTimer
relies on the main thread and does not continue running when the app is in the background.
Understanding the Application Lifecycle
In Swift, the application lifecycle describes the different states an app can be in: Foreground Active, Background, Suspended, and Terminated.
Here is a simple overview:
Foreground Active
Definition: This is when your app is actively in use and visible to the user.
Behavior: Your app is fully operational, and all its resources are available. The
Timer.scheduledTimer
works perfectly here because the app is running and can execute code on the main thread.
Background
Definition: This is when the user switches away from your app to another app or locks the device. Although your app is not visible, it might still be running in the background.
Behavior: In this state, your app has limited access to resources.
Timer.scheduledTimer
will not fire while your app is in the background.
Suspended
Definition: When your app is in the background for a longer period, it may enter the suspended state.
Behavior: The app is paused entirely, and no background tasks, including timers, will run.
Terminated
Definition: This is when the user or the system explicitly closes the app.
Behavior: The app is no longer running. Any active timers or background tasks will stop, and the app will need to restart from scratch if relaunched.
Unfortunately, this means that the above approach only works well if your app is always kept open on the screen. When the app enters the background, the timer will stop firing.
We’ll have to find another solution.
2. Saving Timer State
This method is so absurd that I almost considered omitting it from this blog post entirely. No one has to know! May this secret stay buried with me and my Swift journal.
But here we are, in a safe space where I’ve committed to laying bare all my raw experiences and being vulnerable about my mistakes, so here goes:
In an attempt to handle situations where the app enters the background, I came up with an elaborate solution that involves saving and restoring states using the built-in lifecycle methods and storing this data into UserDefaults
.
Here’s a brief overview:
applicationDidEnterBackground
: a built-in Swift function which is triggered when your app transitions to the background, either due to user actions like switching to another app or system events like locking the device.applicationWillEnterForeground
: a built-in Swift function which is triggered just before your app transitions from the background to the foreground.UserDefaults:
a built-in Swift mechanism for saving small pieces of data in persistent storage, like user preferences or app settings.
Implementation
import Foundation | |
func applicationDidEnterBackground(_ application: UIApplication) { | |
UserDefaults.standard.set(Date(), forKey: "enteredBackgroundTimestamp") | |
} | |
func applicationWillEnterForeground(_ application: UIApplication) { | |
if let timestamp = UserDefaults.standard.object(forKey: "enteredBackgroundTimestamp") as? Date { | |
let timeInBackground = Date().timeIntervalSince(timestamp) | |
timeElapsed += Int(timeInBackground) | |
} | |
} |
1. Saving state on background
In applicationDidEnterBackground()
, I recorded the timestamp when the app entered the background into UserDefaults
. This approach ensures that the timestamp is saved persistently and remains available even if the app is suspended, as opposed to relying solely on in-memory variables, which can be lost.
2. Restoring state on return
In applicationWillEnterForeground()
, I restored the timer state by loading the previously saved timestamp, calculating how long the app was in the background, and updating the timeElapsed
value accordingly.
Alright, go ahead and laugh.
The Verdict
Anyway, it didn’t work. For some reason, I continued to encounter the same issue.
To make matters worse, it was a flaky bug that I couldn’t consistently reproduce. Furthermore, by coincidence or otherwise, it only happened during actual usage and not while running the code in Xcode where I can see the logs (At that time, I was still using print statements for logging and hadn’t implemented a proper logging system yet). As a result, I couldn’t trace the problem.
Honestly, it seemed like a clever solution at the time, and I’d love to revisit this code and figure out why it didn’t work as expected. (If I dive into it now, FocusPasta might never make it to the App Store)
3. DispatchSourceTimer
: The Promising Alternative
I continued on my quest to find a suitable solution for the timer.
After digging around, I found that using a DispatchSourceTimer could be a suitable approach. This method allows code to run in the background thread instead of just the main thread and hence can execute even when the app is in the background, such as when the screen is locked.
Implementation
This is how the implementation would have looked like:
import Foundation | |
import Dispatch | |
class DispatchSourceTimerManager { | |
var timer: DispatchSourceTimer? | |
let countdownDuration: Int | |
var timeElapsed: Int = 0 | |
init(countdownDuration: Int) { | |
self.countdownDuration = countdownDuration | |
} | |
func start() { | |
if timer != nil { return } | |
let queue = DispatchQueue(label: "timer") | |
timer = DispatchSource.makeTimerSource(queue: queue) | |
timer?.schedule(deadline: .now(), repeating: .seconds(1)) | |
timer?.setEventHandler { [weak self] in | |
guard let self = self else { return } | |
self.timeElapsed += 1 | |
if self.timeElapsed >= self.countdownDuration { | |
self.stop() | |
} | |
} | |
timer?.activate() | |
} | |
func stop() { | |
timer?.cancel() | |
timer = nil | |
} | |
} |
Why I didn’t go ahead with it
It seems like a promising solution, but I decided against using it. It felt really unnecessary to have a timer continuously firing every second in the background, consuming your phone’s precious resources and draining your battery life.
Surely, there must be a more efficient solution.
4. Calculating Elapsed Time
Eventually, I circled back to the original solution of using the simple Timer
class, which only runs on the main thread. I was inspired by the code from the earlier approach where we calculated the amount of time spent in the background:
let timeInBackground = Date().timeIntervalSince(timestamp)
What if we leveraged this method to calculate the elapsed time? Instead of incrementing timeElapsed
, we can compute it by finding the time difference between the current time and the timer’s start time.
Revised implementation
import Foundation | |
class TimerManager { | |
var timer: Timer? | |
let countdownDuration: Int | |
var timeElapsed: Int = 0 | |
private var startTime: Date? | |
init(countdownDuration: Int) { | |
self.countdownDuration = countdownDuration | |
} | |
func start() { | |
if timer != nil { return } | |
startTime = Date() | |
timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] timer in | |
guard let self = self else { return } | |
self.timeElapsed = Int(Date().timeIntervalSince(self.startTime!)) | |
if self.timeElapsed >= self.countdownDuration { | |
self.stop() | |
} | |
} | |
} | |
func stop() { | |
timer?.invalidate() | |
timer = nil | |
} | |
} |
In this revised implementation, we save the timer’s start timestamp and use it to calculate timeElapsed
by calculating the difference between the current time and the start time, each time the timer fires. This approach ensures that the timer will always be accurate, regardless of whether the app was backgrounded.
Furthermore, thanks to the UTC timestamps, timezone changes are handled seamlessly even when you’re having a productivity session 35 000 feet up in the air.
Finally, we have a functioning timer that reliably tracks the elapsed time!
Limitations of our solution
This solution is not perfect, either.
For example, if the user decides to change their device’s date and time settings in the middle of a Pomodoro session (but why? 🤨), timeElapsed
will become inaccurate.
Similarly, automatic synchronization to the system clock, such as when connecting to a new network, might affect the timer’s precision and throw it off by a few milliseconds. But come on, we are building a Pomodoro timer here, not the Large Hadron Collider.
Additionally, this approach works because FocusPasta doesn’t have a pause functionality. If we ever decide to add a pause button (unlikely), the complexity of this implementation will increase, but let’s save that for another day.
Conclusion
As always, every solution comes with its own set of trade-offs. For FocusPasta, I chose to prioritize simplicity (and my sanity) over absolute precision. While this approach serves the majority of users effectively, users who derive enjoyment from adjusting their system clocks in the midst of a focus session may have to seek alternative apps that better fit their peculiar needs.
The key takeaway from this post is the importance of evaluating whether a solution meets your specific needs - don’t just copy-paste from the internet! After all, each app is unique, just like how each of us is unique in our own ways (wait, what?)