Niek
Niek

Reputation: 1609

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
        }
    }
}


Upvotes: 2

Views: 73

Answers (2)

Sweeper
Sweeper

Reputation: 273540

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()
}

Upvotes: 3

thecoolwinter
thecoolwinter

Reputation: 1009

It's good practice to pass the binding to the Coordinator instead of passing the view. That may fix the error you're seeing here where the parent's binding value is behind the real value, causing the out of bounds access error.

import SwiftUI

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

    func makeCoordinator() -> Coordinator {
        Coordinator(data: $data)
    }

    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 {
        @Binding var data: [Entry]

        init(data: Binding<[Entry]>) {
            self._data = data
        }

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

        func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? {
            let textField = NSTextField()

            textField.stringValue = 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
        }
    }
}

An alternative would be to create a combine publisher and receive updates in the coordinator rather than relying on the updateNSView method to refresh your data.

Upvotes: 2

Related Questions