What wiring code is necessary to wrap NSComboBox with NSViewRepresentable?

373 views Asked by At

I am trying to wrap NSComboBox with NSViewRepresentable for use in SwiftUI. I would like to pass in both the list of dropdown options and the text value of the combo box as bindings. I would like the text value binding to update on every keystroke, and on selection of one of the dropdown options. I would also like the text value/selection of the combo box to change if binding is changed externally.

Right now, I am not seeing my binding update on option selection, let alone every keystroke, as demonstrated by the SwiftUI preview at the bottom of the code.

My latest lead from reading old documentation is that maybe in an NSComboBox the selection value and the text value are two different properties and I've written this wrapping as if they are one and the same? Trying to run that down. For my purposes, they will be one and the same, or at least only the text value will matter: it is a form field for arbitrary user string input, that also has some preset strings.

Here is the code. I think this should be paste-able into a Mac-platform playground file:

import AppKit
import SwiftUI

public struct ComboBoxRepresentable: NSViewRepresentable {
    private var options: Binding<[String]>
    private var text: Binding<String>

    public init(options: Binding<[String]>, text: Binding<String>) {
        self.options = options
        self.text = text
    }

    public func makeNSView(context: Context) -> NSComboBox {
        let comboBox = NSComboBox()
        comboBox.delegate = context.coordinator
        comboBox.usesDataSource = true
        comboBox.dataSource = context.coordinator

        return comboBox
    }

    public func updateNSView(_ comboBox: NSComboBox, context: Context) {
        comboBox.stringValue = text.wrappedValue
        comboBox.reloadData()
    }
}

public extension ComboBoxRepresentable {
    final class Coordinator: NSObject {
        var options: Binding<[String]>
        var text: Binding<String>

        init(options: Binding<[String]>, text: Binding<String>) {
            self.options = options
            self.text = text
        }
    }

    func makeCoordinator() -> Coordinator {
        Coordinator(options: options, text: text)
    }
}

extension ComboBoxRepresentable.Coordinator: NSComboBoxDelegate {
    public func comboBoxSelectionDidChange(_ notification: Notification) {
        guard let comboBox = notification.object as? NSComboBox else { return }
        text.wrappedValue = comboBox.stringValue
    }
}

extension ComboBoxRepresentable.Coordinator: NSComboBoxDataSource {

    public func comboBox(_ comboBox: NSComboBox, objectValueForItemAt index: Int) -> Any? {
        guard options.wrappedValue.indices.contains(index) else { return nil }
        return options.wrappedValue[index]
    }

    public func numberOfItems(in comboBox: NSComboBox) -> Int {
        options.wrappedValue.count
    }
}

#if DEBUG
struct ComboBoxRepresentablePreviewWrapper: View {
    @State private var text = "four"
    var body: some View {
        VStack {
            Text("selection: \(text)")

            ComboBoxRepresentable(
                options: .constant(["one", "two", "three"]),
                text: $text
            )
        }
    }
}

struct ComboBoxRepresentable_Previews: PreviewProvider {
    @State private var text = ""
    static var previews: some View {
        ComboBoxRepresentablePreviewWrapper()
            .frame(width: 200, height: 100)
    }
}
#endif

Thank you in advance if you have any suggestions!

2

There are 2 answers

0
lorem ipsum On
public struct ComboBoxRepresentable: NSViewRepresentable {
    //If the options change the parent should be an @State or another source of truth if they don't change just remove the @Binding
    @Binding private var options: [String]
    @Binding private var text: String
    public init(options: Binding<[String]>, text: Binding<String>) {
        self._options = options
        self._text = text
    }
    
    public func makeNSView(context: Context) -> NSComboBox {
        let comboBox = NSComboBox()
        comboBox.delegate = context.coordinator
        comboBox.usesDataSource = true
        comboBox.dataSource = context.coordinator
        comboBox.stringValue = text
        comboBox.reloadData()
        return comboBox
    }
    
    public func updateNSView(_ comboBox: NSComboBox, context: Context) {
        //You don't need anything here the delegate updates text and the combobox is already updated
    }
}

public extension ComboBoxRepresentable {
    final class Coordinator: NSObject {
        //This is a much simpler init and injects the new values directly int he View vs losing properties in a class updates can be unreliable
        var parent: ComboBoxRepresentable
        init(_ parent: ComboBoxRepresentable) {
            self.parent = parent
        }
    }
    
    func makeCoordinator() -> Coordinator {
        Coordinator(self)
    }
}

extension ComboBoxRepresentable.Coordinator: NSComboBoxDelegate {
    
    public func comboBoxSelectionDidChange(_ notification: Notification) {
        guard let comboBox = notification.object as? NSComboBox else { return }
        //It is a known issue that this has to be ran async for it to have the current value
        //https://stackoverflow.com/questions/5265260/comboboxselectiondidchange-gives-me-previously-selected-value
        DispatchQueue.main.async {
            self.parent.text = comboBox.stringValue
        }
    }
    
}

extension ComboBoxRepresentable.Coordinator: NSComboBoxDataSource {
    
    public func comboBox(_ comboBox: NSComboBox, objectValueForItemAt index: Int) -> Any? {
        guard parent.options.indices.contains(index) else { return nil }
        return parent.options[index]
    }
    
    public func numberOfItems(in comboBox: NSComboBox) -> Int {
        parent.options.count
    }
}

#if DEBUG
struct ComboBoxRepresentablePreviewWrapper: View {
    @State private var text = "four"
    //If they dont update remove the @Binding
    @State private var options = ["one", "two", "three"]
    var body: some View {
        VStack {
            Text("selection: \(text)")
            
            ComboBoxRepresentable(
                options: $options,
                text: $text
            )
        }
    }
}

struct ComboBoxRepresentable_Previews: PreviewProvider {
    @State private var text = ""
    static var previews: some View {
        ComboBoxRepresentablePreviewWrapper()
            .frame(width: 200, height: 100)
    }
}
#endif
1
kamcma On

Okay, I think I have come to a solution that satisfies the requirements I laid out in the question:

public struct ComboBoxRepresentable: NSViewRepresentable {
    private let title: String
    private var text: Binding<String>
    private var options: Binding<[String]>
    private var onEditingChanged: (Bool) -> Void

    public init(
        _ title: String,
        text: Binding<String>,
        options: Binding<[String]>,
        onEditingChanged: @escaping (Bool) -> Void = { _ in }
    ) {
        self.title = title
        self.text = text
        self.options = options
        self.onEditingChanged = onEditingChanged
    }

    public func makeNSView(context: Context) -> NSComboBox {
        let comboBox = NSComboBox()
        comboBox.delegate = context.coordinator
        comboBox.usesDataSource = true
        comboBox.dataSource = context.coordinator
        comboBox.placeholderString = title
        comboBox.completes = true

        return comboBox
    }

    public func updateNSView(_ comboBox: NSComboBox, context: Context) {
        comboBox.stringValue = text.wrappedValue
        comboBox.reloadData()
    }
}

public extension ComboBoxRepresentable {
    final class Coordinator: NSObject {
        private var parent: ComboBoxRepresentable

        init(parent: ComboBoxRepresentable) {
            self.parent = parent
        }
    }

    func makeCoordinator() -> Coordinator {
        Coordinator(parent: self)
    }
}

extension ComboBoxRepresentable.Coordinator: NSComboBoxDelegate {
    public func comboBoxSelectionDidChange(_ notification: Notification) {
        guard let comboBox = notification.object as? NSComboBox,
              parent.options.wrappedValue.indices.contains(comboBox.indexOfSelectedItem) else { return }
        parent.text.wrappedValue = parent.options.wrappedValue[comboBox.indexOfSelectedItem]
    }

    public func controlTextDidChange(_ notification: Notification) {
        guard let comboBox = notification.object as? NSComboBox else { return }
        parent.text.wrappedValue = comboBox.stringValue
    }

    public func controlTextDidBeginEditing(_ notification: Notification) {
        parent.onEditingChanged(true)
    }

    public func controlTextDidEndEditing(_ notification: Notification) {
        parent.onEditingChanged(false)
    }
}

extension ComboBoxRepresentable.Coordinator: NSComboBoxDataSource {
    public func comboBox(_ comboBox: NSComboBox, completedString string: String) -> String? {
        parent.options.wrappedValue.first { $0.hasPrefix(string) }
    }

    public func comboBox(_ comboBox: NSComboBox, indexOfItemWithStringValue string: String) -> Int {
        guard let index = parent.options.wrappedValue.firstIndex(of: string) else { return NSNotFound }
        return index
    }

    public func comboBox(_ comboBox: NSComboBox, objectValueForItemAt index: Int) -> Any? {
        guard parent.options.wrappedValue.indices.contains(index) else { return nil }
        return parent.options.wrappedValue[index]
    }

    public func numberOfItems(in comboBox: NSComboBox) -> Int {
        parent.options.wrappedValue.count
    }
}

On the point about updating the bound value as the user types, to get that you have implement the parent NSTextField delegate method controlTextDidChange.

And then in comboBoxSelectionDidChange, you need to update the bound value from the bound options using the combo box's newly-selected index.