How to use Searchable, Search Suggestions, Search Scopes in SwiftUI | Modern Swift Concurrency #16
SwiftUI's .searchable modifier, combined with search scopes and search suggestions, gives you a full-featured search experience in a few lines — but the underlying filtering logic still needs to be wired correctly to avoid firing a network call or filter pass on every keystroke, and to keep UI state consistent with whatever the user most recently typed.
What You'll Learn
- How
.searchable,.searchScopes, and.searchSuggestionscompose together in SwiftUI - Why Combine's
.debounceis still the right tool for rate-limiting live search, even in an async/await app - How
@MainActoron a view model keeps search state mutations safe and consistent - How to drive scope-aware filtering inside a Combine subscriber without using the
asynckeyword - How
@Environment(\.isSearching)works in child views and why it only works when the child is inside the search-aware navigation container
Mental Model
Think of search state management as a mail sorting room. Letters (keystrokes) arrive constantly and at irregular intervals. You do not want to re-sort the entire room every time one letter slides through the slot — you wait for a brief pause in the mail before doing a full sort. Combine's .debounce is the pause. It says: "wait 300ms after the last letter before doing anything."
Once the pause fires, the sorting logic (filterRestaurants) runs synchronously on the already-fetched data. There is no async network call during filtering — the data is already in memory. The async call (loadRestaurants) happened once on view appear, and filtering simply slices that local data. This separation — async load once, sync filter always — is the key architectural decision that keeps the search experience fast and responsive.
Detailed Explanation
The .searchable modifier binds a String to the system search bar. As the user types, SwiftUI updates the bound variable on every character. Reacting directly to each character change with a network request or heavy computation would be wasteful and potentially create out-of-order results. Combine's .debounce operator addresses this: it waits for a specified interval of silence before emitting, so only the final query state after the user pauses triggers the filter.
Search scopes (.searchScopes) add a dimension to the search: in this sample, a cuisine category (American, Italian, Japanese, or All). The scope is modeled as an enum (SearchScopeOption) associated alongside the search text. By using combineLatest on both $searchText and $searchScope, the filter runs whenever either changes — a user switching scope mid-search gets immediate results without re-typing.
Search suggestions (.searchSuggestions) provide a dropdown of completions before the user finishes typing. They are driven by synchronous computed methods (getSearchSuggestions(), getRestaurantSuggestions()) rather than async fetches, because they operate on already-loaded in-memory data. The showSearchSuggestions guard limits suggestions to when the query is short (under 5 characters) — once the user has typed enough to show filtered results, suggestions are suppressed.
@Environment(\.isSearching) is a powerful tool for child views: it reports whether the search bar is active. Importantly, it only works in views that are direct or indirect children of the view that has .searchable applied — typically inside a NavigationStack hierarchy. A view at the same level or higher does not receive the environment value.
The @MainActor annotation on SearchableViewModel ensures that all mutations to allRestaurants, filteredRestaurants, searchText, and searchScope happen on the main thread. The Combine sink's closure runs on DispatchQueue.main (due to .debounce(for:scheduler: DispatchQueue.main)) and the view model's @MainActor isolation means no cross-actor writes occur.
Code Structure
SearchableBootcamp.swift contains two model types (Restaurant, CuisineOption), a service (RestaurantManager), a view model (SearchableViewModel with full Combine-driven filtering logic), a main view (SearchableBootcamp with .searchable, .searchScopes, and .searchSuggestions), and a child view (SearchChildView) that demonstrates @Environment(\.isSearching).
Complete Code
SearchableBootcamp.swift
import SwiftUI
import Combine
// Identifiable for use in ForEach; Hashable for NavigationLink(value:) and search suggestions.
struct Restaurant: Identifiable, Hashable {
let id: String
let title: String
let cuisine: CuisineOption
}
// RawValue is a String so we can display and filter by cuisine name easily.
enum CuisineOption: String {
case american, italian, japanese
}
// Service layer: returns all restaurants asynchronously (simulates a network fetch).
final class RestaurantManager {
func getAllRestaurants() async throws -> [Restaurant] {
[
Restaurant(id: "1", title: "Burger Shack", cuisine: .american),
Restaurant(id: "2", title: "Pasta Palace", cuisine: .italian),
Restaurant(id: "3", title: "Sushi Heaven", cuisine: .japanese),
Restaurant(id: "4", title: "Local Market", cuisine: .american),
]
}
}
// @MainActor: all @Published mutations on this class are on the main thread automatically.
@MainActor
final class SearchableViewModel: ObservableObject {
// The canonical, unfiltered list — loaded once on view appear.
@Published private(set) var allRestaurants: [Restaurant] = []
// The filtered list — updated reactively by the Combine pipeline.
@Published private(set) var filteredRestaurants: [Restaurant] = []
// Two-way bound to the search bar text field.
@Published var searchText: String = ""
// Two-way bound to the selected search scope tab.
@Published var searchScope: SearchScopeOption = .all
// Derived from allRestaurants; drives the scope picker tabs.
@Published private(set) var allSearchScopes: [SearchScopeOption] = []
let manager = RestaurantManager()
private var cancellables = Set<AnyCancellable>() // retains Combine subscriptions
// True when the user has typed something — drives which list the view displays.
var isSearching: Bool {
!searchText.isEmpty
}
// Show suggestions only for short queries; suppress them when results are already showing.
var showSearchSuggestions: Bool {
searchText.count < 5
}
// Nested enum gives SearchScopeOption a clear namespace inside the view model.
enum SearchScopeOption: Hashable {
case all
case cuisine(option: CuisineOption)
var title: String {
switch self {
case .all:
return "All"
case .cuisine(option: let option):
return option.rawValue.capitalized
}
}
}
init() {
addSubscribers()
}
private func addSubscribers() {
// combineLatest merges the latest value from both publishers into one emission.
// If either searchText or searchScope changes, the sink fires with both current values.
$searchText
.combineLatest($searchScope)
// debounce: wait 300ms of silence before running the filter — avoids per-keystroke work.
.debounce(for: 0.3, scheduler: DispatchQueue.main)
.sink { [weak self] (searchText, searchScope) in
// [weak self] prevents a retain cycle between the view model and the Combine subscription.
self?.filterRestaurants(searchText: searchText, currentSearchScope: searchScope)
}
.store(in: &cancellables) // must be stored or subscription is deallocated immediately
}
// Pure synchronous filter — no async needed because allRestaurants is already in memory.
private func filterRestaurants(searchText: String, currentSearchScope: SearchScopeOption) {
guard !searchText.isEmpty else {
filteredRestaurants = []
searchScope = .all // reset scope when search is cleared
return
}
// Filter on search scope
var restaurantsInScope = allRestaurants
switch currentSearchScope {
case .all:
break // no scope filter; use all restaurants
case .cuisine(let option):
restaurantsInScope = allRestaurants.filter({ $0.cuisine == option })
}
// Filter on search text
let search = searchText.lowercased()
filteredRestaurants = restaurantsInScope.filter({ restaurant in
let titleContainsSearch = restaurant.title.lowercased().contains(search)
let cuisineContainsSearch = restaurant.cuisine.rawValue.lowercased().contains(search)
return titleContainsSearch || cuisineContainsSearch
})
}
// Called once on view appear via .task — loads the full restaurant list.
func loadRestaurants() async {
do {
allRestaurants = try await manager.getAllRestaurants()
// Build scope options from the unique cuisines in the loaded data.
let allCuisines = Set(allRestaurants.map { $0.cuisine })
allSearchScopes = [.all] + allCuisines.map({ SearchScopeOption.cuisine(option: $0) })
} catch {
print(error)
}
}
// Returns string suggestions for .searchSuggestions — shown as tappable completions.
func getSearchSuggestions() -> [String] {
guard showSearchSuggestions else {
return []
}
var suggestions: [String] = []
let search = searchText.lowercased()
// Prefix-based suggestions: show a suggestion when the query starts to match a known term.
if search.contains("pa") {
suggestions.append("Pasta")
}
if search.contains("su") {
suggestions.append("Sushi")
}
if search.contains("bu") {
suggestions.append("Burger")
}
suggestions.append("Market")
suggestions.append("Grocery")
// Always offer cuisine names as completions.
suggestions.append(CuisineOption.italian.rawValue.capitalized)
suggestions.append(CuisineOption.japanese.rawValue.capitalized)
suggestions.append(CuisineOption.american.rawValue.capitalized)
return suggestions
}
// Returns Restaurant suggestions for .searchSuggestions — shown as NavigationLinks.
func getRestaurantSuggestions() -> [Restaurant] {
guard showSearchSuggestions else {
return []
}
var suggestions: [Restaurant] = []
let search = searchText.lowercased()
// Partial-match suggestions: when the user starts typing a cuisine name, show its restaurants.
if search.contains("ita") {
suggestions.append(contentsOf: allRestaurants.filter({ $0.cuisine == .italian }))
}
if search.contains("jap") {
suggestions.append(contentsOf: allRestaurants.filter({ $0.cuisine == .japanese }))
}
return suggestions
}
}
struct SearchableBootcamp: View {
@StateObject private var viewModel = SearchableViewModel()
var body: some View {
ScrollView {
VStack(spacing: 20) {
// Show filteredRestaurants while the user is typing; show all otherwise.
ForEach(viewModel.isSearching ? viewModel.filteredRestaurants : viewModel.allRestaurants) { restaurant in
NavigationLink(value: restaurant) {
restaurantRow(restaurant: restaurant)
}
}
}
.padding()
// Text("ViewModel is searching: \(viewModel.isSearching.description)")
// SearchChildView()
}
// searchText binding drives the view model's Combine pipeline.
.searchable(text: $viewModel.searchText, placement: .automatic, prompt: Text("Search restaurants..."))
// searchScope binding syncs the selected tab with the view model.
.searchScopes($viewModel.searchScope, scopes: {
ForEach(viewModel.allSearchScopes, id: \.self) { scope in
Text(scope.title)
.tag(scope) // tag must match the bound value type exactly
}
})
// searchSuggestions: shown in a dropdown while the search bar is active.
.searchSuggestions({
// String suggestions with .searchCompletion fill the search bar on tap.
ForEach(viewModel.getSearchSuggestions(), id: \.self) { suggestion in
Text(suggestion)
.searchCompletion(suggestion)
}
// Restaurant suggestions as NavigationLinks navigate directly on tap.
ForEach(viewModel.getRestaurantSuggestions(), id: \.self) { suggestion in
NavigationLink(value: suggestion) {
Text(suggestion.title)
}
}
})
// .navigationBarTitleDisplayMode(.inline)
.navigationTitle("Restaurants")
.task {
await viewModel.loadRestaurants() // initial load on view appear
}
.navigationDestination(for: Restaurant.self) { restaurant in
Text(restaurant.title.uppercased())
}
}
// Extracted view builder keeps the body readable.
private func restaurantRow(restaurant: Restaurant) -> some View {
VStack(alignment: .leading, spacing: 10) {
Text(restaurant.title)
.font(.headline)
.foregroundColor(.red)
Text(restaurant.cuisine.rawValue.capitalized)
.font(.caption)
}
.padding()
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color.black.opacity(0.05))
.tint(.primary)
}
}
// SearchChildView must be inside the NavigationStack that has .searchable applied
// to receive the correct isSearching value from the environment.
struct SearchChildView: View {
@Environment(\.isSearching) private var isSearching // true when the search bar is active
var body: some View {
Text("Child View is searching: \(isSearching.description)")
}
}
struct SearchableBootcamp_Previews: PreviewProvider {
static var previews: some View {
NavigationStack {
SearchableBootcamp()
}
}
}Code Walkthrough
$searchText.combineLatest($searchScope).debounce(for: 0.3, scheduler: DispatchQueue.main)— This pipeline is the heartbeat of the search feature.combineLatestensures the filter always runs with the current values of both text and scope..debouncemeans the filter runs at most once every 300ms of user inactivity — preventing the filter from re-running on every single keystroke while the user is mid-word.[weak self]in the Combine sink — Thecancellablesset is stored onSearchableViewModel, and the Combine subscription's closure capturesself. Without[weak self], the view model and the subscription would form a retain cycle, preventing deallocation.[weak self]breaks this cycle; the optional chainself?.filterRestaurants(...)safely does nothing if the view model has already gone away.filterRestaurantsis synchronous — This is an intentional design choice. All data is already in memory (allRestaurants) after the initial.taskload. There is no reason for filtering to beasync— introducingasynchere would require a newTaskin the Combine sink, which adds complexity and potential race conditions. Filtering is fast, synchronous, and safe.isSearching ? viewModel.filteredRestaurants : viewModel.allRestaurants— When the search bar is empty,isSearchingis false and the view shows the full list. When the user types,isSearchingflips to true and the view shows filtered results. This avoids showing an empty list while the debounce timer is running for the first time..searchCompletion(suggestion)— This modifier on a search suggestion tells SwiftUI to replace the current search bar text withsuggestionwhen the user taps it. This triggers another emission on$searchText, which flows through the Combine pipeline and re-filters.NavigationLink(value:)in suggestions — Tapping a restaurant suggestion navigates directly to its detail view vianavigationDestination(for: Restaurant.self). This requiresRestaurantto beHashable(it is declared so) and theNavigationStackto be the parent container.
Common Mistakes
Mistake: Running a network call inside the Combine sink on every character instead of debouncing.
Without .debounce, every keystroke fires the sink closure immediately. If the sink triggers a network request, a 6-character search creates 6 simultaneous requests. The responses can arrive out of order, causing the final result to reflect a partial query rather than the last character typed. Always debounce before network calls; use .debounce in Combine or Task.sleep plus task cancellation in pure async/await.
Mistake: Applying .searchable to a view but using @Environment(\.isSearching) in the same view or a parent.@Environment(\.isSearching) only works in views that are children of the view where .searchable is applied. If you use it in SearchableBootcamp itself (where .searchable is declared), you get the wrong value. Place the child view (SearchChildView) inside the ScrollView or another descendent view to receive the correct environment value.
Mistake: Not cancelling stale search state when the view disappears mid-filter.
If a user types a query, navigates away before the debounce fires, then navigates back, the debounce might fire on their return with a stale query. Cancel the Combine subscription (clear cancellables) or reset searchText in onDisappear to ensure a clean state on re-entry. The .task modifier handles async work cancellation automatically, but the Combine pipeline lives as long as cancellables does.
Key Takeaways
- Combine's
.debounceandasync/awaitare not mutually exclusive — use Combine for rate-limiting reactive UI events (like search text changes) andasync/awaitfor the actual data fetching and loading. - Keep filtering logic synchronous and operating on already-loaded in-memory data; the async boundary belongs at the service layer, not the filtering layer.
@Environment(\.isSearching)requires the consuming view to be a descendant of the view that applies.searchable— test this in the correct view hierarchy or the environment value will always befalse.
Last updated: June 27, 2026