Updating a View Using a Timer -- Understanding Run Loops
tldr;
If you are using a Timer
to do a repeating task and:
- that task updates a view, such as a label or a slider, in some way,
- that view is contained in a
UIScrollview
, - and that view fails to update while the scroll view is scrolling, but continues once scrolling is finished,
then you probably don't have the Timer
on the correct RunLoop
. You'll need to do something similar to the code below by adding the Timer
to the current RunLoop
to get it to keep updating while you scroll.
let timer = Timer(timeInterval: 1, repeats: true) { _ in
self.updateProgress()
}
RunLoop.current.add(timer, forMode: .common)
Explanation
You may have encountered or you may yet encounter a fairly common scenario, which is updating a view when a custom Timer
fires. It's pretty simple and can allow you to do a bunch of different things with your UI. However, there can be some tricky bits that might throw you for a loop. Today I'd like to talk about one of those situations -- when you have such a view embedded in a UIScrollView
, such as a table view, and you scroll, you might notice your view doesn't update until you stop scrolling. To give you a hint, this has to do with something called a RunLoop
.
What is a RunLoop
?
I only learned about this recently, but according to Apple docs:
A RunLoop object processes input for sources, such as mouse and keyboard events from the window system and Port objects. A RunLoop object also processes Timer events.
That last sentence is of interest to us. Timer events. The system automatically creates a RunLoop
object for each thread, including the main thread. When scrolling, the scrolling action blocks the RunLoop
for the main thread, so anything running on that thread, and therefore in that RunLoop
, won't actually run or be reflected in the UI until that main RunLoop
gets unblocked, or when the scroll view stops scrolling. The timer fires on the main thread's RunLoop
.
How does a RunLoop
relate to my view problem?
I recently saw this in an app I was working on that had a table view where each row contained a separate voicemail. You could play the voicemail and see the progress of the voicemail via a UISlider
. The slider had a timer attached to it that fired every second and updated the position. It worked great...until I started to scroll. Then it would appear to freeze until I stopped scrolling, at which point it would jump to the correct current position. So if I started scrolling at 0:03 seconds and scrolled up and down for 5 seconds, it would visually remain at 0:03 seconds for those 5 seconds and then jump to 0:08 seconds when I stopped. At least it remained accurate after the fact, but it's still not an ideal user experience. This is because my timer was getting fired on the main thread's RunLoop
, but that was being blocked by the scroll action on a different RunLoop
. The solution to this is to make sure you add the timer to the .current
RunLoop
so that it continues to run. The code might look something like this:
let timer = Timer(timeInterval: 1, repeats: true) { _ in
self.updateProgress()
}
RunLoop.current.add(timer, forMode: .common)
We start with something familiar and create a normal Timer
with an associated block of code to run once every second. Then comes the new part -- we add the timer to RunLoop.current
for the .common
mode, which is the mode used for tracking events. And that's it! Your UI components should continue to update while scrolling and your users will love it. Feel free to comment with any suggestions or questions about this or any other Swift thing.