swiftmacosswiftuiappkitnsviewrepresentable

How can I update the number of rows in an NSTableView?


I'm writing a SwiftUI app on macOS that calls out to an AppKit NSTableView. I've gotten to the point where the data are 'in sync', and changes in the data in SwiftUI are reflected in the NSTableView. However, I'm running into a problem where the number of rows in the NSTableView is not being updated in response to fresh data, causing an Index out of range error, since the table is trying to access a third 'row' of data in an array of length 2.

How can I tell the NSTableView to update its number of rows in response to fresh data?

Code

ContentView.swift:


struct ContentView: View {
    // This is the 'single source of truth' for the data.
    @State private var items: [Entry] = [entry1, entry2, entry3]

    var body: some View {
        VStack {
            AppKitReferenceTable(data: $items)
        }
        .onAppear() {
            // This simulates incoming fresh data.
            // Note that the incoming data is of length 2,
            // while the original data is of length 3.
            // This is causing the Index out of range error (in AppKitReferenceTable.swift, below).
            self.items = [entry4, entry5]
        }
    }
}

AppKitReferenceTable.swift:

import SwiftUI

struct AppKitReferenceTable: NSViewRepresentable {
    @Binding var data: [Entry]

    func makeCoordinator() -> Coordinator {
        Coordinator(parent: self)
    }

    func makeNSView(context: Context) -> NSScrollView {
        let column = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("TitleColumn"))
        column.width = 100.0

        let tableView = NSTableView()

        tableView.headerView = nil
        tableView.addTableColumn(column)
        tableView.delegate = context.coordinator
        tableView.dataSource = context.coordinator

        let view = NSScrollView()

        view.documentView = tableView

        return view
    }

    func updateNSView(_ nsView: NSScrollView, context: Context) {
        guard let tableView = nsView.documentView as? NSTableView else { return }
        tableView.reloadData()
    }
}

extension AppKitReferenceTable {
    class Coordinator: NSObject, NSTableViewDelegate, NSTableViewDataSource, NSTextFieldDelegate {
        var parent: AppKitReferenceTable

        init(parent: AppKitReferenceTable) {
            self.parent = parent
        }

        func numberOfRows(in tableView: NSTableView) -> Int {
            parent.data.count
        }

        /// Returns the view for a given row and column
        func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? {
            let textField = NSTextField()

            // ERROR: Thread 1: Fatal error: Index out of range
            // 'parent.data' is of length 2 (the 'fresh' data from .onAppear),
            // but 'row' == 2
            textField.stringValue = parent.data[row].title ?? "no title"
            textField.delegate = self
            textField.tag = row

            switch tableColumn?.identifier.rawValue {
            case "TitleColumn":
                textField.stringValue = parent.data[row].title ?? "no title"
            default:
                textField.stringValue = "Unknown Column"
            }

            return textField
        }
    }
}



Solution

  • The Coordinator should not have a parent property like that. SwiftUI views are value types, so the parent that Coordinator holds will be a copy of the value of self in makeCoordinator. This copy is no longer managed by SwiftUI, so really anything can happen when you try to access its properties.

    With the help of a debugger, we can see that in this case, when updateNSView is called as a result of the update in onAppear, self.data goes out of sync with context.coordinator.parent.data. The former correctly has 2 elements, while the latter has 3 elements.

    reloadData causes numberOfRows(in:) to be called synchronously. This is where the table view delegate incorrectly return 3. Then, updateNSView returns, and the two datas get back in sync. Only after that does NSTableView layout its rows and calls viewFor.

    In this case, manually assigning self to parent in updateNSView works:

    func updateNSView(_ nsView: NSScrollView, context: Context) {
        guard let tableView = nsView.documentView as? NSTableView else { return }
        context.coordinator.parent = self
        tableView.reloadData()
    }
    

    But I would suggest not including anything SwiftUI-related in Coordinator. Directly assign the [Entry] to it.

    struct AppKitReferenceTable: NSViewRepresentable {
        @Binding var data: [Entry]
    
        func makeCoordinator() -> Coordinator {
            Coordinator(data: data)
        }
        ...
    }
    
    extension AppKitReferenceTable {
        class Coordinator: NSObject, NSTableViewDelegate, NSTableViewDataSource, NSTextFieldDelegate {
            var data: [Entry]
    
            init(data: [Entry]) {
                self.data = data
            }
    
            func numberOfRows(in tableView: NSTableView) -> Int {
                return data.count
            }
    
            ...
        }
    }
    
    ...
    
    func updateNSView(_ nsView: NSScrollView, context: Context) {
        guard let tableView = nsView.documentView as? NSTableView else { return }
        context.coordinator.data = data
        tableView.reloadData()
    }
    

    To broadcast changes from the NSView to the SwiftUI side, you can add callbacks like this to the Coordinator.

    // call textDidChange?(someIndex, newText) when necessary
    var textDidChange: ((Int, String) -> Void)?
    

    In updateNSView, you will set this callback

    func updateNSView(_ nsView: NSScrollView, context: Context) {
        guard let tableView = nsView.documentView as? NSTableView else { return }
        context.coordinator.data = data
        context.coordinator.textDidChange = { data[$0].title = $1 }
        tableView.reloadData()
    }