Searchable

Search bars are a very common pattern in iOS, and UIKit has fantastic support for them. Before iOS 15, you’d have to lean into UIKit to get the optimal search experience. With iOS 15 SwiftUI gets super-powered support for search bars thats easier than anything I’ve seen before.

How does it work?

List all-ready has a whole bunch of useful features, this is just another one on-top of that. To get started, you’ll need a list of something. For a quick refresher, here’s how you do that.

struct CoffeeListView: View {
    
    private let coffees = Coffee.all
    
    var body: some View {
        NavigationView {
            List {
                Section("Coffee") {
                    ForEach(coffees, id: \.hashValue) { coffee in
                        CoffeeItemView(coffee: coffee)
                    }
                }
            }
            .listStyle(InsetGroupedListStyle())
            .navigationTitle("Menu")
        }
    }
    
}
struct CoffeeListView: View {
    
    private let coffees = Coffee.all
    
    var body: some View {
        NavigationView {
            List {
                Section("Coffee") {
                    ForEach(coffees, id: \.hashValue) { coffee in
                        CoffeeItemView(coffee: coffee)
                    }
                }
            }
            .listStyle(InsetGroupedListStyle())
            .navigationTitle("Menu")
        }
    }
    
}

Your list will iterate over the identifiable data it gets passed and render a row for each item. You’ll have spotted some of the modifiers on the end of the List declaration - these just give us an extra level of polish. The list is nested inside a NavigationView to make sure that we can place the search bar in it later on.

To make this list searchable, we need to use just one modifier, searchable, and create a binding for the search text. Here's how that looks.

@State private var searchText: String = ""

var body: some View {
  NavigationView {
    List {
      //
    }
		.searchable(text: $searchText, placement: .navigationBarDrawer(displayMode: .always), prompt: "Coffee Name")
    .listStyle(InsetGroupedListStyle())
    .navigationTitle("Menu")
  }
}
@State private var searchText: String = ""

var body: some View {
  NavigationView {
    List {
      //
    }
		.searchable(text: $searchText, placement: .navigationBarDrawer(displayMode: .always), prompt: "Coffee Name")
    .listStyle(InsetGroupedListStyle())
    .navigationTitle("Menu")
  }
}

The searchable modifier takes the binding as an argument, alongside an optional placement. In this case, i've set the placement to always be in the navigation bar, which means it always shows. If you don't want this behavour, you can also set it to automatic, which will show when the user scrolls down a bit to expose the bar. Whilst this might sound like a hidden feature it's actually such a common pattern that users will probably expect it to be there.

There's even a sidebar option to get the most out of iPads and Macs.

If you run the code at this point, you'll have a search bar, list of content, and you can tap in the bar to start typing - but nothing will happen yet. As powerful as searchable is, it can't do everything for free. We need to handle filtering our content manually. In this instance, we have a list of coffees and we're going to let the user search by their names.

To do this, we'll add a new visibleCoffee variable, which filters out content based on the currently entered string.

private var visibleCoffees: [Coffee] {
  return coffees.filter({ $0.name.hasPrefix(searchText)})
}
private var visibleCoffees: [Coffee] {
  return coffees.filter({ $0.name.hasPrefix(searchText)})
}

To finish the job, we just have to pass this to our list, instead of our entire list of Coffee.

ForEach(visibleCoffees, id: \.hashValue) { coffee in
    CoffeeItemView(coffee: coffee)
}
ForEach(visibleCoffees, id: \.hashValue) { coffee in
    CoffeeItemView(coffee: coffee)
}

Thats it! You now have a searchable list with a very small amount of code. Theres more features available to you though, so read on to take your app even further.

How do I make it better?

When you think about all the best search experiences you've had, a few things will come to mind, of course things like speed and relevance, but also search suggestions. SwiftUI can give us these too.

There's an optional closure you can pass to the serchable modifier which will give the user a list of suggestions. The UI for this is pretty too, matching what the user would see in most of Apple's own apps.

To power this, we'll need to build a set of strings to show to the user and filter those based on the current search string. First lets add the set of strings with another computed variable.

private var coffeeNames: [String] {
  return coffees.map { $0.name }
}
private var coffeeNames: [String] {
  return coffees.map { $0.name }
}

Next, we need to pass these to thesearchable modifier and filter them out based on the search text.

.searchable(text: $searchText, placement: .navigationBarDrawer(displayMode: .always)) {
	ForEach(coffeeNames.filter { $0.hasPrefix(searchText) } , id: \.self) { suggestion in
		Text(suggestion)
			.searchCompletion(suggestion)
    }
}
.searchable(text: $searchText, placement: .navigationBarDrawer(displayMode: .always)) {
	ForEach(coffeeNames.filter { $0.hasPrefix(searchText) } , id: \.self) { suggestion in
		Text(suggestion)
			.searchCompletion(suggestion)
    }
}

This closure will power the new suggestions view SwiftUI will make for us. As we type, the list of suggestions gets smaller until we run out. In this example with a short list it might seem a little strange to do this, but with a longer list this could be very powerful.

I've added a special modifier to the text searchCompletion, which is the way this all works. When the user taps on this view, it will automatically dismiss the suggestion view and show results as if they typed it themselves.

What else can It do?

There's a couple more little tricks available to you. First, there's an environment variable that tells you if the user is searching.

@Environment(\.isSearching) var isSearching
@Environment(\.isSearching) var isSearching

For an example of how you could use this, you could have a nice overlay on your view that shows the result count.

.overlay(alignment: .bottom, content: {
  if isSearching {
    Text("\(visibleCoffees.count) results")
  }
})
.overlay(alignment: .bottom, content: {
  if isSearching {
    Text("\(visibleCoffees.count) results")
  }
})

You also get access to a special dismissSearch action that will end the search.

@Environment(\.dismissSearch) var dismissSearch
@Environment(\.dismissSearch) var dismissSearch

This works as you might expect - you can call the function to close.

Button("Cancel", action: { dismissSearch() })
Button("Cancel", action: { dismissSearch() })

There's a drawback to these, however. They only work if they're a child view of another view that is marked as searchable. If you added these variables to our current list, it wouldn't work, as thats not a child of another searchable view, its the same one.

The requirement is a structure like the below, which is provided in the Apple documentation.

struct ContentView: View {
    @State var text = ""

    var body: some View {
        SearchReadingView()
            .searchable(text: $text)
    }
}

struct SearchReadingView: View {
    @Environment(\.isSearching) var isSearching

    var body: some View {
        if isSearching {
           Text("Searching!")
        } else {
            Text("Not searching.")
    }
}
struct ContentView: View {
    @State var text = ""

    var body: some View {
        SearchReadingView()
            .searchable(text: $text)
    }
}

struct SearchReadingView: View {
    @Environment(\.isSearching) var isSearching

    var body: some View {
        if isSearching {
           Text("Searching!")
        } else {
            Text("Not searching.")
    }
}

If we were to do this, we can just do a simple refactor with a new child view, called CoffeeList that would look like this.

struct CoffeeList: View {
    @State var coffees: [Coffee]
    @Environment(\.isSearching) var isSearching
    @Environment(\.dismissSearch) var dismissSearch
    
    var body: some View {
        List {
            if isSearching {
                Button("Cancel", action: { dismissSearch() })
            }
            Section("Coffee") {
                ForEach(coffees, id: \.hashValue) { coffee in
                    CoffeeItemView(coffee: coffee)
                }
            }
        }
        .overlay(alignment: .bottom, content: {
            if isSearching {
                Text("\(coffees.count) results")
            }
        })
    }
}
struct CoffeeList: View {
    @State var coffees: [Coffee]
    @Environment(\.isSearching) var isSearching
    @Environment(\.dismissSearch) var dismissSearch
    
    var body: some View {
        List {
            if isSearching {
                Button("Cancel", action: { dismissSearch() })
            }
            Section("Coffee") {
                ForEach(coffees, id: \.hashValue) { coffee in
                    CoffeeItemView(coffee: coffee)
                }
            }
        }
        .overlay(alignment: .bottom, content: {
            if isSearching {
                Text("\(coffees.count) results")
            }
        })
    }
}

This can then be used in our original view instead of the inline List, making our modifiers work as we expect.

struct CoffeeListView: View {

    // All the existing variables
  
    var body: some View {
        NavigationView {
        CoffeeList(coffees: visibleCoffees)
            .listStyle(InsetGroupedListStyle())
            .navigationTitle("Menu")
        }
				.searchable(text: $searchText, placement: .navigationBarDrawer(displayMode: .always), prompt: "Coffee Name") {
            ForEach(coffeeNames.filter({ $0.hasPrefix(searchText) }), id: \.self) { suggestion in
                Text(suggestion)
                    .searchCompletion(suggestion)
            }
        }
    }
    
}
struct CoffeeListView: View {

    // All the existing variables
  
    var body: some View {
        NavigationView {
        CoffeeList(coffees: visibleCoffees)
            .listStyle(InsetGroupedListStyle())
            .navigationTitle("Menu")
        }
				.searchable(text: $searchText, placement: .navigationBarDrawer(displayMode: .always), prompt: "Coffee Name") {
            ForEach(coffeeNames.filter({ $0.hasPrefix(searchText) }), id: \.self) { suggestion in
                Text(suggestion)
                    .searchCompletion(suggestion)
            }
        }
    }
    
}

When you type now, you'll see a cancel button that on tap cancels the search, and a live updating results count at the bottom.


Searchable is very powerful, and I'd love to see what you can do with it. For a follow up challenge, how about instead of using the same list and filtering its content, you show an entirely custom overlay view with a different row if its a search result?

The code is available on the github repo under 003-searchable.

If you fancy reading a little more, I’m @SwiftyAlex on twitter.