Search Scopes And Suggestions
A modifier that adds a search field with scope buttons and suggestion rows that narrow as you type.

- Navigation
- iOS 18.0+
- intermediate
- Uses
searchable(text:isPresented:prompt:)searchScopes(_:activation:_:)searchSuggestionssearchCompletion- Origin
- Original work
- Found by
- search · searchable · scopes · suggestions · autocomplete · filter · búsqueda · buscador · ámbitos · sugerencias · autocompletar · filtro
Description
Section titled “Description”Adds a search field with scope buttons and suggestion rows. Apply it inside a NavigationStack.
Tapping a suggestion fills in the field. The suggestions narrow as the person types.
private enum SearchScopesAndSuggestionsDemoScope: String, CaseIterable { case all = "All" case fruit = "Fruit" case vegetable = "Vegetables"}
private struct SearchScopesAndSuggestionsDemoProduce: Identifiable { var id: String { name } let name: String let symbol: String let scope: SearchScopesAndSuggestionsDemoScope}
struct SearchScopesAndSuggestionsDemo: View { private let produce = [ SearchScopesAndSuggestionsDemoProduce(name: "Apple", symbol: "apple.logo", scope: .fruit), SearchScopesAndSuggestionsDemoProduce(name: "Apricot", symbol: "leaf.fill", scope: .fruit), SearchScopesAndSuggestionsDemoProduce(name: "Grape", symbol: "circle.grid.3x3.fill", scope: .fruit), SearchScopesAndSuggestionsDemoProduce(name: "Papaya", symbol: "sun.max.fill", scope: .fruit), SearchScopesAndSuggestionsDemoProduce(name: "Asparagus", symbol: "leaf", scope: .vegetable), SearchScopesAndSuggestionsDemoProduce(name: "Bell pepper", symbol: "flame.fill", scope: .vegetable), SearchScopesAndSuggestionsDemoProduce(name: "Carrot", symbol: "carrot.fill", scope: .vegetable), SearchScopesAndSuggestionsDemoProduce(name: "Spinach", symbol: "leaf.circle.fill", scope: .vegetable), ]
@State private var text = "" @State private var isSearching = false @State private var scope = SearchScopesAndSuggestionsDemoScope.all
private var results: [SearchScopesAndSuggestionsDemoProduce] { produce.filter { item in (scope == .all || item.scope == scope) && (text.isEmpty || item.name.localizedStandardContains(text)) } }
var body: some View { NavigationStack { List(results) { item in Label(item.name, systemImage: item.symbol) } .overlay { if results.isEmpty { ContentUnavailableView.search(text: text) } } .navigationTitle("Market") .modifier( SearchScopesAndSuggestions( text: $text, isPresented: $isSearching, scope: $scope, scopes: SearchScopesAndSuggestionsDemoScope.allCases, scopeTitle: \.rawValue, suggestions: produce.map(\.name) ) ) // The scope bar goes away with the search, so its filter does too. .onChange(of: isSearching) { _, isSearching in if !isSearching { scope = .all } } } }}API reference
Section titled “API reference”| Parameter | Type | Default |
|---|---|---|
text | Binding<String> | Required |
isPresented | Binding<Bool> | Required |
scope | Binding<Scope> | Required |
scopes | [Scope] | Required |
scopeTitle | (Scope) -> String | Required |
suggestions | [String] | Required |
prompt | LocalizedStringResource | "Search" |
Copy this code into your project, or download the file.
// SPDX-License-Identifier: MIT// Copyright (c) 2026 Oscar Canton and SwiftUI Blocks contributors
import SwiftUI
/// Adds a search field with scope buttons and suggestion rows. Apply it inside a `NavigationStack`.////// Tapping a suggestion fills in the field. The suggestions narrow as the person types.struct SearchScopesAndSuggestions<Scope: Hashable>: ViewModifier { @Binding var text: String @Binding var isPresented: Bool @Binding var scope: Scope let scopes: [Scope] let scopeTitle: (Scope) -> String let suggestions: [String] var prompt: LocalizedStringResource = "Search"
func body(content: Content) -> some View { content .searchable(text: $text, isPresented: $isPresented, prompt: prompt) .searchScopes($scope, activation: .onSearchPresentation) { ForEach(scopes, id: \.self) { scope in Text(scopeTitle(scope)).tag(scope) } } .searchSuggestions { ForEach(SearchScopesAndSuggestionsMath.matches(in: suggestions, for: text), id: \.self) { suggestion in Label(suggestion, systemImage: "magnifyingglass") .searchCompletion(suggestion) } } }}
enum SearchScopesAndSuggestionsMath { /// The suggestions worth showing: unique, containing the text, and not what is already typed. static func matches(in suggestions: [String], for text: String, limit: Int = 5) -> [String] { let query = text.trimmingCharacters(in: .whitespacesAndNewlines) var seen = Set<String>() let unique = suggestions.filter { seen.insert($0).inserted }
let matching = query.isEmpty ? unique : unique.filter { $0.localizedStandardContains(query) && $0.localizedCaseInsensitiveCompare(query) != .orderedSame } return Array(matching.prefix(max(limit, 0))) }}
// MARK: - Demo
private enum SearchScopesAndSuggestionsDemoScope: String, CaseIterable { case all = "All" case fruit = "Fruit" case vegetable = "Vegetables"}
private struct SearchScopesAndSuggestionsDemoProduce: Identifiable { var id: String { name } let name: String let symbol: String let scope: SearchScopesAndSuggestionsDemoScope}
struct SearchScopesAndSuggestionsDemo: View { private let produce = [ SearchScopesAndSuggestionsDemoProduce(name: "Apple", symbol: "apple.logo", scope: .fruit), SearchScopesAndSuggestionsDemoProduce(name: "Apricot", symbol: "leaf.fill", scope: .fruit), SearchScopesAndSuggestionsDemoProduce(name: "Grape", symbol: "circle.grid.3x3.fill", scope: .fruit), SearchScopesAndSuggestionsDemoProduce(name: "Papaya", symbol: "sun.max.fill", scope: .fruit), SearchScopesAndSuggestionsDemoProduce(name: "Asparagus", symbol: "leaf", scope: .vegetable), SearchScopesAndSuggestionsDemoProduce(name: "Bell pepper", symbol: "flame.fill", scope: .vegetable), SearchScopesAndSuggestionsDemoProduce(name: "Carrot", symbol: "carrot.fill", scope: .vegetable), SearchScopesAndSuggestionsDemoProduce(name: "Spinach", symbol: "leaf.circle.fill", scope: .vegetable), ]
@State private var text = "" @State private var isSearching = false @State private var scope = SearchScopesAndSuggestionsDemoScope.all
private var results: [SearchScopesAndSuggestionsDemoProduce] { produce.filter { item in (scope == .all || item.scope == scope) && (text.isEmpty || item.name.localizedStandardContains(text)) } }
var body: some View { NavigationStack { List(results) { item in Label(item.name, systemImage: item.symbol) } .overlay { if results.isEmpty { ContentUnavailableView.search(text: text) } } .navigationTitle("Market") .modifier( SearchScopesAndSuggestions( text: $text, isPresented: $isSearching, scope: $scope, scopes: SearchScopesAndSuggestionsDemoScope.allCases, scopeTitle: \.rawValue, suggestions: produce.map(\.name) ) ) // The scope bar goes away with the search, so its filter does too. .onChange(of: isSearching) { _, isSearching in if !isSearching { scope = .all } } } }}
#Preview { SearchScopesAndSuggestionsDemo()}