swiftswiftuiuiviewrepresentablensviewrepresentable

How to write a view that provides a proxy to interact with a AppKit/UIKit view, akin to ScrollViewReader?


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?


Solution

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