How to implement search filters in SwiftUI
Attach .searchable(text:) to your
NavigationStack for the search bar, then pair it
with a Menu in the toolbar to let users toggle
filter and sort options — both feed a single computed property that drives your
List.
enum Category: String, CaseIterable { case all, design, code, marketing }
@State private var query = ""
@State private var activeCategory = Category.all
var filtered: [Item] {
items.filter {
(activeCategory == .all || $0.category == activeCategory) &&
(query.isEmpty || $0.title.localizedStandardContains(query))
}
}
// Inside body:
List(filtered) { item in ItemRow(item: item) }
.searchable(text: $query, prompt: "Search items")
.toolbar {
Menu("Filter", systemImage: "line.3.horizontal.decrease.circle") {
Picker("Category", selection: $activeCategory) {
ForEach(Category.allCases, id: \.self) { Text($0.rawValue).tag($0) }
}
}
}
Full implementation
The example below builds a searchable list of articles with two independent filter axes: a
category picker and a sort order toggle. Both are housed inside a
single Menu in the navigation toolbar, keeping
the UI clean while exposing powerful filtering. The computed filteredArticles
property re-evaluates automatically whenever @State
changes, so there is no need for manual refresh calls.
import SwiftUI
// MARK: - Models
enum ArticleCategory: String, CaseIterable, Identifiable {
case all = "All"
case design = "Design"
case code = "Code"
case marketing = "Marketing"
var id: String { rawValue }
}
enum SortOrder: String, CaseIterable, Identifiable {
case newest = "Newest First"
case oldest = "Oldest First"
case title = "Title A–Z"
var id: String { rawValue }
}
struct Article: Identifiable {
let id = UUID()
let title: String
let category: ArticleCategory
let date: Date
}
// MARK: - View
struct ArticleListView: View {
// Sample data
private let articles: [Article] = [
Article(title: "Building design systems", category: .design, date: .now.addingTimeInterval(-86400 * 2)),
Article(title: "SwiftUI performance tips", category: .code, date: .now.addingTimeInterval(-86400 * 5)),
Article(title: "Growth hacking 101", category: .marketing,date: .now.addingTimeInterval(-86400 * 1)),
Article(title: "Color theory for devs", category: .design, date: .now.addingTimeInterval(-86400 * 8)),
Article(title: "Async/Await patterns", category: .code, date: .now.addingTimeInterval(-86400 * 3)),
Article(title: "App Store screenshots", category: .marketing,date: .now.addingTimeInterval(-86400 * 4)),
Article(title: "Typography basics", category: .design, date: .now.addingTimeInterval(-86400 * 6)),
Article(title: "Combine vs async streams", category: .code, date: .now.addingTimeInterval(-86400 * 7)),
]
@State private var searchQuery = ""
@State private var activeCategory = ArticleCategory.all
@State private var sortOrder = SortOrder.newest
// Computed filtered + sorted results
private var filteredArticles: [Article] {
let categoryFiltered = articles.filter {
activeCategory == .all || $0.category == activeCategory
}
let queryFiltered = categoryFiltered.filter {
searchQuery.isEmpty || $0.title.localizedStandardContains(searchQuery)
}
return queryFiltered.sorted {
switch sortOrder {
case .newest: return $0.date > $1.date
case .oldest: return $0.date < $1.date
case .title: return $0.title < $1.title
}
}
}
var body: some View {
NavigationStack {
Group {
if filteredArticles.isEmpty {
ContentUnavailableView.search(text: searchQuery)
} else {
List(filteredArticles) { article in
ArticleRow(article: article)
}
.listStyle(.plain)
}
}
.navigationTitle("Articles")
.searchable(text: $searchQuery, prompt: "Search articles")
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
filterMenu
}
}
}
}
// MARK: Filter Menu
private var filterMenu: some View {
Menu {
// Category filter
Section("Category") {
Picker("Category", selection: $activeCategory) {
ForEach(ArticleCategory.allCases) { cat in
Label(cat.rawValue,
systemImage: activeCategory == cat ? "checkmark" : "")
.tag(cat)
}
}
.pickerStyle(.inline)
}
// Sort order
Section("Sort") {
Picker("Sort order", selection: $sortOrder) {
ForEach(SortOrder.allCases) { order in
Text(order.rawValue).tag(order)
}
}
.pickerStyle(.inline)
}
} label: {
Label("Filter", systemImage: "line.3.horizontal.decrease.circle")
.symbolVariant(activeCategory != .all ? .fill : .none)
.accessibilityLabel(
activeCategory == .all
? "Filter articles"
: "Filter articles — \(activeCategory.rawValue) active"
)
}
}
}
// MARK: - Row
struct ArticleRow: View {
let article: Article
private static let formatter = RelativeDateTimeFormatter()
var body: some View {
VStack(alignment: .leading, spacing: 4) {
Text(article.title)
.font(.body)
HStack(spacing: 6) {
Text(article.category.rawValue)
.font(.caption)
.padding(.horizontal, 8)
.padding(.vertical, 2)
.background(.tint.opacity(0.12))
.foregroundStyle(.tint)
.clipShape(Capsule())
Text(Self.formatter.localizedString(for: article.date, relativeTo: .now))
.font(.caption)
.foregroundStyle(.secondary)
}
}
.padding(.vertical, 4)
.accessibilityElement(children: .combine)
}
}
// MARK: - Preview
#Preview {
ArticleListView()
}
How it works
-
.searchable(text: $searchQuery, prompt:)— Attached to theNavigationStack, this renders a native iOS search bar and two-way binds typed text intosearchQuery. SwiftUI automatically shows/hides the bar as the user scrolls, matching platform conventions. -
filteredArticlescomputed property — Rather than storing a separate filtered array, the computed property re-derives results on every state change. SwiftUI's dependency tracking means only renders that need the list trigger a re-evaluation — noonChangeboilerplate needed. -
Menuwith inlinePickers — Wrapping twoPicker(.inline)views inside a singleMenugives a native popover-style filter panel without custom sheets. Each picker selection updates its bound@Stateand the list redraws instantly. -
Active-filter icon badge — The
.symbolVariant(activeCategory != .all ? .fill : .none)modifier swaps the filter icon to its filled variant when a category is active, giving users a clear visual cue that results are being filtered without needing extra UI real estate. -
ContentUnavailableView.search(text:)— iOS 17's built-in empty-state view handles the no-results case with zero extra markup, using the user's actual query in the default message for helpful feedback.
Variants
Multi-select tag filters
Replace the single-selection Picker with a
Set-backed toggle group when users need to
combine multiple categories at once.
@State private var selectedTags: Set<ArticleCategory> = []
private var filteredArticles: [Article] {
articles.filter {
(selectedTags.isEmpty || selectedTags.contains($0.category)) &&
(searchQuery.isEmpty || $0.title.localizedStandardContains(searchQuery))
}
}
// In your Menu:
Section("Categories") {
ForEach(ArticleCategory.allCases.filter { $0 != .all }) { cat in
Button {
if selectedTags.contains(cat) {
selectedTags.remove(cat)
} else {
selectedTags.insert(cat)
}
} label: {
Label(cat.rawValue,
systemImage: selectedTags.contains(cat) ? "checkmark" : "")
}
}
}
// Clear all button
if !selectedTags.isEmpty {
Button("Clear filters", role: .destructive) {
selectedTags.removeAll()
}
}
Search scope buttons
For tab-style category switching directly inside the search bar, use the
searchable(text:editableTokens:scopes:) overload
(iOS 17+) or the simpler
.searchScopes($activeScope) modifier, which
renders segmented-control-style scope buttons beneath the search field — ideal when categories are
few (2–4) and mutually exclusive. Pass the same
ArticleCategory enum as the scope binding and
remove the Menu category section to avoid
duplicating the control.
Common pitfalls
-
iOS 16 and
ContentUnavailableView—ContentUnavailableView.search(text:)is iOS 17+. If you need iOS 16 support, guard it withif #available(iOS 17, *)and fall back to a plainVStackwith descriptive text. -
Placing
.searchableon the wrong view — The modifier must be attached to (or inside) aNavigationStackorNavigationSplitViewto appear in the correct position. Attaching it to a bareListoutside a navigation container shows the bar in the wrong layout on some devices. -
Filtering large datasets on the main thread —
For lists exceeding ~5 000 items, move filtering into a
Taskwith debounce ononChange(of: searchQuery)to avoid per-keystroke main-thread work that can drop frames. -
VoiceOver missing filter state —
Always set an
accessibilityLabelon your filter button that announces the active filter, as shown in the full example. A generic "Filter" label leaves VoiceOver users guessing whether filters are active.
Prompt this with Claude Code
When using Soarias or Claude Code directly to implement this:
Implement search filters in SwiftUI for iOS 17+. Use searchable(text:prompt:) and Menu with inline Picker for category and sort. Drive the List from a computed filtered property — no separate @State array. Fill the filter icon when a non-default filter is active. Make it accessible (VoiceOver labels on the filter button announcing active state). Add a #Preview with realistic sample data (8+ items, mixed categories).
In the Soarias Build phase, drop this prompt into the Implementation step after your screen mockups are approved — Claude Code will scaffold the full filterable list and wire it into your existing navigation structure.
Related
FAQ
Does this work on iOS 16?
Mostly yes — .searchable and
Menu both exist on iOS 16.
The only iOS 17-exclusive piece is ContentUnavailableView.search(text:);
wrap it in if #available(iOS 17, *) and show a plain
"No results for …" Text view on iOS 16 to maintain
compatibility.
How do I persist the active filter between app launches?
Replace @State with
@AppStorage on your
activeCategory and
sortOrder properties. Because both backing types
are RawRepresentable with a
String raw value, they store and restore from
UserDefaults automatically with no extra
serialization code.
What's the UIKit equivalent?
In UIKit you'd use a UISearchController (set on
navigationItem.searchController) for the search bar
and observe UISearchResultsUpdating.updateSearchResults(for:)
for query changes. Filters would live in a
UIMenu attached to a
UIBarButtonItem. The SwiftUI approach is significantly
less boilerplate and handles keyboard/animation automatically.
Last reviewed: 2026-05-11 by the Soarias team.