How to get a ComboBox with swiftUI

Viewed 963

Is there a way to create a NSComboBox in SwiftUI? When I use Picker, a NSPopUpButton is created under MacOS.

I've tried this:

var body: some View {
    Picker("Test:", selection: $selected) {
        ForEach(1..<10) { i in
            Text(String(i))
        }
    }
    .padding()
}

But this will get me this:

enter image description here

But I need a ComboBox where I can enter text as well as select from a list.

Is this even possible with SwiftUI without integrating an NSComboBox as NSViewRepresentable.

I've already check the following question: SwiftUI Custom Picker / ComboBox But it's about a customised picker and not about a NSComboBox.

1 Answers

VDKComboBox

Using NSViewRepresentable isn't as awful as I was expecting. Here's a complete implementation of an NSComboBox for SwiftUI:

import Foundation
import SwiftUI


struct VDKComboBox: NSViewRepresentable
{
    // The items that will show up in the pop-up menu:
    var items: [String]
    
    // The property on our parent view that gets synced to the current stringValue of the NSComboBox, whether the user typed it in or selected it from the list:
    @Binding var text: String
    

    func makeCoordinator() -> Coordinator {
        return Coordinator(self)
    }
    
    
    func makeNSView(context: Context) -> NSComboBox
    {
        let comboBox = NSComboBox()
        comboBox.usesDataSource = false
        comboBox.completes = false
        comboBox.delegate = context.coordinator
        comboBox.intercellSpacing = NSSize(width: 0.0, height: 10.0)            // Matches the look and feel of Big Sur onwards.
        return comboBox
    }
    

    func updateNSView(_ nsView: NSComboBox, context: Context)
    {
        nsView.removeAllItems()
        nsView.addItems(withObjectValues: items)
        
        // ComboBox doesn't automatically select the item matching its text; we must do that manually. But we need the delegate to ignore that selection-change or we'll get a "state modified during view update; will cause undefined behavior" warning.
        context.coordinator.ignoreSelectionChanges = true
        nsView.stringValue = text
        nsView.selectItem(withObjectValue: text)
        context.coordinator.ignoreSelectionChanges = false
    }
}



// MARK: - Coordinator


extension VDKComboBox
{
    class Coordinator: NSObject, NSComboBoxDelegate
    {
        var parent: VDKComboBox
        var ignoreSelectionChanges: Bool = false
        
        init(_ parent: VDKComboBox) {
            self.parent = parent
        }
        

        func comboBoxSelectionDidChange(_ notification: Notification)
        {
            if !ignoreSelectionChanges,
               let box: NSComboBox = notification.object as? NSComboBox,
               let newStringValue: String = box.objectValueOfSelectedItem as? String
            {
                parent.text = newStringValue
            }
        }
        
        
        func controlTextDidEndEditing(_ obj: Notification)
        {
            if let textField = obj.object as? NSTextField
            {
                parent.text = textField.stringValue
            }
        }
    }
}

Using It:

struct MyView: View
{   
    @State var comboBoxText: String = ""

    var body: some View
    {
         VDKComboBox(items: ["one", "two", "three"], text: $comboBoxText)
             .padding()
    }
}

Of course, you'll populate your list of items dynamically; the hard-coded array of strings is just a quick example.

Alternatives

I do realize you explicitly ruled out NSViewRepresentable, but I tried LOTS of other ways to imitate NSComboBox, including:

  • A SwiftUI Button on top of a TextField, .trailing aligned
  • Dynamically swapping a TextField and Picker
  • A custom Popover linked to a TextField

All of that looked "foreign" in one way or another. And since NSViewRepresentable wasn't all that bad, that's what I settled on. It may help others in the future, so I put my implementation here even though you wanted one that didn't rely on NSViewRepresentable.

Related