SwiftUI Searchable in iOS 26: Tokens, Scopes, Suggestions, and Search Focus
A hands-on tour of SwiftUI .searchable in iOS 26, from tokens and scopes to suggestions, focus control, and the new toolbar behavior that keeps the tab bar visible during search. Runnable code and the debounce pattern I use in production.
The SwiftUI .searchable modifier adds a native, adaptive search field to any view hierarchy by binding a String to the current query and letting SwiftUI decide where the field lives on iPhone, iPad, Mac, and visionOS. In iOS 26 the API surface is finally large enough to be its own topic: you get search tokens for tag-style filters, .searchScopes for segmented category pickers, typed .searchSuggestions, a .searchFocused modifier, and the new .searchPresentationToolbarBehavior that (at last) lets you keep the tab bar visible while searching. This guide walks through every piece with runnable code.
.searchable(text:) attaches a search field to a NavigationStack or NavigationSplitView and gives you the query as a normal @State string. Filtering runs in your view like any other data transformation.
Search tokens turn free-text queries into structured tag filters using a binding to [Token] and the .searchable(text:tokens:) overload.
.searchScopes renders a segmented picker under the search field so users can narrow by category (All, Users, Posts) without you writing extra UI.
.searchSuggestions shows an autocomplete list; each row must set .searchCompletion("term") so tapping it submits the query.
Read @Environment(\.isSearching) to know when the field is active, and call dismissSearch() to close it programmatically after navigation.
iOS 26's .searchPresentationToolbarBehavior(.avoidHidingContent) stops the tab bar from disappearing when the keyboard opens, a long-requested UX fix.
How the .searchable modifier works
At its simplest, .searchable takes a Binding<String> and attaches a native search field to the enclosing navigation container. It doesn't filter anything on its own. It just hands you the current query as state. The mental model I use, after living through the old UISearchController assign-and-pray days from the Objective-C era, is that .searchable is the field, and your view body is the filter. Whatever you compute from that query string is what the user sees.
import SwiftUI
struct BookList: View {
@State private var query = ""
let books: [Book]
var filtered: [Book] {
guard !query.isEmpty else { return books }
return books.filter { $0.title.localizedCaseInsensitiveContains(query) }
}
var body: some View {
NavigationStack {
List(filtered) { book in
Text(book.title)
}
.navigationTitle("Library")
.searchable(text: $query, prompt: "Search books")
}
}
}
Three things are worth calling out. First, the modifier must be attached inside a navigation container. Put it on the root List or directly on the NavigationStack, not on the outermost WindowGroup. Second, prompt replaces the greyed placeholder text and accepts a LocalizedStringKey, so it participates in String Catalogs automatically. Third, filtering happens synchronously every time the query changes; for datasets larger than a few thousand rows you'll want to move the work off the main actor, which we'll cover in the debouncing section.
If you're new to the NavigationStack container that hosts the search field, my SwiftUI NavigationStack complete guide covers the type-safe routing side of the story.
Placement: where SwiftUI puts the search field
The placement parameter is a hint, not a command. SwiftUI picks the final location based on the current platform, size class, and container. On iPhone in portrait, .automatic becomes the navigation bar drawer that appears when you pull down; on Mac Catalyst and iPad regular width, it sits inline in the toolbar; on NavigationSplitView it moves to the sidebar. The values you can request are .automatic, .toolbar, .sidebar, .navigationBarDrawer(displayMode:), and (new in iOS 26 for tabbed apps) .tabBar, which docks the field inside the floating tab bar itself.
If your app uses the new iOS 26 tab API and you want the search field docked to the tab bar rather than the navigation bar, pair .searchable with the tab-role toolbar surface. Details in my SwiftUI TabView in iOS 26 guide. The behavior also depends on .searchPresentationToolbarBehavior, covered further down.
How do I add search suggestions in SwiftUI?
Use the .searchSuggestions modifier to render an autocomplete list below the search field while the user types. Each suggestion row must apply .searchCompletion(_:); tapping a row copies that string into the query binding and submits. Without .searchCompletion, taps do nothing. Honestly, that's one of the top "why aren't my suggestions working" bugs I see on the developer forums.
struct SuggestingSearch: View {
@State private var query = ""
let history: [String]
let books: [Book]
var body: some View {
NavigationStack {
List(books.filter { $0.title.contains(query) }) { book in
Text(book.title)
}
.searchable(text: $query)
.searchSuggestions {
ForEach(history.prefix(5), id: \.self) { term in
Label(term, systemImage: "clock")
.searchCompletion(term)
}
Divider()
ForEach(books.prefix(3)) { book in
Label(book.title, systemImage: "book")
.searchCompletion(book.title)
}
}
}
}
}
Two subtleties are easy to miss. You can pass an active-visibility parameter (.searchSuggestions(.hidden, for: .content)) to suppress suggestions once the user has already submitted a query, which keeps the list from covering results. And .searchCompletion also has an overload that accepts a typed value if you're using .searchable(text:tokens:), which lets a tap add a token instead of replacing the query string. The official searchSuggestions documentation lists every overload.
Filtering by category with .searchScopes
Scopes turn the search field into a two-dimensional filter: the text query is one axis, the scope selection is the other. SwiftUI renders scopes as a segmented control directly beneath the search field on iOS, and as a scope button row on macOS. You bind a value of any Hashable type (an enum is the ergonomic choice) and iterate over its cases.
enum Scope: String, CaseIterable, Hashable {
case all, users, posts, tags
}
struct ScopedSearch: View {
@State private var query = ""
@State private var scope: Scope = .all
var body: some View {
NavigationStack {
ResultsList(query: query, scope: scope)
.searchable(text: $query, prompt: "Search")
.searchScopes($scope, activation: .onSearchPresentation) {
ForEach(Scope.allCases, id: \.self) { scope in
Text(scope.rawValue.capitalized).tag(scope)
}
}
}
}
}
The activation parameter controls when the scope bar appears. .onSearchPresentation (the default in iOS 17+) shows scopes only when the user is actively typing; .onTextEntry waits until the query has at least one character. Choose the second variant if scopes only make sense with a real query. For a plain "browse all" state, showing an empty segmented control looks broken.
Search tokens for tag-style queries
Search tokens are the "pills" you see in Mail and Reminders: a typed value rendered as a chip inside the search field, alongside the free-text query. The .searchable(text:tokens:token:) overload takes a binding to your token collection and a view builder that describes how each token looks. This is the API I reach for whenever the search grammar is naturally multi-dimensional: dates, people, tags, filters.
struct Tag: Identifiable, Hashable {
let id = UUID()
let name: String
}
struct TokenSearch: View {
@State private var query = ""
@State private var tokens: [Tag] = []
let suggestedTags: [Tag]
var body: some View {
NavigationStack {
ResultsList(query: query, tags: tokens)
.searchable(text: $query, tokens: $tokens) { token in
Label(token.name, systemImage: "tag")
}
.searchSuggestions {
ForEach(suggestedTags) { tag in
Label(tag.name, systemImage: "tag")
.searchCompletion(tag) // typed, not String
}
}
}
}
}
The magic here is the typed .searchCompletion(tag). Because the overload of .searchable knows the element type of the tokens array, SwiftUI wires the completion to append a token rather than replace the query string. The user sees a chip appear next to their typed text, and your tokens binding updates. To let the user delete a token, they just hit backspace on an empty query and SwiftUI handles the rest. You get all the interaction for free.
For richer chip appearances (colored backgrounds, avatars), use the token view builder to compose anything you like; SwiftUI clips it to the field's height and centers it vertically. In my experience, keep tokens under 100 points wide, or the field becomes hard to read.
Programmatic control: isSearching, dismissSearch, and .searchFocused
Three environment tools give you programmatic control over the search state without owning it directly. @Environment(\.isSearching) is a Bool that's true whenever the search field is active. Read it inside any child view to conditionally render "empty state" content or an overlay. @Environment(\.dismissSearch) is a callable value that closes the search field; call it after a user picks a result so they don't return to a still-active search. And .searchFocused($isFocused), added in iOS 18 and unchanged in iOS 26, gives you a FocusState-style handle for imperative focus control.
struct ResultsList: View {
@Environment(\.isSearching) private var isSearching
@Environment(\.dismissSearch) private var dismissSearch
@FocusState private var searchFocused: Bool
let results: [Book]
var body: some View {
List(results) { book in
NavigationLink(book.title) {
BookDetail(book: book)
.onAppear { dismissSearch() }
}
}
.overlay {
if isSearching && results.isEmpty {
ContentUnavailableView.search
}
}
.searchFocused($searchFocused)
.toolbar {
Button("Search") { searchFocused = true }
}
}
}
A few production notes. ContentUnavailableView.search is the Apple-provided empty state that renders the standard "No Results" glyph with the current query interpolated; always prefer it over a hand-rolled placeholder. Reading isSearching only works in a view inside the container that owns .searchable. If you place the read at the same level as the modifier, you'll always get false (I've burned an afternoon on that one myself). For FocusState patterns in general, including how .searchFocused integrates with keyboard navigation and form focus chains, my SwiftUI FocusState guide for iOS 26 has the deep dive.
How do you debounce a search in SwiftUI?
SwiftUI runs your body every time the query binding changes, which for a fast typist is 6-8 changes per second. If each keystroke triggers a network call or a heavy SQL query, you need to debounce. The cleanest pattern in Swift 6 is a .task(id: query) modifier that cancels the previous task automatically when the id changes and awaits a small delay before doing the real work.
struct DebouncedSearch: View {
@State private var query = ""
@State private var results: [Book] = []
var body: some View {
NavigationStack {
List(results) { Text($0.title) }
.searchable(text: $query)
.task(id: query) {
guard !query.isEmpty else { results = []; return }
do {
try await Task.sleep(for: .milliseconds(300))
results = try await BookAPI.search(query)
} catch is CancellationError {
// The next keystroke cancelled us. Fine.
} catch {
// Real error, surface it.
}
}
}
}
}
The trick is that .task(id:) ties task lifetime to the query. When the user types the next character, SwiftUI cancels the running task before starting a new one; the Task.sleep throws CancellationError before the network call ever fires. Only the last keystroke, the one that hasn't been superseded within 300 ms, survives long enough to run the request. No Combine, no manual Timer, no debounce operator to remember. So, if you want to compose this with a full async pipeline, my type-safe API client walkthrough shows how the URLSession call itself should be structured.
iOS 26: .searchPresentationToolbarBehavior
The most requested search-related change in iOS 26 was fixing the way the tab bar disappears when the user taps the search field. Apple's answer is .searchPresentationToolbarBehavior(_:), which takes a SearchPresentationToolbarBehavior value. The two variants are .avoidHidingContent (the tab bar stays visible while the field is focused) and .default, which preserves the pre-iOS-26 auto-hide behavior. Attach the modifier to the same view as .searchable.
Use .avoidHidingContent for tabbed apps where losing the tab bar during search feels like a modal takeover. Most content browsers, file managers, and social apps benefit. Keep .default for immersive experiences (Photos, Maps) where you genuinely want the search field to fill the screen. The SearchPresentationToolbarBehavior API reference lists the exact interaction with the floating tab bar on iPad.
Why is my SwiftUI searchable not showing?
Nine times out of ten, the search field isn't rendering because the modifier is applied outside a navigation container. .searchable hooks into the toolbar of the nearest NavigationStack or NavigationSplitView; put it on a bare List at the root of your WindowGroup and SwiftUI has nowhere to hang it. The fix is always to wrap the view in a navigation container, then apply .searchable to the container's root child.
The other frequent bugs I've debugged in review sessions:
Suggestions don't submit. Every suggestion row must apply .searchCompletion(_:). A plain Text is not tappable.
isSearching is always false. You're reading the environment value at the same level as the .searchable modifier. Move the read into a child view.
Search field hidden by the keyboard on iPad. The ScrollView containing your results needs .scrollDismissesKeyboard(.interactively).
Query resets when navigating. The query is @State on the parent view; if you push a detail view that pops back and re-creates the parent, state resets. Lift the query to a parent, or use @SceneStorage.
Tokens duplicate on every keystroke. You're mutating the tokens array from a custom onChange handler in addition to .searchable managing it. Pick one path and stick with it.
Frequently Asked Questions
What is the difference between .searchable and .searchScopes?
.searchable attaches the search field itself and binds the query text. .searchScopes is a companion modifier that adds a segmented control below the field for filtering by category. You use them together: .searchable for the input, .searchScopes for the axis selector.
Does .searchable work with UIKit view controllers?
Not directly. .searchable requires a SwiftUI NavigationStack or NavigationSplitView. If you're embedding SwiftUI inside a UIKit navigation controller, use UISearchController on the UIKit side, or move the navigation container into SwiftUI.
Can I use .searchable inside a sheet or popover?
Yes, as long as the sheet's root view is a NavigationStack. Attach .searchable to the stack's content and it renders in the sheet's navigation bar. On iOS 26 with medium detents, the field docks to the top of the sheet.
Why does my search field disappear when the tab bar is present in iOS 26?
That's the classic auto-hide behavior. Add .searchPresentationToolbarBehavior(.avoidHidingContent) to the same view as .searchable. The modifier is new in iOS 26 and keeps the tab bar visible while the search field is focused.
How do I trigger a search action when the user hits return?
Attach .onSubmit(of: .search) { runSearch() } to any view inside the searchable container. It fires when the user taps the return key on the software keyboard, or presses Enter on Mac. Combine it with the .task(id: query) debounce pattern for the best UX.
A production-ready guide to bridging SwiftUI and UIKit in iOS 26: UIViewRepresentable, UIViewControllerRepresentable, coordinators, UIHostingController, sizing, and the pitfalls that trip up mixed codebases.
SwiftUI @FocusState binds view focus to a value. Learn iOS 26 patterns for keyboard toolbars, focus chains, sheet dismissals, OTP inputs, and hardware keyboard support with working code.
The SwiftUI @Entry macro replaces the classic EnvironmentKey boilerplate with a single line. Here's how to use it for EnvironmentValues, FocusedValues, Transaction, and ContainerValues in iOS 26 apps.