It is widely known that unlike ObservableObject
s, one can nest @Observable
objects and SwiftUI will still detect changes in the nested objects. However, I found that this doesn't work when I pass these objects to a NSViewRepresentable
(and possibly also UIViewRepresentable
).
Consider this NSViewRepresentable
that wraps an NSOutlineView
to display a tree of @Observable
objects. (This is taken from this question of mine.)
protocol OutlineViewable: Observable, Hashable {
var displayText: String { get }
var children: [Self] { get }
}
struct OutlineViewRepresentable<Item: OutlineViewable>: NSViewRepresentable {
let items: [Item]
class MyScrollView: NSScrollView {
let outlineView = NSOutlineView()
}
func makeNSView(context: Context) -> MyScrollView {
let scrollView = MyScrollView()
scrollView.documentView = scrollView.outlineView
let outline = scrollView.outlineView
let col = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("items"))
col.title = "Items"
outline.addTableColumn(col)
outline.outlineTableColumn = col
outline.delegate = context.coordinator
outline.dataSource = context.coordinator
return scrollView
}
func makeCoordinator() -> Coordinator {
.init()
}
func updateNSView(_ nsView: MyScrollView, context: Context) {
context.coordinator.items = items
nsView.outlineView.reloadData()
}
func sizeThatFits(_ proposal: ProposedViewSize, nsView: MyScrollView, context: Context) -> CGSize? {
proposal.replacingUnspecifiedDimensions()
}
@MainActor
class Coordinator: NSObject, NSOutlineViewDelegate, NSOutlineViewDataSource {
var items: [Item] = []
func outlineView(_ outlineView: NSOutlineView, child index: Int, ofItem item: Any?) -> Any {
if let item = item as? Item {
item.children[index]
} else {
items[index]
}
}
func outlineView(_ outlineView: NSOutlineView, numberOfChildrenOfItem item: Any?) -> Int {
if let item = item as? Item {
item.children.count
} else {
items.count
}
}
func outlineView(_ outlineView: NSOutlineView, shouldSelectItem item: Any) -> Bool {
true
}
func outlineView(_ outlineView: NSOutlineView, isItemExpandable item: Any) -> Bool {
guard let item = item as? Item else { return false }
return !item.children.isEmpty
}
func outlineView(_ outlineView: NSOutlineView, shouldEdit tableColumn: NSTableColumn?, item: Any) -> Bool {
false
}
func outlineView(_ outlineView: NSOutlineView, viewFor tableColumn: NSTableColumn?, item: Any) -> NSView? {
guard let item = item as? Item else { return nil }
if let tableCell = outlineView.makeView(withIdentifier: .init("itemCell"), owner: self) as? NSTableCellView,
let textField = tableCell.textField {
textField.stringValue = item.displayText
return tableCell
} else {
let textField = NSTextField()
textField.backgroundColor = NSColor.clear
textField.translatesAutoresizingMaskIntoConstraints = false
textField.isBordered = false
// Create a cell
let newCell = NSTableCellView()
newCell.identifier = .init("itemCell")
newCell.addSubview(textField)
newCell.textField = textField
NSLayoutConstraint.activate([
textField.centerYAnchor.constraint(equalTo: newCell.centerYAnchor),
textField.leftAnchor.constraint(equalTo: newCell.leftAnchor),
textField.rightAnchor.constraint(equalTo: newCell.rightAnchor),
])
textField.stringValue = item.displayText
textField.isEditable = false
return newCell
}
}
}
}
Then I have:
struct ContentView: View {
@State private var items = [
Item(name: "Foo", children: [
Item(name: "Bar"),
])
]
var body: some View {
OutlineViewRepresentable(items: items)
Button("Add Child") {
items[0].children.append(Item(name: "Baz"))
}
}
}
@Observable
final class Item: Identifiable, OutlineViewable {
var displayText: String
var children: [Item] = []
init(name: String, children: [Item] = []) {
self.displayText = name
self.children = children
}
static func ==(lhs: Item, rhs: Item) -> Bool {
lhs === rhs
}
func hash(into hasher: inout Hasher) {
hasher.combine(ObjectIdentifier(self))
}
}
To reproduce the issue, first expand the "Foo" row to reveal the "Bar" row. Then click on the "Add Child" button. The new "Baz" row does not appear in the outline view.
How can I fix this?
Observations:
If I add a child to the root (items.append(Item(name: "Baz"))
), the outline view updates as expected.
I thought this has something to do with the Equatable
/Hashable
implementation. After adding a child to items[0]
, the items
after this change is still equal to the items
before this change, since I am just using reference equality. I tried changing the implementations to:
static func ==(lhs: Item, rhs: Item) -> Bool {
lhs === rhs && lhs.displayText == rhs.displayText && lhs.children == rhs.children
}
func hash(into hasher: inout Hasher) {
hasher.combine(ObjectIdentifier(self))
hasher.combine(displayText)
hasher.combine(children)
}
But this does not change the behaviour.
I found that if I add an extra trigger
property to OutlineViewRepresentable
, and change that property at the same time as I add the child, it works:
struct OutlineViewRepresentable<Item: OutlineViewable>: NSViewRepresentable {
let items: [Item]
let trigger: UUID
...
}
@State private var trigger = UUID()
var body: some View {
OutlineViewRepresentable(items: items, trigger: trigger)
Button("Add Child") {
items[0].children.append(Item(name: "Baz"))
trigger = UUID()
}
}
But this is not a good solution, because I can easily forget to set the trigger
in a large codebase.
You should access each observable property of Item
in updateNSView
in order to register these properties as the dependencies of the NSViewRepresentable
.
func updateNSView(_ nsView: MyScrollView, context: Context) {
context.coordinator.items = items
nsView.outlineView.reloadData()
for item in items {
registerDependencies(item)
}
}
func registerDependencies(_ item: Item) {
let _ = item.displayText // call the getter to register the dependency
for child in item.children {
registerDependencies(child)
}
}
Just calling reloadData
will not cause the outline view delegate methods to be called synchronously, so the getter of children
is not called before updateNSView
returns, so changes to children
is not observed.
As an optimisation (not sure how much this helps though), this process can stop as soon as we reach an item that is invisible in the outline view (i.e. its parent is collapsed).
func registerDependencies(_ item: Item, outlineView: NSOutlineView) {
if outlineView.row(forItem: item) == -1 { // item is invisible
return
}
let _ = item.displayText
for child in item.children {
registerDependencies(child, outlineView: outlineView)
}
}