NSTableView is unresponsive when inside a modal window shown in DispatchQueue.main.async

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
    }

}
Answered by DTS Engineer in 845120022
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 because applicationDidFinishLaunching(…) 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"

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 because applicationDidFinishLaunching(…) 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"

Thanks for the explanation. So is using perform(_:with:afterDelay:) or Timer.scheduledTimer(withTimeInterval:repeats:block:) the correct way in this case?

  • I thought using DispatchQueue.main.async was the most elegant way, as it's succinct and allows me to call a native Swift method with a native Swift argument, but because of the modal table view unresponsiveness it's out of the question.
  • Timer.scheduledTimer(withTimeInterval:repeats:block:) is less intuitive and a little longer, but works in this case, so it's my best option for now.
  • perform(_:with:afterDelay:) requires the first argument to be a @objc method, which in turn requires its own argument to be representable in Objective C (which I did by inheriting from NSObject, quite an overhead).
Accepted Answer

I just discovered RunLoop.current.perform(_:). I guess that would be the correct way to go?

I guess that would be the correct way to go?

Yep.

Share and Enjoy

Quinn “The Eskimo!” @ Developer Technical Support @ Apple
let myEmail = "eskimo" + "1" + "@" + "apple.com"

NSTableView is unresponsive when inside a modal window shown in DispatchQueue.main.async
 
 
Q