Updating a View Using a Timer -- Understanding Run Loops

tldr;

If you are using a Timer to do a repeating task and:

  1. that task updates a view, such as a label or a slider, in some way,
  2. that view is contained in a UIScrollview,
  3. 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.