Matt Rajca

On Run Loops, Modal UI, and Buttery-Smooth Scrolling

September 15, 2016

TL;DR: Don’t run modal sessions inside of dispatch_async calls or you’ll see poor scrolling performance.

If you search around the web for differences between scheduling deferred work with dispatch_async/dispatch_after versus the -performSelector:... family of methods, you’ll notice most people simply consider it a matter of style. There are, however, subtle differences in how these APIs work that can significantly impact how and when your code is scheduled to execute. Let’s start by reviewing how run loops work.

In its simplest form, a run loop simply spins indefinitely, blocking while waiting for events and processing any events as they come in. In pseudocode, it looks something like this:

while !should_exit() {
	event = wait_for_event()
	process_event(event)
}

On macOS, timers, mouse events, and keyboard events are some examples of work that interrupt the main run loop; on iOS, one example is touch events. This is why it’s important to keep expensive work off the main thread – it prevents the run loop from processing events that drive user interaction.

Deep down, things are a bit more complicated than the pseudocode shown above. For one, run loops can run in different modes, and a run loop will only process events scheduled with the mode it’s running in.

To explain that better, let’s walk through an example. A repeating NSTimer created with one of the +scheduledTimer... factory methods is scheduled with the NSDefaultRunLoop mode by default. If you pull down a menu on macOS while that timer is scheduled, you’ll notice timer events don’t get delivered as long as the menu is up. This happens because the menu tracking code spins its own run loop in the NSEventTrackingRunLoop mode, which the timer is not scheduled with. This causes the delivery of timer events to be postponed until the menu is dismissed. If you wish to receive timer events while a menu is pulled down, you can simply schedule the timer with NSCommonRunLoopModes, which includes the NSEventTrackingRunLoop mode in addition to the NSDefaultRunLoop mode:

NSRunLoop *loop = [NSRunLoop currentRunLoop];
[loop addTimer:someTimer forMode:NSRunLoopCommonModes];

Now timer events will be processed even while the menu is pulled down.

Similarly, on iOS, if you use the now-deprecated NSURLConnection class, you may find yourself not getting delegate method calls while you’re scrolling. This is, again, because scrolling occurs in a different run loop mode which your NSURLConnection, by default, is not scheduled with. This causes URL connection events to get deferred until scrolling stops. In most cases this is desirable behavior as it improves scrolling performance.

Recently, I’ve noticed an issue where table view scrolling would get extremely unresponsive in modal windows in two of my ongoing projects. After looking further, I noticed that in both of these cases, the modal session starts inside of a dispatch_async call:

dispatch_async(dispatch_get_main_queue() ^{
	NSWindow *someWindow = ...;
	[someWindow runModal];
});

If we simply switch to using -performSelector:withObject:afterDelay:, scrolling is responsive again:

[self performSelector:@selector(_actuallyShowWindow) withObject:nil afterDelay:0];

- (void)_actuallyShowWindow {
	NSWindow *someWindow = ...;
	[someWindow runModal];
}

As you probably guessed by now, the issue boils down to which run loop modes our work is scheduled with. While undocumented, dispatch_async executes work in NSRunLoopCommonModes, which on macOS include the NSModalPanelRunLoop and NSEventTrackingRunLoop modes. This means while we’re in dispatch_async, our modal session is interferering with events scheduled with the NSEventTrackingRunLoop mode.

The performSelector:... call, however, executes work in the NSDefaultRunLoop mode (and this is actually documented). This means any event tracking code scheduled with the NSEventTrackingRunLoop mode is unaffected, and table view scrolling is buttery-smooth again.