Last active
April 14, 2025 00:40
-
-
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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