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()
}
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:
The boolean flag needs to be passed as a binding to ComboBox
too.
Apply .fixedSize()
to the ScrollView
in ComboMenu
, to restrict its size to the minimum necessary.
A catch-all tap gesture can be attached to the ZStack
, to clear the popup if showing.
I found that it was difficult to get a popup-animation to work smoothly, a simple opacity transition may be safer.
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
)
}
}