Milan
Milan

Reputation: 95

Async search bar

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

Answers (2)

Rob
Rob

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:

  1. It already is an asynchronous context, so there is no need to introduce unstructured concurrency with Task {…} or Task.detached {…} in the .onChange closure.

  2. 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

Volkan Sonmez
Volkan Sonmez

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

Related Questions