Introduced in iOS 16 and macOS 14, SwiftUI Search Tokens are a powerful new feature that allows developers to enhance search functionality in their apps. Search Tokens are a more advanced version of the Search Bar, allowing users to create complex search queries using tokens that represent specific search terms or categories. By using Search Tokens in your SwiftUI app, you can provide a more intuitive and customizable search experience, allowing users to easily refine their search queries and find the content they need. In this blog post, we’ll explore how to use SwiftUI Search Tokens to create advanced search functionality in your app.
This blog is an addition to my previous post about SwiftUI Search Bar: Best Practices and Examples. Please have a look there if you need an overview of the SwiftUI search.
Search Tokens
Let’s say I have a food delivery app and I want to allow the user of my app to search for food that is on the go or on sale. My data has a tag property that I can use to filter.
struct Meal: Codable, Hashable, Identifiable {
let imageURL: String
let id: String
let category: MealCategory
let name: String
let location: String
let rating: Double
var tags: [String]
}
But how do I allow my users to pick these options? You might want to use a picker, but tokens are a much smoother user experience.
There are 2 different kinds of implementation for tokens:
searchable(text: Binding<String>, tokens: Binding<C>, suggestedTokens: Binding<C>, @ViewBuilder token: @escaping (C.Element)
searchable(text: Binding<String>, tokens: Binding<C>, @ViewBuilder token: @escaping (C.Element) -> T)
In the first, you give a fixed array of suggested tokens. SwiftUI displays these suggestions like in the example above. When the user selects a suggestion, it is removed from the suggestions array and added to the selected token property.
The second implementation will not show suggestions. A use case would be to dynamically add tokens depending on the user input. For example, if you have a list of common search tokens. As soon as the user enters one of these, you add the token. Or when the user types a comma, you can use the search text as a new token.
Let’s look at an example for the first case. We need to create a new type for tokens because SwiftUI expects an Identifiable type:
enum MealSearchToken: String, Hashable, CaseIterable, Identifiable {
case fourStarReview = "4+ star review"
case onSale = "On sale"
case toGo = "To go"
case coupon = "coupon"
var id: String { rawValue }
func icon() -> String {
switch self {
case .fourStarReview:
return "star"
case .onSale:
return "paperplane"
case .toGo:
return "figure.walk"
case .coupon:
return "tag"
}
}
}
I am using here the tokens that correspond to the values of the Meal types tags property. Now, you need to create state property for the selected and suggested tokens. The filtered meal array needs to now also include the selected tokens:
class MealListViewModel: ObservableObject {
@Published var meals = [Meal]()
@Published var searchText: String = ""
@Published var selectedTokens = [MealSearchToken]()
@Published var suggestedTokens = MealSearchToken.allCases
var filteredMeals: [Meal] {
var meals = self.meals
if searchText.count > 0 {
meals = meals.filter { $0.name.localizedCaseInsensitiveContains(searchText) }
}
for token in selectedTokens {
switch token {
case .fourStarReview:
meals = meals.filter({ $0.rating >= 4 })
case .onSale, .coupon, .toGo:
meals = meals.filter({ $0.containsTag(token.rawValue)})
}
}
return meals
}
...
}
I can now update my searchable modifier to include these tokens:
.searchable(text: $viewModel.searchText,
tokens: $viewModel.selectedTokens,
suggestedTokens: $viewModel.suggestedTokens,
token: { token in
Text(token.rawValue)
})
In the closure, I will get the token and decide what view to show. For example, if you like to add a colorful background and an icon, you can have a look at this example:
import SwiftUI
struct ContentView: View {
@StateObject var viewModel = MealTokenListViewModel()
var body: some View {
NavigationStack {
List {
ForEach(viewModel.filteredMeals) { meal in
MealCardView(meal: meal)
}
.listRowSeparator(.hidden, edges: .all)
}
.listStyle(.plain)
.navigationTitle("Find Your Next Meal")
.searchable(text: $viewModel.searchText,
tokens: $viewModel.selectedTokens,
suggestedTokens: $viewModel.suggestedTokens,
token: { token in
Label(token.rawValue, systemImage: token.icon())
.encapsulate(color: Color.cyan,
foregroundColor: .white)
})
}
}
}
Showing different views for suggested and selected tokens
The provided view is used in both the suggestion list and as a token in the text field. There is a way to add 2 different views. For my example, I want a shorter token in the text field otherwise it does not fit more than 2 tokens. First, I don´t use the suggestedTokens parameter and use a shorter view for the token. This will be used inside the search text field:
.searchable(text: $viewModel.searchText,
tokens: $viewModel.selectedTokens, token: { token in
Label(" ", systemImage: token.icon())
})
Then I use the searchSuggestions modifier to add the tokens in the suggestions area:
struct ContentView: View {
@StateObject var viewModel = MealTokenListViewModel()
@State var selectedMeal: Meal? = nil
var body: some View {
NavigationStack {
List {
ForEach(viewModel.filteredMeals) { meal in
MealCardView(meal: meal)
}
.listRowSeparator(.hidden, edges: .all)
}
.listStyle(.plain)
.navigationTitle("Find Your Next Meal")
.searchable(text: $viewModel.searchText,
tokens: $viewModel.selectedTokens, token: { token in
Label(" ", systemImage: token.icon())
})
.searchSuggestions({
ForEach(viewModel.suggestedTokens) { token in
Button {
viewModel.selectedTokens.append(token)
} label: {
Label(token.rawValue, systemImage: token.icon())
}
}
})
}
}
}
It does not work well with icons only. But you could for example also show a shorter text, that describes the search token well.
Dynamically Add Search Tokens
I want to show you now a more advanced implementation. You can analyze the search text during typing and add new tokens dynamically. For example, if you have a list of common search tokens. As soon as the user enters one of these and presses enter or comma, you add the token.
I will handle the token updates in the view model. The following code does not show token suggestions:
NavigationStack {
List {
...
}
.searchable(text: $viewModel.searchText,
tokens: $viewModel.selectedTokens, token: { token in
Text(token.name)
})
}
As I said previously, the token data needs to be Identifiable. I am declaring a simple type, that holds the String values and add a test data array that I will use as common tokens:
struct StringToken: Identifiable {
let name: String
let id = UUID()
static func testData() -> [StringToken] {
["Muffin", "Noodles", "Beef", "Wraps", "Hamburger", "Chicken",
"Falafel", "Pita", "Avocado", "Tomato",
"Chocolate", "Strawberry", "Coffee", "Cheese"].map {
StringToken(name: $0)
}
}
}
Now to the hard part of updating the view model. First I add properties for StringToken and the common tokens which in a real app would come from the server.
class MealListViewModel: ObservableObject {
@Published var meals = [Meal]()
@Published var searchText: String = ""
@Published var selectedTokens = [StringToken]()
@Published private var commonTokens: [StringToken] = StringToken.testData()
var filteredMeals: [Meal] {
var meals = self.meals
if searchText.count > 0 {
meals = meals.filter { $0.name.localizedCaseInsensitiveContains(searchText) }
}
if selectedTokens.count > 0 {
let tokens = selectedTokens.map { $0.name }
meals = meals.filter { $0.name.containsAll(tokens) }
}
return meals
}
...
}
In order to create new tokens dynamically, I need to check when the search text changes. Time to use the Combine framework.
import SwiftUI
import Combine
class MealListViewModel: ObservableObject {
...
var subscriptions = Set<AnyCancellable>()
init() {
meals = service.loadMealsFromDataStorage()
$searchText.filter({
$0.last == " "
})
.filter({
$0.count > 1
})
.sink { [unowned self] text in
var filteredText = text
for suggestion in commonTokens {
if let range = filteredText.lowercased().range(of: suggestion.name.lowercased()) {
filteredText.removeSubrange(range)
filteredText.removeLast() // remove " "
DispatchQueue.main.async {
self.searchText = filteredText
}
self.selectedTokens.append(suggestion)
}
}
}
.store(in: &subscriptions)
}
}
First, I check if the user’s last input was an ” ” with a filter. Then I analyze the search text in the sink. I loop over all common tokens and check if any of these is contained in the user input. If it is, I remove the token from the search text. I create a new token and add it to the selected tokens.
In order to update the search text state property, I need to use DispatchQueue.main.async, otherwise the SwiftUI view will not update correctly. This seems to be an odd bug.
A similar data stream can be implemented if you want to create new tokens when the user types a comma “,”. In this case, I simply take the whole search term and create a new token from it.
class MealListViewModel: ObservableObject {
...
init() {
...
$searchText.filter({
$0.last == ","
})
.filter({
$0.count > 1
})
.sink { text in
var filteredText = text
filteredText.removeLast() // remove "," at the end
self.selectedTokens.append(StringToken(name: filteredText))
DispatchQueue.main.async {
self.searchText = ""
}
}
.store(in: &subscriptions)
}
}
Other Filter Options
If you want to support lower iOS and macOS versions, you can show filtering options with SwiftUI Picker. You can see different implementations in this post: “Master SwiftUI Picker View”. For example, you could add a picker or menu view in the toolbar. The picker options would be similar to the tokens.
Summary
By using SwiftUI Search Tokens, you can create a more advanced and customizable search experience in your app. You can add tokens to the search bar by specifying a list of token strings, and show them in the suggested area to help guide users in their search. Additionally, you can dynamically add new tokens by analyzing the search text as the user types, allowing for more personalized and accurate search results. With these features, SwiftUI Search Tokens provide a powerful tool for enhancing the search functionality of your app.
Further Reading:
- Use Search with Core Data: SwiftUI and Core Data Course
- How to work with the Search bar in SwiftUI
- Search Scope for advanced search queries – a must for macOS apps
- Human interface guidelines for search