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?
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
}
}
}
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 data
s 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()
}