⌘ kean.blog

...But Not NSTableView

  

Despite the previous post title, it wasn’t about AppKit but about showing what’s possible with SwiftUI on the platform where it has felt least at home so far. I tried not to make it overly-positive, but I think it ended up this way anyway. To balance it, I would like to focus on some of the issues and how I solved them.

Performance #

The main issue I ran into was performance. The initial test with a decently sized data set with 150 0001 entries was miserable, even after all possible database optimizations. I saw junky scrolling, slow reloads, even opening details was slow!

The root cause of the slow reloads was the infamous List automatic diffs that you still can’t disable. The workaround from the article was not working anymore. The diff computation is slow in itself, but it also breaks fetchBatchSize making matters worse2.

I have some ideas why scrolling might be slow (can’t specify constant cell height, cell reuse is not functioning as well as in AppKit?). But I couldn’t figure out why there was a small but noticeable delay in opening the details screen.

I solved all these performance issues with one simple trick – rewriting the list using NSTableView. After the change, the app is – what’s the overused term is – blazing fast.

If you want to see the performance before, here is a video. The exact same database code, the only difference is List instead of NSTableView. Please also note the smaller dataset with 46 000 entries.

Not List #

Typically, mixing SwiftUI and AppKit is easy – just use NSViewRepresentable. But how do you integrate NSTableView with SwiftUI NavigationView? Fortunately, there is a way.

var body: some View {
    ZStack {
        NotList(
            model: model, // NotListViewModel
            makeRow: ConsoleView.makeRow, // Creates NSView for each cell
            onSelectRow: model.selectRow // Changes model.details.entity
        )
        NavigationLink(
            destination: ConsoleDetailsRouter(model: model.details),
            isActive: .constant(true),
            label: EmptyView()
        )
        .hidden()
    }

I add a hidden NavigationLink which is always active and then switch between details views using ConsoleDetailsRouter: View. It’s not pretty, but it works without accessing the underlying NSSplitViewController.

This is not guaranteed to continue working in the future versions.

With NSTableView, I was back in the driver’s seat. To reload the view, I simply call reloadData. That took care of the reload performance issues. I also set up static cell height and proper cell reuse to resolve the scrolling issues3.

The data flow with NSViewRepresentable is similar to pure SwiftUI views: you use @ObservedObject to communicate the data changes.

struct NotList<Element: Identifiable>: NSViewRepresentable {
    @ObservedObject var model: NotListViewModel<Element>
    let makeRowView: (Element, Int, NSTableView) -> NSView?
    let onSelectRow: (Element, Int) -> Void

    final class Coordinator: NSObject, NSTableViewDelegate, NSTableViewDataSource {
        // ... standard AppKit stuff ...
    }

    // Gets called once per view "identity"
    func makeNSView(context: Context) -> NSScrollView {
        let tableView = NSTableView()
        tableView.delegate = context.coordinator
        tableView.dataSource = context.coordinator
        // ...

        let scrollView = NSScrollView()
        scrollView.documentView = tableView
        return scrollView
    }

    // Gets called whenever `model` changes
    func updateNSView(_ nsView: NSScrollView, context: Context) {
        let tableView = (nsView.documentView as! NSTableView)

        if model.isReloadNeeded {
            model.isReloadNeeded = false

            tableView.reloadData()
            // ... some basic logic to keep selection state ...
        }

        if let index = model.scrollToIndex {
            model.scrollToIndex = nil

            tableView.scrollRowToVisible(index)
            tableView.selectRowIndexes(IndexSet(integer: index), byExtendingSelection: false)
        }
    }
}

To communicate the changes, I set the flags on NotListViewModel and manually send objectWillChange event. It gives me precise control over what to update and when without breaking out from the SwiftUI data flow model.

Platform Discrepancies #

iOS engineers are used to lazy self-sizing table cells. Well, NSTableView doesn’t support it, and for a good reason. List on macOS:

Unlike iOS, macOS needs to know the size of every cell. It allows precise control using a scroll indicator. It appears that List attempts to implement self-sizing on top of NSTableView, but it’s destined to fail on macOS. I don’t think it is even a supported use-case. If all cells have the same height, the scroll indicator works properly, even when using List.

Conclusion #

This little maneuver cost me a couple of wasted hours. I had to re-implement the list, the cells, and the context menus – about 300 lines of AppKit. I could probably get away with using SwiftUI views as cells, but I couldn’t get it to perform as fast as I wanted. The silver lining is that now I have an NSTableView setup, and I can take advantage of some of its more powerful features in the future. For example, I could add a horizontal view with multiple columns.

There is always a risk with SwiftUI. It is an exceptional tool, but you never know when you are going to hit a wall. My pure SwiftUI app uses NSTextView, NSSearchField, and now NSTableView and NSMenu.

  1. Technically, 150 023… off-by-one error. 

  2. Databases are fast. In practice, if you measure it, there isn’t much difference with or without the fetchBatchSize, at least with a dataset of this size. Having said that, I want every operation in the app to feel instantaneous, so I’m taking any wins possible. 

  3. I’m not sure what made List scrolling performance sluggish. If I were to guess, it’s either cell height calculations or cell reuse. Does List even have cell reuse? I don’t think so.