Add Search Experience in SwiftUI Apps

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.

💡
The search APIs described in this blog post were introduced in iOS 15. They are only available on Xcode 13.x running app on the device with iOS version 15 or more. Before proceeding with this tutorial, please make sure to upgrade your Xcode to run iOS 15

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.

0:00
/

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

0:00
/

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.

0:00
/

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")
        }
    }
}

0:00
/
💡
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 manner

Source 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.