Skip to content

Instantly share code, notes, and snippets.

@ogtega
Last active April 14, 2025 00:40
Show Gist options
  • Save ogtega/3c972d92050c42106f0f7792e395a8cf to your computer and use it in GitHub Desktop.
Save ogtega/3c972d92050c42106f0f7792e395a8cf to your computer and use it in GitHub Desktop.
This Swift code builds a custom ComboBox (autocomplete/dropdown-like input) component in SwiftUI
import SwiftUI
struct ComboBox<Content, T>: View where Content: View, T: Hashable {
let hint: String
@Binding var text: String
@Binding var items: [T]
var filter: ((T) -> Bool)? = nil
var padding: EdgeInsets = EdgeInsets(
top: 0, leading: 0, bottom: 0, trailing: 0)
var content: (T) -> Content
@FocusState private var isFocused: Bool
@State private var geometry: CGRect = .zero
private var popup: some View {
let filteredItems = items.filter {
return filter?($0) ?? true
}
return VStack {
ScrollView {
LazyVStack(alignment: .leading, spacing: 2) {
ForEach(filteredItems, id: \.self) { item in
content(item)
}
}
}
.background(.background)
.overlay {
RoundedRectangle(cornerRadius: 6)
.strokeBorder(Color.gray.opacity(0.2))
}
.frame(
maxHeight: 200,
alignment: .top
)
.frame(width: geometry.width)
.fixedSize(horizontal: false, vertical: true)
.clipShape(RoundedRectangle(cornerRadius: 6))
}
}
var body: some View {
HStack {
TextField(hint, text: $text).textFieldStyle(.plain)
.focused($isFocused)
Button {
isFocused.toggle()
} label: {
Image(systemName: "chevron.down")
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
.padding(padding)
.overlay(alignment: .top) {
if isFocused {
popup
.frame(width: geometry.size.width) // Match the width of the HStack
.offset(y: geometry.size.height + 4) // Position below the HStack
.zIndex(1000)
} else {
EmptyView()
}
}
.background {
GeometryReader { reader in
Color.clear
.onAppear {
geometry = reader.frame(in: .global)
}
.onChange(of: reader.frame(in: .global)) {
_, frame in
geometry = frame
}
}
}
}
}
extension ComboBox {
func padding(_ insets: EdgeInsets) -> ComboBox {
var box = self
box.padding = insets
return box
}
func padding(_ edges: Edge.Set = .all, _ length: CGFloat? = nil) -> ComboBox
{
var box = self
let value = length ?? 8
var currentInsets = box.padding
if edges.contains(.top) { currentInsets.top = value }
if edges.contains(.leading) { currentInsets.leading = value }
if edges.contains(.bottom) { currentInsets.bottom = value }
if edges.contains(.trailing) { currentInsets.trailing = value }
box.padding = currentInsets
return box
}
func padding(_ length: CGFloat) -> ComboBox {
var box = self
box.padding = EdgeInsets(
top: length, leading: length, bottom: length, trailing: length)
return box
}
}
#Preview {
@Previewable @State var text = ""
@Previewable @State var items: [String] = [
"1 Item", "2 Items", "3 Items", "4 Items", "5 Items",
]
VStack {
ComboBox(
hint: "Search", text: $text, items: $items,
filter: {
item in
item.lowercased().contains(text.lowercased())
|| items.contains(where: {
$0.lowercased() == text.lowercased()
}) || text.isEmpty
}
) { item in
Button {
} label: {
Text(item)
.frame(
maxWidth: .infinity, alignment: .leading
)
.padding(.horizontal, 8)
.padding(.vertical, 8)
.contentShape(Rectangle())
}
.buttonStyle(.borderless)
}
.padding(.horizontal, 8)
.padding(.vertical, 6)
.background(
RoundedRectangle(cornerRadius: 6)
.strokeBorder(Color.gray.opacity(0.3))
)
Spacer()
}
.padding(8)
.frame(width: 300, height: 600)
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment