[iOS][Swift] Adding timer on reusable UITableViewCells
A few months ago I started the job search and in my very first take-home assignment, I was asked to implement a feature to display timer on UITableViewCell
. The use-case was we were going to fetch Bitcoin prices every 60 seconds and the cell would display how long it is before we refresh the price. When I submitted it, I thought I did pretty well. After all, UI looked something like this,
However, after I started going through the live code review, I realized how brittle my solution was. I was asked to scroll the table view all the way up and bring it down triggering the cell reuse. Unfortunately, that flow broke the timer and it started showing random values completely out of sequence.
To understand why this bug happened in the first place, let's look at what I was doing,
- First, register the cell to table view
- Dequeue the cell in cellForRowAt Datasource method
- Create a view model and apply it to cell via a public
apply
method - Inside the
apply
method, create a timer, trigger it and update the cell label as the timer is fired every second. - For example, initially, it would show
Update in 60 seconds
. After each second, it would decrement that count. - Once it would reach zero, it will stop the timer, send an async request to fetch the latest value, update the cell and restart the count from 60
This flow was good as long as the cell wasn't being reused. But once the cell was reused, it would create another timer in the cell's apply
method. This and the existing timer would then simultaneously update the cell's label. Thus you can see how those values change without any sense. Actually, they make sense in their 0wn flow but not when used in conjunction with duplicate timers assigned to the same cell (Since the cell is essentially reused so no new instance is created).
If the cell is reused a third time, it will make another timer, and values will change even more rapidly.
Timer 1 | Timer 2 | Timer 3 |
60 | ||
59 | ||
58 | ||
57 | [Cell reused] | |
56 | 60 | |
55 | 59 | |
54 | 58 | [Cell reused] |
53 | 57 | 60 |
52 | 56 | 59 |
51 | 55 | 58 |
50 | 54 | 57 |
49 | 53 | 56 |
As you can see from the illustration, all three timers are running simultaneously in their own sense but cell update becomes random and chaotic when they come together.
This post is a result of my investigation of this bug and what I did to fix it. This is a bit advanced iOS topic and use-case that you don't come across very often, but it's still good to know how to handle it since it shows you are an experienced iOS developer and you know how to handle complex scenarios.
Problem:
Showing an auto-countdown timer on reusable cells does not work when cells are reused. Either earlier count is lost or it ends up creating duplicate timers
Solution:
We want to write an auto-countdown timer for cells that will retain its value even when the cell is reused and won't end up creating duplicate timers for cells. Once the timer reaches zero, it will send a request to fetch the latest value and reset the timer to start over the countdown
Architecture
One of the common mistakes people do while implementing this architecture is, they keep the state to keep track of timer values in the cell. Since the cell is reused, you may lose the previous state after reuse and it starts fresh. Also, after reuse, you may end up reusing the same state for the next cell in line - Not something you want. In short words, you want to store the timer state at a place that owns all cells, in other words - UIViewController
Since the cell is reused, the next thing you want to do is capture that cell somewhere strongly so that as the timer updates, the cell also gets updated without worrying about its reuse. For each cell that appears on view, they get their own timer to keep track of time passed without affecting other cells and connected timers.
Third thing - And this is very subtle. When you keep long-pressing on the cell or app goes in the background, the timer stops. In order to force it to keep running, you may want to register the timer using common run loop mode.
Implementation
Let's first start with cellForRowAtIndexPath
method. Every time the cell is dequeued, we will check if the cell already has an associated timer. If not, we will create it, capture the current cell and keep updating its label.
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath)
setupTimer(for: cell, row: indexPath.row)
return cell
}
private func setupTimer(for cell: UITableViewCell, row: Int) {
if cellRowToTimerMapping[row] == nil {
var numberOfSecondsPassed = 0
let timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { capturedTimer in
numberOfSecondsPassed += 1
if let visibleCell = visibleCell {
cell.textLabel?.text = "\(String(numberOfSecondsPassed)) seconds before update"
}
}
cellRowToTimerMapping[row] = capturedTimer
}
}
Please note, this also works if the current cell is reused and displayed again
Were you able to spot the bug? The timer doesn't run when the app goes in the background or when the user is interacting with the UI (Scrolling or long-press)
This is because our timer runs in default mode. As explained in this nice SO post, when user events are happening, the timer is blocked so that those events can be prioritized. In order to force run our timer during user events, let's register our timer on the current run loop with common mode.
RunLoop.current.add(timer, forMode: .common)
There is another major bug with this implementation. While we are strongly capturing the cell in a closure, it's in fact reused multiple times. So if you simply update its textLabel
with timer, you will end up updating currently visible cells with two timer values - One that is attached to cell currently not visible and the other which is visible.
To fix it, you can perform other operations in timer closure, but only update the cell which is currently visible. What you can do it, grab the cell at the given index path by using cellForRow(at
API and only update it. But also don't forget to keep non-cell-related logic unchanged.
let visibleCell = self.tableView.cellForRow(at: indexPath)
if let visibleCell = visibleCell {
visibleCell.textLabel?.text = "\(String(numberOfSecondsPassed)) seconds before update"
}
cellRowToTimerMapping[row] = timer
RunLoop.current.add(timer, forMode: .common)
Let's see it in action now,
Stop the timer after 10 seconds
Our use-case is to send a network request to get the latest data after every 10 seconds. Thus, we will keep count until the time reaches 10 and will pause the timer until the network request finishes. Let's break it down this way,
- Keep track of how many seconds elapsed
- Once 10 seconds have passed, reset the count and temporarily pause the timer
- Send (mock) network request to fetch the data. Once data comes back, resume the timer
We are already doing #1 in the above code, so let's jump right into #2
2. Once 10 seconds have passed, reset the count and temporarily pause the timer
To pause the timer after 10 seconds, we will use a boolean value to set a pause flag for every cell. Every time the timer fires, we will check if it is true or false. If it's false, proceed as usual. If it's true, return early without updating the cell. Once the timer reaches the terminal value, we will set the flag to false.
if cellRowToTimerMapping[row] == nil {
var numberOfSecondsPassed = 0
let timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { capturedTimer in
if self.cellRowToPauseFlagMapping[row] != nil && self.cellRowToPauseFlagMapping[row] == true {
return
}
numberOfSecondsPassed += 1
let visibleCell = self.tableView.cellForRow(at: indexPath)
if let visibleCell = visibleCell {
visibleCell.textLabel?.text = "\(String(numberOfSecondsPassed)) seconds before update"
}
if numberOfSecondsPassed == 10 {
numberOfSecondsPassed = 0
self.cellRowToPauseFlagMapping[row] = true
}
}
cellRowToTimerMapping[row] = capturedTime
RunLoop.current.add(timer, forMode: .common)
}
3. Send (mock) network request to fetch the data. Once data comes back, resume the timer
To send a mock network request, we will create a fake async function that takes a closure as a parameter, adds a delay, and returns back to the caller. Once data comes back, we will set the pause flag for the table view cell row to false and let the timer resume for that cell
let timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { capturedTimer in
if self.cellRowToPauseFlagMapping[row] != nil && self.cellRowToPauseFlagMapping[row] == true {
return
}
numberOfSecondsPassed += 1
if let visibleCell = visibleCell {
visibleCell.textLabel?.text = "\(String(numberOfSecondsPassed)) seconds before update"
}
if numberOfSecondsPassed == 10 {
numberOfSecondsPassed = 0
self.cellRowToPauseFlagMapping[row] = true
if let visibleCell = visibleCell {
visibleCell.textLabel?.text = "Loading..."
}
self.makeNetworkCall {
self.cellRowToPauseFlagMapping[row] = false
}
}
}
...
private func makeNetworkCall(completion: @escaping () -> Void) {
let seconds = 2.0
DispatchQueue.main.asyncAfter(deadline: .now() + seconds) {
completion()
}
}
And this even works when the app is in the background or the cell disappears,
This is an interesting question if you're applying for a Senior+ iOS positions as this question was asked to me at least 3 times during my live coding and take-home challenges. Hope you learned something new from this post. The source code is public in my Github repository and you can check it out here.
Feel free to contact me on Twitter and LinkedIn if you have any feedback or follow-up questions about this post. Thanks for reading.