I am trying to write a NSViewRepresentable
wrapper for NSOutlineView
that allows users to programmatically expand/collapse items from the SwiftUI side. I am doing this because the SwiftUI List(_:children:)
API doesn't allow us to control which list items are expanded/collapsed.
Though I am dealing with NSOutlineView
in this specific situation, this question would also apply to UIKit views that have this kind of "side effect" methods that one would want to trigger from the SwiftUI side.
Here is what I imagined the use-site would look:
@MainActor
var items: [Item] = [
.init(name: "Foo", children: [
.init(name: "Foo1"),
.init(name: "Foo2"),
.init(name: "Foo3"),
]),
.init(name: "Bar", children: [
.init(name: "Bar1"),
.init(name: "Bar2"),
.init(name: "Bar3"),
]),
]
struct ContentView: View {
var body: some View {
OutlineViewReader { proxy in
VStack {
OutlineViewRepresentable(items: items)
Button("Expand") {
proxy.expand(items[0], expandChildren: false)
}
}
}
}
}
where proxy.expand
will lead to a call to NSOutlineView.expandItem(_:expandChildren:)
.
This is very similar to ScrollViewReader
which provides a ScrollViewProxy
that allows you to scroll programmatically.
I have already implemented the OutlineViewRepresentable
:
protocol OutlineViewable: Observable, Hashable {
var displayText: String { get }
var children: [Self] { get }
}
struct OutlineViewRepresentable<Item: OutlineViewable>: NSViewRepresentable {
let items: [Item]
func makeNSView(context: Context) -> NSOutlineView {
let outline = NSOutlineView()
let col = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("items"))
col.title = "Items"
outline.addTableColumn(col)
outline.outlineTableColumn = col
outline.delegate = context.coordinator
outline.dataSource = context.coordinator
return outline
}
func makeCoordinator() -> Coordinator {
.init()
}
func updateNSView(_ nsView: NSOutlineView, context: Context) {
context.coordinator.items = items
nsView.reloadData()
}
func sizeThatFits(_ proposal: ProposedViewSize, nsView: NSOutlineView, 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
}
}
}
}
I'd imagine OutlineProxy
would look something like this:
@MainActor
struct OutlineProxy<Item: OutlineViewable>: Hashable {
fileprivate weak var outlineView: NSOutlineView?
func expand(_ item: Item, expandChildren: Bool) {
outlineView?.expandItem(item, expandChildren: expandChildren)
}
func collapse(_ item: Item, collapseChildren: Bool) {
outlineView?.collapseItem(item, collapseChildren: collapseChildren)
}
}
How can I write a OutlineViewReader
that can provide such an OutlineProxy
?
OutlineViewRepresentable
would be a child of OutlineViewReader
, and we want to pass some information (the NSOutlineView
) from child to parent. PreferenceKey
does exactly that.
We can write such a preference key.
struct OutlineProxyKey<Item: OutlineViewable>: PreferenceKey {
static var defaultValue: OutlineProxy<Item> { OutlineProxy() }
static func reduce(value: inout OutlineProxy<Item>, nextValue: () -> OutlineProxy<Item>) {
let next = nextValue()
if let view = next.outlineView {
value.outlineView = view
}
}
}
Then OutlineViewReader
just needs to read this preference key:
struct OutlineViewReader<Item: OutlineViewable, Content: View>: View {
@State private var proxy = OutlineProxy<Item>()
let content: (OutlineProxy<Item>) -> Content
init(of type: Item.Type, @ViewBuilder content: @escaping (OutlineProxy<Item>) -> Content) {
self.content = content
}
var body: some View {
content(proxy)
.onPreferenceChange(OutlineProxyKey<Item>.self) {
proxy = $0
}
}
}
We cannot set the preference key directly in OutlineViewRepresentable
. We need to wrap another SwiftUI view around the NSViewRepresentable
(credits to this blog post). Here I have called this wrapper ItemOutline
.
struct ItemOutline<Item: OutlineViewable>: View {
let items: [Item]
@State private var proxy = OutlineProxy<Item>()
var body: some View {
// OutlineViewRepresentable exposes the proxy through a binding
OutlineViewRepresentable(items: items, proxy: $proxy)
.preference(key: OutlineProxyKey<Item>.self, value: proxy)
}
}
struct OutlineViewRepresentable<Item: OutlineViewable>: NSViewRepresentable {
let items: [Item]
@Binding var proxy: OutlineProxy<Item>
class MyOutlineView: NSOutlineView {
var onMoveToWindow: (() -> Void)?
override func viewDidMoveToWindow() {
super.viewDidMoveToWindow()
onMoveToWindow?()
}
}
func makeNSView(context: Context) -> MyOutlineView {
// same as before
}
func makeCoordinator() -> Coordinator {
.init()
}
func updateNSView(_ nsView: MyOutlineView, context: Context) {
context.coordinator.items = items
nsView.reloadData()
if proxy.outlineView == nil {
// here I decided to set the binding at viewDidMoveToWindow.
// doing it simply with a DispatchQueue.main.async also works, but I prefer this
nsView.onMoveToWindow = { [weak nsView] in proxy.outlineView = nsView }
}
}
// the rest is also same as before
}
Usage:
OutlineViewReader(of: Item.self) { proxy in
VStack {
ItemOutline(items: items) // use the wrapper instead of OutlineViewRepresentable
Button("Expand") {
proxy.expand(items[0], expandChildren: false)
}
}
}