Reputation: 95
I need to filter my model data with a search bar. I added the .searchable()
property and when the search text changes I filter my objects with fuzzy matching. This takes too much time and the app lags when writing into the search box. So I want to do the searching asynchronously so that the app doesn't freeze.
I tried to do it with the onChange(of:)
property and then I create a Task
that runs the async function because the onChange()
property doesn't allow async functions by themselves. But the app still lags.
Here is a code example of how I tried doing it:
import SwiftUI
import Fuse
struct SearchView: View {
@EnvironmentObject var modelData: ModelData
@State var searchText = ""
@State var searchResults: [Item] = []
@State var searchTask: Task<(), Never>? = nil
let fuseSearch = Fuse()
var body: some View {
// Show search results
}
.searchable(text: $searchText)
.onChange(of: searchText) { newQuery in
// Cancel if still searching
searchTask?.cancel()
searchTask = Task {
searchResults = await fuzzyMatch(items: modelData.items, searchText: newQuery)
}
}
func fuzzyMatch(items: [Item], searchText: String) async -> [Item] {
filteredItems = items.filter {
(fuseSearch.search(searchText, in: $0.name)?.score ?? 1) < 0.25
}
return filteredItems
}
}
I would really appreciate some help.
Upvotes: 6
Views: 2288
Reputation: 438112
If you want to introduce debouncing, you can just add a Task.sleep
:
.onChange(of: searchText) { newQuery in
// Cancel if still searching
searchTask?.cancel()
searchTask = Task {
try await Task.sleep(for: .seconds(0.5))
searchResults = await fuzzyMatch(items: modelData.items, searchText: newQuery)
}
}
Where:
@State var searchTask: Task<Void, Error>?
Even easier, rather than .onChange
modifier, I might advise the .task
view modifier. That has a few benefits over .onChange
, namely:
It already is an asynchronous context, so there is no need to introduce unstructured concurrency with Task {…}
or Task.detached {…}
in the .onChange
closure.
It automatically will cancel the prior search before starting another.
The net effect is that you no longer need the searchTask
property and can enjoy debouncing for free.
Thus, perhaps:
struct ContentView: View {
@State var searchText = ""
@State var searchResults: [Item] = []
var body: some View {
NavigationView {
…
}
.searchable(text: $searchText)
.task(id: searchText) {
try? await Task.sleep(for: .seconds(0.5))
if Task.isCancelled { return }
searchResults = await …
}
}
}
However, debouncing might not be the whole issue. If your fuzzy filtering is too slow (which one might infer from the use of async
qualifier in a function that has no await
inside it), you might need to make it run asynchronously and get it off the main thread. Simply marking the fuzzyMatch
as async
may not always be sufficient. You might want to put the search on its own actor. Or simpler, make it a nonisolated
method of Fuse
:
struct ContentView: View {
@EnvironmentObject var modelData: ModelData
@State var searchText = ""
@State var searchResults: [Item] = []
let fuse = Fuse()
var body: some View {
NavigationView {
List(searchText.isEmpty ? modelData.items : searchResults) { item in
Text(item.name)
}
}
.searchable(text: $searchText)
.task(id: searchText) {
try? await Task.sleep(for: .seconds(0.5))
if Task.isCancelled { return }
searchResults = await fuse.fuzzyMatch(items: modelData.items, searchText: searchText)
}
}
}
extension Fuse {
nonisolated func fuzzyMatch(items: [Item], searchText: String) async -> [Item] {
items.filter {
(search(searchText, in: $0.name)?.score ?? 1) < 0.25
}
}
}
And if the search can be slow, we might take it a step further and have the fuzzyMatch
method support cancelation by periodically try Task.checkCancellation
, itself. Thus, perhaps:
struct ContentView: View {
@EnvironmentObject var modelData: ModelData
@State var searchText = ""
@State var searchResults: [Item] = []
let fuse = Fuse()
var body: some View {
NavigationView {
List(searchText.isEmpty ? modelData.items : searchResults) { item in
Text(item.name)
}
}
.searchable(text: $searchText)
.task(id: searchText) {
do {
try await Task.sleep(for: .seconds(0.5))
searchResults = try await fuse.fuzzyMatch(items: modelData.items, searchText: searchText)
} catch is CancellationError {
// this is intentionally blank; no error handling needed if canceled
} catch {
print(error)
}
}
}
}
extension Fuse {
nonisolated func fuzzyMatch(items: [Item], searchText: String) async throws -> [Item] {
var matches: [Item] = []
for item in items {
try Task.checkCancellation()
if (search(searchText, in: item.name)?.score ?? 1) < 0.25 {
matches.append(item)
}
}
return matches
}
}
That having been said, there are also debouncing methods in both Swift Async Algorithms (for debouncing asynchronous sequences) and Combine (for debouncing publishers). But I think the .task
view modifier is the simpler solution for this particular use-case.
Upvotes: 5
Reputation: 785
I think the main problem is debouncing as lorem ipsum mentioned before. I just tested my code and you need to call your filter method where i printed.
In this way you will not filter for every editing textfield. You will filter after some millisecond which you may change.
You can find more detail in this link SwiftUI Combine Debounce TextField
struct Example: View {
@State var searchText = ""
let searchTextPublisher = PassthroughSubject<String, Never>()
var body: some View {
NavigationView {
Text("Test")
}
.searchable(text: $searchText)
.onChange(of: searchText) { searchText in
searchTextPublisher.send(searchText)
}
.onReceive(
searchTextPublisher
.debounce(for: .milliseconds(500), scheduler: DispatchQueue.main)
) { debouncedSearchText in
print("call your filter method")
}
}
}
Upvotes: 5