Add Search Experience in SwiftUI Apps
Search is one of the most fundamental operations in iOS apps. For any app with a long list view, search offers a quick way to find the item matching the given keyword. Whether you want to search for a movie title, a book, a song, a favorite person, or even a restaurant on food-delivery apps, the search experience provides valuable functionality for end users.
Without search functionality, app users will end up endlessly scrolling the long list of items only at the mercy of luckily finding the item they are looking for. Fortunately, Apple has taken note of these things and they have introduced major SwiftUI search API improvements in iOS 15.
In today's post, we are going to take a look at new ways to create a search experience on iOS using these new APIS.
Search Component
A search component is any view that allows users to type search characters and optionally provides a button to trigger the search. This is a basic design of any search view. In iOS 15, Apple introduced new APIs to make it easier to add a search feature to the app.
As an extension, the search component is accompanied by the list view that lists all the items matching the current search keyword or default list if now search is triggered.
Meet Searchable Modifier
Searchable modifier acts as a foundation for search experience in SwiftUI. It can be applied to any view and is responsible for marking that view as containing the searchable content. In order to use it, we define the state variable and pass it as a binding to this modifier. As the search is ready and the user is typing, this variable gets updated with the current search query and we can filter the list by this keyword.
Searchable content is added into NavigationView
which is responsible for adding a search bar to the searchable content. We will add it to search the list of places we have in the app.
struct Searchable: View {
let places = [
Place(id: "1", name: "Mumbai", imageName: "car"),
Place(id: "2", name: "Vienna", imageName: "airplane"),
Place(id: "3", name: "Amsterdam", imageName: "camera"),
Place(id: "4", name: "Prague", imageName: "alarm"),
Place(id: "5", name: "Delhi", imageName: "bag")
]
@State var searchText: String = ""
var body: some View {
NavigationView {
List {
Text("Search Text is \(searchText)")
ForEach(searchResults) { searchResult in
HStack {
Image(systemName: searchResult.imageName)
Text(searchResult.name)
}
}
}
.searchable(text: $searchText)
.navigationTitle("Search Experience")
}
}
var searchResults: [Place] {
if searchText.isEmpty {
return places
} else {
return places.filter { $0.name.contains(searchText) }
}
}
}
In the example above, we have a list of places we want to filter by search text. We have added searchable
modifier to the list view to mark its content as searchable.
We also have a local state variable searchText
to store the current search query. Since it has a binding with searchable
modifier, as the user is typing, its value is updated on the fly. As long as searchText
is bound to the searchable
modifier, we can access its updated value from anywhere in the current view.
Next, as the user is typing a non-empty string in the search box, we use it to compute the value of the computed variable searchResults
and they are shown on the view. When the search field has a non-empty search string, it will show the places matching with the search query.
If the search field is empty or the user taps on the cancel button, the app shows the original list of places without any filter.
Trickling Searching State to Child Views
In the example above, we could automatically detect whether the search operation is on or not. However, when we drill down to child views, it's not obvious to them whether their parent is undergoing any search. Knowing this information is useful when children have to change the state based on the search state.
SwiftUI makes it easier for children to detect whether the search is active or not using isSearching
environment variable.
Consider an example where we are wrapping SearchChildView
inside parent view Searchable
. The Searchable
view knows if the search is active or not from the value of the searchText
, but SearchChildView
has no way of knowing if the active search is going on.
We will use isSearching
environment variable on SearchChildView
to detect the search state and also have the parent view pass the binding variable searchText
so that child knows the current search query.
SearchChildView
will use these values to show and hide overlay view with the list of previous searches - Probably stored in the local cache or retrieved from the server.
Searchable.swift
import SwiftUI
struct Searchable: View {
@State var searchText: String = ""
var body: some View {
NavigationView {
SearchChildView(searchText: $searchText)
.searchable(text: $searchText)
.navigationTitle("Search Experience")
}
}
}
SearchChildView.swift
struct SearchChildView: View {
@Binding var searchText: String
private let recentSearches: [String] = ["Boston", "Utah", "Bangalore", "Chennai"]
@Environment(\.isSearching)
private var isSearching: Bool
let places = [
Place(id: "1", name: "Mumbai", imageName: "car"),
Place(id: "2", name: "Vienna", imageName: "airplane"),
Place(id: "3", name: "Amsterdam", imageName: "camera"),
Place(id: "4", name: "Prague", imageName: "alarm"),
Place(id: "5", name: "Delhi", imageName: "bag")
]
var body: some View {
List {
Text("Search Text is \(searchText)")
ForEach(searchResults) { searchResult in
HStack {
Image(systemName: searchResult.imageName)
Text(searchResult.name)
}
}
}.overlay(content: {
if isSearching && searchText.isEmpty {
List {
Text("Recent Searches")
.fontWeight(.heavy)
ForEach(recentSearches, id: \.self) { recentSearch in
Text(recentSearch)
}
}
}
})
}
var searchResults: [Place] {
if searchText.isEmpty {
return places
} else {
return places.filter { $0.name.contains(searchText) }
}
}
}
Demo
Search Suggestions
There is one more way we can refine the search experience. That is, by adding search suggestions - This can include a potential list of items users might be interested in or trending search items on the platform. These suggestions appear in the list as soon the search field is active.
As the user selects one of the suggestions from the list, we will replace the text in the search field and trigger the search.
Suggestions can be provided by wrapping them in the closure provided by the searchable
view modifier.
....
...
private let suggestions = ["Milan", "Rome", "Paris", "Iceland", "Greenland", "Florida"]
...
SearchChildView(searchText: $searchText)
.searchable(text: $searchText) {
ForEach(suggestions, id: \.self) { suggestion in
HStack {
Image(systemName: "circle.fill")
Text(suggestion)
}
.searchCompletion(suggestion)
}
}
We use searchCompletion
modifier to signal the selection of suggestions from the list. As soon as it's selected, it replaces the current text in the search field and you can trigger the search.
Manually Triggering the Search
If you have an app where you want users to type the query and manually trigger the keyword search, you can use the new onSubmit
modifier to know when to fetch the search results. As soon as the user selects one of the suggestions or taps thesearch
button on the keyboard, the action inside this modifier is triggered.
import SwiftUI
struct Searchable: View {
@State var searchText: String = ""
private let suggestions = ["Milan", "Rome", "Paris", "Iceland", "Greenland", "Florida"]
//------------------------------------------------------------------------------------------------
@State private var showingAlert = false
//------------------------------------------------------------------------------------------------
var body: some View {
NavigationView {
SearchChildView(searchText: $searchText)
.searchable(text: $searchText) {
ForEach(suggestions, id: \.self) { suggestion in
HStack {
Image(systemName: "circle.fill")
Text(suggestion)
}
.searchCompletion(suggestion)
}
}
//--------------------------------------------------------------------------------------------
.onSubmit(of: .search, {
showingAlert = true
}).alert("Submitted search for \(searchText)", isPresented: $showingAlert) {
Button("OK", role: .cancel) {
// no-op
}
}
//---------------------------------------------------------------------------------------------
.navigationTitle("Search Experience")
}
}
}
OnSubmit
modifier is not just limited to search
fields, but can also be applied to non-searchable fields such as regular or secure text fields. OnSubmit
, along with Searchable
modifier offers an easy way to trigger the search for a selected suggestion or a typed keyword in the time-saving mannerSource Code
The full source code associated with this post is available on Github in the Gist. Please let me know if you have any suggestion or comments on it.
Summary
So this was all about creating user-friendly search experience in iOS 15 using new APIs. As user is surrounded with lot of information, search is an effective tool that lets them choose things they are interested in. It's great that Apple has provided brand new APIs that let developers create a refined search experience without too much manual intervention. Do you like these new APIs and search experience? Do you have any suggestion or thoughts on this new approach or your own tips to improve it? Reach out to me at @jayeshkawli with your feedback and comments.