swift - How to write a view that provides a proxy to interact with a AppKitUIKit view, akin to ScrollViewReader? - Stack Overflo

I am trying to write a NSViewRepresentable wrapper for NSOutlineView that allows users to programmatica

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?

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?

Share Improve this question asked Mar 2 at 10:47 SweeperSweeper 278k23 gold badges242 silver badges397 bronze badges
Add a comment  | 

1 Answer 1

Reset to default 0

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

发布者:admin,转转请注明出处:http://www.yc00.com/questions/1745124530a4612611.html

相关推荐

发表回复

评论列表(0条)

  • 暂无评论

联系我们

400-800-8888

在线咨询: QQ交谈

邮件:admin@example.com

工作时间:周一至周五,9:30-18:30,节假日休息

关注微信