Attach Search Bar to your screen in SwiftUI
with searchable, SwiftData, TCA
When I developed a few years ago, I used UISearchController to give a search feature to the screen like this.
How to attach search bar to the screens in SwiftUI?
searchable
Apple introduced a new modifier, searchable since iOS 15. It is very simple and there is also an official implementation guide.
let’s see how to use it. As other modifiers, we can attach searchable
to any screens.
There are a lot kind of initializer. However, this time I will see only a basic usage.
text
The text is a string to share search keyword between screen and search bar. We can pass any bindable variable to the parameter.
@State var keyword: String = ""
...
.searchable(text: $keyword,
placement
The placement determines where to put your search bar. This means you can put the search bar into sidebar if your app is running on iPad.
prompt
The prompt is like placeholder of TextField
.
.searchable(...,
prompt: "Input name or skill name")
Now the search bar is attached as navigation bar!
onSubmit
But how can I trigger searching? We can use onSubmit to detect pressing the search
button on the keyboard.
Usage is so simple
.onSubmit(of: .search) {
print("keyword", keyword)
}
There is one more type of trigger, but it doesn’t work for the search bar.
onChange
Instead we can observe the keyword changes with onChange
.
.onChange(of: keyword) { _, newValue in
print("type", keyword)
}
@Query
I wanted to filter items with search keyword. But my items queried by SwiftData. How to filter them?
@Query private var characters: [QBCharacter]
Query Macro has filter
parameter. This is Predicate we used for Core Data.
I expected to a template created by double clicking the parameter block.
But it was not working :(
@Query(filter: T##Predicate<Element>?,
So I had to write code following Hacking with Swift.
@Query(filter: #Predicate<QBCharacter> { character in
character.name.contains(keyword)
},
Predicate
However this approach also doesn’t work. I guess the reason is inaccessible to members from Macro.
There are two solutions. One is regenerate Query in the constructor every time.
init(...){
_characters = Query(filter: #Predicate<QBCharacter> { character in
character.name.contains(keyword)
},
}
Another way is to give up using Query and to fetch data manually.
var query = FetchDescriptor<QBCharacter>()
query.predicate = #Predicate{ item in
item.name.contains(keyword)
}
query.sortBy = [.init(\.id)]
self.characters = (try? modelContext.fetch(query)) ?? []
I extracted the fetching code to method and ran it on keyword changes.
.onChange(of: keyword) { _, newValue in
print("type", keyword)
searchCharacters(with: keyword)
}
TCA
I am building an new app using TCA, therefore defining rows in the view is strange. So I decided to add search result into the state of the store.
@Reducer
struct CharacterSearchStore {
@ObservableState
struct State: Equatable {
var characters: [QBCharacter] = []
enum Action {
case search(keyword: String)
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .search(let keyword)
return .run { @MainActor send in
characters = database?.mainContext.fetch ...
}
I don’t like such a long code like FetchDescriptor. So I made extension to generator descriptor easy.
extension FetchDescriptor {
static func queryCharacter<Model>(where: (Model) -> Bool,
by: [SortDescriptor<Model>] = []) -> FetchDescriptor<Model> {
return FetchDescriptor<Model>(predicate: #Predicate{
`where`($0)
}, sortBy: by)
}
}
where
is keyword so if we use the name as parameter, we will encounter this error.
We can add alternative parameter
or wrap it with ``.
Now I can call fetch like follow.
database?.mainContext.fetch(.queryCharacter(where: { (row: QBCharacter) in
row.name.contains(keyword)
}, by: [.init(\.id)])) as? [QBCharacter] ?? []
However I can’t mutate state in run block.
case .search(let keyword):
return .run { @MainActor send in
state.characters = try! database?.mainContext.fetch(.queryCharacter(where: { (row: QBCharacter) in
row.name.contains(keyword)
}, by: [.init(\.id)])) as? [QBCharacter] ?? []
}
I solved issue for insert operation with run block before, but it doesn’t work for fetching.
return .run { @MainActor send in
database?.mainContext.insert(Item(timestamp: selectedDate))
}
To escape using ModelContainer directly,
private enum DatabaseDependencyKey: DependencyKey {
static var liveValue: ModelContainer? = nil
}
I created DatabaseProvider
containing ModelContainer
.
@MainActor
class DatabaseProvider {
var container: ModelContainer
var context: ModelContext {
container.mainContext
}
init(container: ModelContainer) {
self.container = container
}
func fetch<Model>(where query: (Model) -> Bool,
by: [SortDescriptor<Model>] = []) throws -> [Model]? where Model : PersistentModel{
try context.fetch(.queryCharacter(where: query, by: by)) as? [Model]
}
}
And the run block is now more simple. I stored filtered items into local variable and passed to update action to update state with search result.
case .search(let keyword):
return .run { @MainActor send in
let characters = try! database?.fetch(where: { (model: QBCharacter) -> Bool in
model.name.contains(keyword)
}) ?? []
send(.updateCharacters(characters))
}
This is an update action.
case .updateCharacters(let characters):
state.characters = characters
return .none
Predicate Limitation
After all of task, I ran preview, but the compile had been failed. Because predicate don’t allow global function. This means it makes predicate string in compile time. So I had to do change closure to just keyword string.
extension FetchDescriptor {
static func queryCharacter(where keyword: String,
by: [SortDescriptor<QBCharacter>] = []) -> FetchDescriptor<QBCharacter> {
return FetchDescriptor<QBCharacter>(predicate: #Predicate{ model in
model.name.contains("a")
}, sortBy: by)
}
}
Empty Keyword
If there is no search keyword, we should show all items to user, so I modified predicate like this.
keyword.isEmpty || model.name.contains(keyword)
Preview Crash
As you’ve seen I moved modelContainer into TCA dependencies.
However this caused crash to the preview for the pure view not using model. I guess it might use App for #preview.
I tried to make convenience way, but this is not allowed.
Even if
block doesn’t work. SceneBuilder doesn’t allow conditional flows.
I had to generate model container for preview.
var sharedModelContainer: ModelContainer = ModelContainer.generate(forPreview: true)
...
.modelContainer(sharedModelContainer)
Initially I attempted this way also, but it didn’t work.
.modelContainer(ModelContainer.generate(forPreview: true))
If you found any help in this post, please give it a round of applause 👏. Explore more iOS-related content in my other posts.
For additional insights and updates, check out my LinkedIn profile. Thank you for your support!
Troubleshootings
Cannot convert value of type ‘()’ to closure result type ‘Bool’
`where`
Mutable capture of ‘inout’ parameter ‘state’ is not allowed in concurrently-executing code
Create a new action to update state and run it in the run block with send.
return .run { @MainActor send in
let characters = ...
send(.updateCharacters(characters))
}
Global functions are not supported in this predicate
#Predicate will generate predicate string like “name = xxx”. So it cannot recognize closures defined out of Predicate block.
Fatal Error in ModelContainer.swift
let modelConfiguration = ModelConfiguration(schema: schema,
isStoredInMemoryOnly: forPreview)
...
WindowGroup{ ... }
.modelContainer(...)
References
https://www.hackingwithswift.com/books/ios-swiftui/filtering-query-using-predicate