In my app I have a background task performed on a custom DispatchQueue
. When it has completed, I update the UI in DispatchQueue.main.async
. In a particular case, the app then needs to show a modal window that contains a table view, but I have noticed that when scrolling through the tableview, it only responds very slowly.
It appears that this happens when the table view in the modal window is presented in DispatchQueue.main.async
. Presenting it in perform(_:with:afterDelay:)
or in a Timer.scheduledTimer(withTimeInterval:repeats:block:)
on the other hand works. Why? This seems like an ugly workaround.
I created FB7448414 in November 2019 but got no response.
@NSApplicationMain
class AppDelegate: NSObject, NSApplicationDelegate {
func applicationDidFinishLaunching(_ aNotification: Notification) {
let windowController = NSWindowController(window: NSWindow(contentViewController: ViewController()))
// 1. works
// runModal(for: windowController)
// 2. works
// perform(#selector(runModal), with: windowController, afterDelay: 0)
// 3. works
// Timer.scheduledTimer(withTimeInterval: 0, repeats: false) { [self] _ in
// self.runModal(for: windowController)
// }
// 4. doesn't work
DispatchQueue.main.async {
self.runModal(for: windowController)
}
}
@objc func runModal(for windowController: NSWindowController) {
NSApp.runModal(for: windowController.window!)
}
}
class ViewController: NSViewController, NSTableViewDataSource, NSTableViewDelegate {
override func loadView() {
let tableView = NSTableView()
tableView.dataSource = self
tableView.delegate = self
tableView.addTableColumn(NSTableColumn())
let scrollView = NSScrollView(frame: CGRect(x: 0, y: 0, width: 400, height: 400))
scrollView.documentView = tableView
scrollView.hasVerticalScroller = true
scrollView.translatesAutoresizingMaskIntoConstraints = false
view = scrollView
}
func numberOfRows(in tableView: NSTableView) -> Int {
return 100
}
func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? {
let view = NSTableCellView()
let textField = NSTextField(labelWithString: "\(row)")
textField.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(textField)
NSLayoutConstraint.activate([textField.leadingAnchor.constraint(equalTo: view.leadingAnchor), textField.trailingAnchor.constraint(equalTo: view.trailingAnchor), textField.topAnchor.constraint(equalTo: view.topAnchor), textField.bottomAnchor.constraint(equalTo: view.bottomAnchor)])
return view
}
}
DispatchQueue.main.async {
self.runModal(for: windowController)
}
Yeah, don’t do that.
To understand why, you’re gonna need to understand something about how run loops work. See WWDC 2010 Session 207 Run Loops Section for my explanation of this.
Most of Apple’s UI frameworks really don’t like you running the run loop recursively. AppKit is the exception to that general rule. In AppKit it’s fine to run the run loop recursively as long as you run it in a non-default run loop mode.
However, doing that from a work item enqueued using DispatchQueue.main.async(…)
is really bad. The main queue is a serial queue. That means that Dispatch can’t run work item N+1 on the queue until work item N has completed. In your case work item N is stuck for a long time within runModal(…)
. That means that the Dispatch main queue is stuck for the duration. It can’t perform any work until runModal(…)
returns and your return from your work item. Lots of code within the system relies on the Dispatch main queue and so blocking it indefinitely like this will do Very Bad Things™.
Note that:
-
Case 1 (call
runModal(…)
directly) works fine becauseapplicationDidFinishLaunching(…)
is called on the main thread before AppKit enters the run loop. -
Case 2 (using a timer) works fine because timers are called from the run loop, and AppKit explicitly allows run loop recursion as I mentioned above.
Share and Enjoy
—
Quinn “The Eskimo!” @ Developer Technical Support @ Apple
let myEmail = "eskimo" + "1" + "@" + "apple.com"