Master Guide: How to Add a Search Bar in SwiftUI with .searchable()
In the modern era of app development, the ability to find content quickly is not an optional feature; it is a necessity. For an iOS Developer, implementing search bars used to involve complex delegates, search controllers, and tedious UI state management in UIKit.
However, with the maturity of Swift programming and the evolution of SwiftUI, Apple has gifted us one of the most powerful and elegant modifiers in the framework: .searchable().
In this comprehensive tutorial, we will learn how to add a search bar in SwiftUI, manage real-time data filtering, and adapt our implementation to shine on iOS, macOS, and watchOS, all from Xcode.
What is the .searchable() modifier?
Introduced in iOS 15, .searchable() is a view modifier that marks a container (usually a NavigationStack or NavigationSplitView) as “searchable”.
Unlike old imperative approaches where you had to draw the search bar yourself, SwiftUI takes care of rendering the native system search bar in the correct location based on the platform. This ensures your app always feels native, whether on an iPhone, iPad, Mac, or Apple Watch.
Why is it vital for the modern iOS Developer?
- Declarative: You define what you want (a search bar), not how to draw it.
- Contextual: It adapts automatically. On iOS, it appears under the navigation bar; on macOS, in the toolbar.
- Integrated: It automatically handles presentation animations, keyboard focus, and the “cancel” button.
Basic Implementation on iOS
Let’s start with the most common scenario: a contact list on an iPhone. For this, we need a source of truth (our data) and a state variable to store what the user types.
Step 1: State Configuration
Open Xcode and create a new view. We will need a @State property to bind the search text.
import SwiftUI
struct ContactsView: View {
// 1. The text the user types
@State private var searchText = ""
// Sample data
let names = ["Alejandro", "Berta", "Carlos", "Diana", "Elena", "Fernando", "Gabriela"]
var body: some View {
NavigationStack {
List {
// Step 2: Show data (filtered later)
ForEach(names, id: \.self) { name in
Text(name)
}
}
.navigationTitle("Contacts")
// Step 3: The magic of SwiftUI
.searchable(text: $searchText, prompt: "Search contact")
}
}
}
By adding .searchable(text: $searchText), SwiftUI automatically injects the search bar. The prompt parameter is the placeholder text that guides the user.
Step 2: Filtering Logic
The .searchable() modifier does not filter the data for you; it simply gives you the text the user has typed. As a good iOS Developer, you must implement the logic.
The cleanest way to do this in Swift programming is via a computed property:
var filteredNames: [String] {
if searchText.isEmpty {
return names
} else {
return names.filter { $0.localizedCaseInsensitiveContains(searchText) }
}
}
Now, update our ForEach to use filteredNames instead of names.
List {
ForEach(filteredNames, id: \.self) { name in
Text(name)
}
}
With this simple change, you have a functional real-time search. localizedCaseInsensitiveContains is crucial here, as it ignores capitalization and respects the device’s language rules.
Leveling Up: Search Suggestions
An excellent search experience doesn’t just wait for the user to finish typing; it guides them. SwiftUI allows adding visual suggestions that appear before or during typing. This is especially useful in e-commerce or media apps.
.searchable(text: $searchText) {
// These views appear when the user taps the search bar
// but hasn't typed much or anything yet.
if searchText.isEmpty {
Text("Recent").font(.caption).foregroundStyle(.secondary)
Button("Carlos") { searchText = "Carlos" }
Button("Elena") { searchText = "Elena" }
} else {
// Suggestions based on what they type
ForEach(names.filter { $0.hasPrefix(searchText) }, id: \.self) { name in
Text("Looking for \(name)?").searchCompletion(name)
}
}
}
The .searchCompletion(_:) modifier is a gem. When the user taps that row, SwiftUI automatically fills the search bar with that text and executes the search.
Multiplatform Adaptation: macOS and watchOS
One of the pillars of Swift and SwiftUI is the ability to write once and deploy everywhere. However, adding a search bar in SwiftUI has nuances depending on the device.
Behavior on macOS
On macOS, design conventions are different. A search bar usually doesn’t scroll with content; it usually lives fixed in the top right of the window or in the sidebar. By using .searchable on macOS inside a NavigationSplitView, Xcode will compile a native Mac interface.
struct MacContentView: View {
@State private var searchText = ""
var body: some View {
NavigationSplitView {
List(filteredNames, id: \.self) { name in
NavigationLink(name, destination: Text("Details of \(name)"))
}
.searchable(text: $searchText, placement: .sidebar) // Mac Specific
} detail: {
Text("Select a contact")
}
}
}
The placement parameter is vital here.
.sidebar: Places the search at the top of the sidebar list (standard in Finder, Mail)..toolbar: Places it in the top right toolbar.
The watchOS Challenge
On the Apple Watch, screen space is small. When using .searchable, the system collapses the search bar into an iconic button (magnifying glass) at the top of the list. When pressed, it enters a text input mode (dictation or QWERTY keyboard on Series 7+).
struct WatchContactsView: View {
@State private var searchText = ""
var body: some View {
NavigationStack {
List {
// Content...
}
}
.searchable(text: $searchText)
}
}
Important Note: On watchOS, ensure that .searchable is applied to the top navigation container, not internal elements, to ensure the system draws it correctly under the navigation title.
Advanced Techniques for the Senior iOS Developer
To differentiate yourself as an expert in Swift programming, you must handle edge cases and performance.
1. Scopes
Sometimes, searching by name isn’t enough. What if you want to search by “Name” or by “Phone”? Here is where Search Scopes come in.
enum SearchScope: String, CaseIterable {
case name = "Name"
case phone = "Phone"
}
struct ScopedSearchView: View {
@State private var searchText = ""
@State private var searchScope: SearchScope = .name
var body: some View {
NavigationStack {
List { ... }
.searchable(text: $searchText)
.searchScopes($searchScope) {
ForEach(SearchScope.allCases, id: \.self) { scope in
Text(scope.rawValue).tag(scope)
}
}
}
}
}
This automatically adds a segmented control bar under the search field on iOS, allowing the user to refine their intent instantly.
2. Tokens (iOS 16+)
Similar to the Mail app or Finder, Tokens allow complex searches by combining filters.
// Requires defining a model that conforms to Identifiable
.searchable(text: $text, tokens: $selectedTokens, suggestedTokens: $suggestions) { token in
Text(token.description)
}
3. Performance Optimization (Debouncing)
When searching a local database (CoreData/SwiftData), real-time search is fast. But if your search performs a call to a REST API, you can’t afford to make an HTTP request for every letter the user types (“H”, “He”, “Hel”, “Hello”).
We must implement “Debouncing”. Although we can use Combine, modern Swift with Concurrency allows us to do it cleanly using .task.
struct RemoteSearchView: View {
@State private var searchText = ""
@State private var results: [String] = []
var body: some View {
NavigationStack {
List(results, id: \.self) { item in
Text(item)
}
.searchable(text: $searchText)
.onChange(of: searchText) { oldValue, newValue in
// Automatically cancel previous tasks when value changes
Task {
// Wait 500ms before triggering search
try? await Task.sleep(nanoseconds: 500 * 1_000_000)
// If user kept typing, this task is cancelled
// and the next line never executes.
if !Task.isCancelled {
await performRemoteSearch(query: newValue)
}
}
}
}
}
func performRemoteSearch(query: String) async {
// Your API call here
}
}
This pattern prevents flooding your server and saves the user’s battery and data.
4. Managing State with Environment
Sometimes you need to know programmatically if the user is searching to hide UI elements (like a floating button or image carousel). SwiftUI exposes this environment variable:
@Environment(\.isSearching) private var isSearching
var body: some View {
VStack {
if !isSearching {
HeroImageBanner() // Hides when search starts
}
List { ... }
}
.searchable(text: $text)
}
Improving UX: ContentUnavailableView
With iOS 17 and Xcode 15, Apple introduced a standard way to show empty states. When a user searches for something that doesn’t exist, showing a blank screen is a bad experience.
List {
ForEach(filteredNames, id: \.self) { name in
Text(name)
}
}
.overlay {
if filteredNames.isEmpty && !searchText.isEmpty {
ContentUnavailableView.search(text: searchText)
}
}
ContentUnavailableView.search automatically generates a magnifying glass icon with the text “No results for ‘xyz'”, maintaining operating system consistency.
Conclusion
Adding a search bar in SwiftUI has gone from a tedious task in UIKit to a powerful declarative experience. The .searchable() modifier encapsulates years of Apple user interface best practices into a single line of code.
As an iOS Developer, your responsibility now focuses on data logic and offering a fluid experience (using debouncing and scopes), letting SwiftUI handle drawing the perfect pixels on iOS, macOS, and watchOS. Mastering these Swift programming tools not only makes your apps look better but makes them more accessible and user-friendly, critical factors for App Store success.
If you have any questions about this article, please contact me and I will be happy to help you . You can contact me on my X profile or on my Instagram profile.