MacKuba

🍎 Kuba Suder's blog on Mac & iOS development

WWDC 21

Craft search experiences in SwiftUI

Categories: SwiftUI 0 comments Watch the video

This year, SwiftUI adds a .searchable view modifier which adds a search UI appropriate for the current platform

Here's how it's used in the new Weather app written in SwiftUI:

NavigationView {
    WeatherList(text: $text) {
        ForEach(data) { item in
            WeatherCell(item)
        }
    }
}
.searchable(text: $text)

What happens behind the scenes is that the .searchable modifier puts the search field configuration into the Environment, and the views within the hierarchy look for the search field configuration there and apply it in the best way for the given platform

In this case, the NavigationView knows how to display the search field and will show it e.g. at the top of one of its panes on iOS, or on the right side of the window toolbar on macOS

If no view knows how to display the search field, a default rendering is used which puts it in the toolbar

A search field isn't very useful without another piece of UI that displays the search results, which is something that you need to implement yourself

The Weather app does it this way:

struct WeatherList: View {
    @Binding var text: String

    @Environment(\.isSearching) private var isSearching: Bool

    var body: some View {
        WeatherCitiesList()
        .overlay {
            if isSearching && !text.isEmpty {
                WeatherSearchResults()
            }
        }
    }
}
  • the .searchable modifier puts an \.isSearching key in the Environment, which you can check to see if a search field is focused somewhere on the screen, and show a different UI with search results if it is
  • the view checks the text property (which you need to pass yourself) and if it's not empty, displays a list of matching locations
  • the results list is displayed as an overlay on top of the standard UI – this is done so that when the user leaves the search field and the results list is hidden, the main UI below remains unchanged (unless they user has picked something from the results)

The second example is the "Colors" app:

NavigationView {
    Sidebar()
    DetailView()
}
.searchable(text: $text)

Here's how we implement search here:

  • when you apply .searchable to a two-pane NavigationView, it will add the search field to its first pane (the master view); if you want it to be added to the detail view, add the modifier to the detail view directly
  • on iOS and iPad OS, the Sidebar view uses the \.isSearching Environment key to display search results as an overlay over the list sidebar
  • on macOS, NavigationView puts the search field in the right side of the sidebar, automatically collapsing it into a button if the window is too small
  • on the Mac we display the search results instead in the main window pane, on top of the DetailView
  • on tvOS, we use a slightly different layout structure, adding a tab bar with one tab used for the standard navigation and the other for search, and putting the search UI in the second tab above a results list:
NavigationView {
    TabView {
        Sidebar()
        ColorsSearch()
            .searchable(text: $text)
    }
}

In most cases, the .searchable modifier applied to NavigationView will automatically render the search field in the appropriate place of the hierarchy

However, you always need to decide how and where to display the search results in a way appropriate for your app (which may vary between platforms)

In some cases you may want to use a different layout between platforms, as we do here in case of tvOS

Search suggestions

You can provide search suggestions for the search UI, which are examples of search queries that may be autocompleted into the field when the user picks them; suggestions give the user an idea of the types of things they can search for

Search suggestions also render in a platform-appropriate way:

  • as a menu popover below the field on macOS
  • as a complete search results screen on iOS
  • as a button that opens the list on watchOS

Suggestions are provided as an optional view builder closure that returns a set of buttons:

.searchable(text: $text) {
    ForEach(suggestions) { suggestion in
        Button {
            text = suggestion.text
        } label: {
            ColorsSuggestionLabel(suggestion)
        }
    }
}

There is also a shorthand version available, using the .searchCompletion modifier

The modifier should be applied to a non-interactive element like a Label, and it transforms it into a button which:

  • updates the search text, like the button above
  • dismisses the suggestions view
.searchable(text: $text) {
    ForEach(suggestions) { suggestion in
        ColorsSuggestionLabel(suggestion)
            .searchCompletion(suggestion.text)
    }
}

If you want to run a search query only when the user submits the search field, not live while they're typing (e.g. because it requires a request to a server), you can use the new .onSubmit modifier:

.searchable(text: $text) {
    ForEach ...
}
.onSubmit(of: .search) {
    fetchResults()
}