What are run loop modes, and why does a Timer stop firing while the user scrolls?
Predict first
What do these two lines print?
import Foundation
import CoreFoundation
let tracking = RunLoop.Mode("Tracking")
var fired: [String] = []
let a = Timer(timeInterval: 0.02, repeats: false) { _ in fired.append("default-timer") }
RunLoop.current.add(a, forMode: .default)
RunLoop.current.run(mode: tracking, before: Date(timeIntervalSinceNow: 0.15))
print(fired)
let b = Timer(timeInterval: 0.02, repeats: false) { _ in fired.append("common-timer") }
CFRunLoopAddCommonMode(CFRunLoopGetCurrent(), CFRunLoopMode(tracking.rawValue as CFString))
RunLoop.current.add(b, forMode: .common)
RunLoop.current.run(mode: tracking, before: Date(timeIntervalSinceNow: 0.15))
print(fired)
- [] then ["default-timer", "common-timer"]
- [] then ["common-timer"]
- ["default-timer"] then ["default-timer", "common-timer"]
Mental model. Modes are different “shifts” at the restaurant. A task assigned only to the lunch shift does not happen during dinner unless it is marked for every shift.
A run loop runs in one mode at a time, and each mode has its own set of sources, timers and observers; only the ones registered for the current mode fire. The main loop normally runs in the default mode. While a touch is tracking a scroll, UIKit switches it to a tracking mode, and anything registered only for the default mode does not run until scrolling ends. That is why a Timer.scheduledTimer, which schedules in the default mode, freezes mid-scroll and then fires late.
The fix is the common modes: a pseudo-mode that stands for a set of real ones (default and tracking among them). Add the timer with RunLoop.main.add(timer, forMode: .common) and it fires in all of them. Use that for UI-driven timers (a progress label, a ticker), and keep the default mode for work that should pause while the user interacts. Modes also scope other sources, so a mode you invent only runs what you put in it, which is how modal run loops keep unrelated work from interfering.
While the loop runs in the “Tracking” mode the default-mode timer is ignored. Once that mode joins the common set, a timer added for common modes fires there.
Common traps
- Scheduling a UI timer in the default mode only. It stalls during scrolling and tracking.
- Adding a timer to a custom mode and wondering why it never fires. Nothing runs the loop in that mode.
They might follow up with
- Which API schedules in common modes by default? Many newer APIs do;
Timer.scheduledTimeruses the default mode only, so add the timer manually. - How does
CADisplayLinkrelate? You add it to a run loop and mode like a timer, and common modes keep it running during scrolling.
Related questions
- 6.3.1 What is a run loop, and what happens in one iteration?
- 6.3.3 What is the main-thread contract, and why does DispatchQueue.main.sync from the main thread crash?
← What is a run loop, and what happens in one iteration? What is the main-thread contract, and why does… →