swiftmacosswiftui

Combobox in SwiftUI


I'm trying to build a combobox in SwiftUIfor a macOS app and I've hit a wall. I have looked at other questions on here, but didn't really find any answers. The main issue I want the dropdown list to appear on top of the views below, and also on top and the parent view, similar to what the ComboBox in AppKit does. In the code below, you'll see that the dropdown doesn't even show up, due to the lack of space probably. I tried playing with z-indexes and overlays with no results.

import Foundation
import SwiftUI

struct ComboBox: View {
    let label: String

    @Binding var text: String
    @State private var isExpanded = false

    var items: [String]

    var body: some View {
        VStack(spacing: 0) {
            TextField(label, text: $text)
                .overlay(alignment: .trailing) {
                    Button {
                        print("Clicked chyron")

                        withAnimation {
                            isExpanded.toggle()
                        }

                    } label: {
                        if isExpanded {
                            Image(systemName: "chevron.up")
                        } else {
                            Image(systemName: "chevron.down")
                        }
                    }.buttonStyle(.borderedProminent)
                }
                .onChange(of: text) { _, newValue in
                    if newValue.isEmpty {
                        isExpanded = false
                    } else {
                        isExpanded = true
                    }
                }

            if isExpanded {
                ScrollView {
                    ForEach(items, id: \.self) { item in
                        Text(item)
                    }
                }
            }
        }
        .padding()
    }
}

#Preview {
    @Previewable @State var text = ""
    let items = ["Item 1", "Item 2", "Item 3", "Item 4", "Item 5"]
    Form {
        ComboBox(label: "Label", text: $text, items: items)
        HStack {
            Spacer()

            Button("Cancel") {
                print("Cancel")
            }

            Button("Submit") {
                print("Submit")
            }.buttonStyle(.borderedProminent)
        }
    }.padding()
}

Solution

  • As suggested in a comment, you could try using a sheet or fullScreenCover. However, with macOS, you don't have much control over shadow effects or positioning when using a sheet and you would need to enable Mac Catalyst to use a full screen cover. I also found that Color.clear didn't work as the presentation background for a sheet (running on macOS 15.1.1).

    As an alternative approach, you could consider separating the menu from the combo field and showing the menu as the top layer in a ZStack. Positioning can be done using .matchedGeometryEffect. This is essentially the same technique as used in the answer to iOS SwiftUI Need to Display Popover Without "Arrow" for showing a custom popover (it was my answer).

    Some notes:

    struct ContentView: View {
        let items = ["Item 1", "Item 2", "Item 3", "Item 4", "Item 5"]
        @State private var text = ""
        @State private var isComboExpanded = false
        @Namespace private var ns
    
        var body: some View {
            ZStack {
                Form {
                    ComboBox(
                        label: "Label",
                        ns: ns,
                        text: $text,
                        isExpanded: $isComboExpanded
                    )
                    HStack {
                        Spacer()
    
                        Button("Cancel") {
                            text = ""
                            print("Cancel")
                        }
    
                        Button("Submit") {
                            print("Submit")
                        }
                        .buttonStyle(.borderedProminent)
                    }
                }
                .padding()
            }
            .frame(maxWidth: .infinity, maxHeight: .infinity)
            .contentShape(Rectangle())
            .onTapGesture {
                isComboExpanded = false
            }
            .overlay {
                if isComboExpanded {
                    ComboMenu(label: "Label", ns: ns, items: items, text: $text)
                }
            }
            .animation(.spring, value: isComboExpanded)
        }
    }
    
    struct ComboBox: View {
        let label: String
        let ns: Namespace.ID
    
        @Binding var text: String
        @Binding var isExpanded: Bool
    
        var body: some View {
            TextField(label, text: $text)
                .padding(.trailing, 40)
                .overlay(alignment: .trailing) {
                    Button {
                        print("Clicked chevron")
                        isExpanded.toggle()
                    } label: {
                        Image(systemName: "chevron.down")
                            .rotation3DEffect(
                                .degrees(isExpanded ? 180 : 0),
                                axis: (x: 1, y: 0, z: 0),
                                perspective: 0.1
                            )
                    }
                    .buttonStyle(.borderedProminent)
                    .matchedGeometryEffect(
                        id: label,
                        in: ns,
                        anchor: .bottomTrailing,
                        isSource: true
                    )
                }
                .onChange(of: text) { _, newValue in
                    isExpanded = newValue.isEmpty
                }
                .padding(.vertical, 10)
        }
    }
    
    struct ComboMenu: View {
        let label: String
        let ns: Namespace.ID
        let items: [String]
        @Binding var text: String
    
        var body: some View {
            ScrollView {
                VStack(alignment: .leading) {
                    ForEach(items, id: \.self) { item in
                        Text(item)
                            .padding(.vertical, 4)
                            .frame(maxWidth: .infinity)
                            .contentShape(Rectangle())
                            .onTapGesture {
                                text = item
                            }
                    }
                }
                .padding()
            }
            .fixedSize()
            .background {
                RoundedRectangle(cornerRadius: 6)
                    .fill(.background)
                    .shadow(radius: 6)
            }
            .padding(.top, 6)
            .matchedGeometryEffect(
                id: label,
                in: ns,
                properties: .position,
                anchor: .topTrailing,
                isSource: false
            )
        }
    }
    

    Animation