87

While a UIScrollView (or a derived class thereof) is scrolling, it seems like all the NSTimers that are running get paused until the scroll is finished.

Is there a way to get around this? Threads? A priority setting? Anything?

Cœur
  • 37,241
  • 25
  • 195
  • 267
mcccclean
  • 7,721
  • 10
  • 32
  • 36
  • 1
    Possible duplicate of [Why does UIScrollView pause my CADisplayLink?](http://stackoverflow.com/questions/12622800/why-does-uiscrollview-pause-my-cadisplaylink) – Fattie Dec 29 '16 at 14:00
  • seven years later ... http://stackoverflow.com/a/12625429/294884 – Fattie Dec 29 '16 at 14:00

8 Answers8

206

An easy & simple to implement solution is to do:

NSTimer *timer = [NSTimer timerWithTimeInterval:... 
                                         target:...
                                       selector:....
                                       userInfo:...
                                        repeats:...];
[[NSRunLoop mainRunLoop] addTimer:timer forMode:NSRunLoopCommonModes];
Kashif Hisam
  • 2,201
  • 1
  • 15
  • 6
  • 2
    UITrackingRunLoopMode is the mode - and its public - you can use to create different timer behaviours. – Tom Andersen Jul 28 '10 at 20:08
  • Love it, works like a charm with GLKViewController as well. Just set controller.paused to YES when the UIScrollView appears and start your own timer as described. Reverse it when the scrollview is dismissed and thats it! My update/render loop isn't very expensive, so that might help. – Jeroen Bouma Feb 01 '13 at 11:55
  • 3
    Awesome! Do i have to remove the timer when i invalidate it or not? Thanks – Jacopo Penzo Sep 02 '15 at 02:18
  • This works for scrolling but stops timer when app goes into the background. Any solution for achieving both? – Pulkit Sharma Aug 01 '19 at 10:55
23

For anyone using Swift 3

timer = Timer.scheduledTimer(timeInterval: 0.1,
                            target: self,
                            selector: aSelector,
                            userInfo: nil,
                            repeats: true)


RunLoop.main.add(timer, forMode: RunLoopMode.commonModes)
Isaac
  • 1,442
  • 17
  • 26
  • 8
    It is better to call `timer = Timer(timeInterval: 0.1, target: self, selector: aSelector, userInfo: nil, repeats: true)` as the first command instead of `Timer.scheduleTimer()`, because `scheduleTimer()` adds timer to runloop, and next call is another adding to the same runloop but with different mode. Don't do same work twice. – Accid Bright Jun 08 '18 at 15:04
13

tl;dr the runloop is handing scroll related events. It can't handle any more events — unless you manually change the timer's config so the timer can be processed while runloop is handling touch events. OR try an alternate solution and use GCD


A must read for any iOS developer. Lots of things are ultimately executed through RunLoop.

Derived from Apple's docs.

What is a Run Loop?

A run loop is very much like its name sounds. It is a loop your thread enters and uses to run event handlers in response to incoming events

How delivery of events are disrupted?

Because timers and other periodic events are delivered when you run the run loop, circumventing that loop disrupts the delivery of those events. The typical example of this behavior occurs whenever you implement a mouse-tracking routine by entering a loop and repeatedly requesting events from the application. Because your code is grabbing events directly, rather than letting the application dispatch those events normally, active timers would be unable to fire until after your mouse-tracking routine exited and returned control to the application.

What happens if timer is fired when run loop is in the middle of execution?

This happens A LOT OF TIMES, without us ever noticing. I mean we set the timer to fire at 10:10:10:00, but the runloop is executing an event which takes till 10:10:10:05, hence the timer is fired 10:10:10:06

Similarly, if a timer fires when the run loop is in the middle of executing a handler routine, the timer waits until the next time through the run loop to invoke its handler routine. If the run loop is not running at all, the timer never fires.

Would scrolling or anything that keeps the runloop busy shift all the times my timer is going to fire?

You can configure timers to generate events only once or repeatedly. A repeating timer reschedules itself automatically based on the scheduled firing time, not the actual firing time. For example, if a timer is scheduled to fire at a particular time and every 5 seconds after that, the scheduled firing time will always fall on the original 5 second time intervals, even if the actual firing time gets delayed. If the firing time is delayed so much that it misses one or more of the scheduled firing times, the timer is fired only once for the missed time period. After firing for the missed period, the timer is rescheduled for the next scheduled firing time.

How can I change the RunLoops's mode?

You can't. The OS just changes itself for you. e.g. when user taps, then the mode switches to eventTracking. When the user taps are finished, the mode goes back to default. If you want something to be run in a specific mode, then it's up to you make sure that happens.


Solution:

When user is scrolling the the Run Loop Mode becomes tracking. The RunLoop is designed to shifts gears. Once the mode is set to eventTracking, then it gives priority (remember we have limited CPU cores) to touch events. This is an architectural design by the OS designers.

By default timers are NOT scheduled on the tracking mode. They are scheduled on:

Creates a timer and schedules it on the current run loop in the default mode.

The scheduledTimer underneath does this:

RunLoop.main.add(timer, forMode: .default)

If you want your timer to work when scrolling then you must do either:

let timer = Timer.scheduledTimer(timeInterval: 1.0, target: self,
 selector: #selector(fireTimer), userInfo: nil, repeats: true) // sets it on `.default` mode

RunLoop.main.add(timer, forMode: .tracking) // AND Do this

Or just do:

RunLoop.main.add(timer, forMode: .common)

Ultimately doing one of the above means your thread is not blocked by touch events. which is equivalent to:

RunLoop.main.add(timer, forMode: .default)
RunLoop.main.add(timer, forMode: .eventTracking)
RunLoop.main.add(timer, forMode: .modal) // This is more of a macOS thing for when you have a modal panel showing.

Alternative solution:

You may consider using GCD for your timer which will help you to "shield" your code from run loop management issues.

For non-repeating just use:

DispatchQueue.main.asyncAfter(deadline: .now() + 5) {
    // your code here
}

For repeating timers use:

See how to use DispatchSourceTimer


Digging deeper from a discussion I had with Daniel Jalkut:

Question: how does GCD (background threads) e.g. a asyncAfter on a background thread get executed outside of the RunLoop? My understanding from this is that everything is to be executed within a RunLoop

Not necessarily - every thread has at most one run loop, but can have zero if there's no reason to coordinate execution "ownership" of the thread.

Threads are an OS level affordance that gives your process the ability to split up its functionality across multiple parallel execution contexts. Run loops are a framework-level affordance that allows you to further split up a single thread so it can be shared efficiently by multiple code paths.

Typically if you dispatch something that gets run on a thread, it probably won't have a runloop unless something calls [NSRunLoop currentRunLoop] which would implicitly create one.

In a nutshell, modes are basically a filter mechanism for inputs and timers

mfaani
  • 33,269
  • 19
  • 164
  • 293
8

Yes, Paul is right, this is a run loop issue. Specifically, you need to make use of the NSRunLoop method:

- (void)addTimer:(NSTimer *)aTimer forMode:(NSString *)mode
August
  • 12,139
  • 3
  • 29
  • 30
7

This is the swift version.

timer = NSTimer.scheduledTimerWithTimeInterval(0.01, target: self, selector: aSelector, userInfo: nil, repeats: true)
            NSRunLoop.mainRunLoop().addTimer(timer, forMode: NSRunLoopCommonModes)
Andrew Cook
  • 116
  • 1
  • 8
7

You have to run another thread and another run loop if you want timers to fire while scrolling; since timers are processed as part of the event loop, if you're busy processing scrolling your view, you never get around to the timers. Though the perf/battery penalty of running timers on other threads might not be worth handling this case.

Ana Betts
  • 73,868
  • 16
  • 141
  • 209
  • 11
    It doesn't require another thread, see the more recent [answer](http://stackoverflow.com/questions/605027/uiscrollview-pauses-nstimer-until-scrolling-finishes/2742275#2742275) from Kashif. I tried it and it works with no extra thread required. – progrmr Aug 02 '11 at 06:11
  • 3
    Shooting cannons at sparrows. Instead of a thread, just schedule the timer in the right run loop mode. – uliwitness Aug 19 '11 at 16:25
  • 2
    I think Kashif's answer below is the best answer by far as it only requires you to add 1 line of code to fix this issue. – damien murphy. Oct 31 '12 at 11:53
3

for anyone use Swift 4:

    timer = Timer(timeInterval: 1, target: self, selector: #selector(timerUpdated), userInfo: nil, repeats: true)
    RunLoop.main.add(timer, forMode: .common)
Yulong Xiao
  • 191
  • 2
  • 4
1

Tested in swift 5

var myTimer: Timer?

self.myTimer= Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { timer in
      //your code
}
    
RunLoop.main.add(self.myTimer!, forMode: .common)