How to implement search filters in SwiftUI

iOS 17+ Xcode 16+ Intermediate APIs: searchable, Menu Updated: May 11, 2026
TL;DR

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

  1. .searchable(text: $searchQuery, prompt:) — Attached to the NavigationStack, this renders a native iOS search bar and two-way binds typed text into searchQuery. SwiftUI automatically shows/hides the bar as the user scrolls, matching platform conventions.
  2. filteredArticles computed 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 — no onChange boilerplate needed.
  3. Menu with inline Pickers — Wrapping two Picker(.inline) views inside a single Menu gives a native popover-style filter panel without custom sheets. Each picker selection updates its bound @State and the list redraws instantly.
  4. 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.
  5. 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

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.