Home > Software engineering >  Async search bar
Async search bar

Time:11-17

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.

CodePudding user response:

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")
        }
    }
}

CodePudding user response:

There are a few issues:

  1. The fuzzyMatch is marked as async, but is not currently doing anything asynchronous. Having the method signature reflect what is going on may make it easier to reason about one’s code.

  2. To that end, now that we realize that fuzzyMatch runs synchronously, it becomes apparent that if it runs slowly, it will block the current thread. And because Task { ... } runs on the current actor, you will end up blocking the main thread. You should consider using Task.detached to get it on a background thread. But mark searchResults as being on the main actor.

  3. If you cancel the Task, it will not stop fuzzyMatch that is underway. It should check for cancelation.

So, pulling that all together, perhaps:

struct ContentView: View {
    @StateObject var modelData: ModelData()

    @State var searchText = ""
    @MainActor @State var searchResults: [Item] = []
    @State var searchTask: Task<[Item], Error>?

    // let fuseSearch = Fuse()

    var body: some View {
        NavigationStack {
            ...
        }
        .searchable(text: $searchText)
        .onChange(of: searchText) { newQuery in
            Task {
                searchTask?.cancel()

                let task = Task.detached {
                    try await Task.sleep(seconds: 0.5) // debounce; if you don't want debouncing, remove this, but it can eliminate annoying updates of the UI while the user is typing
                    return try await fuzzyMatch(items: modelData.items, searchText: newQuery)
                }
                searchTask = task

                searchResults = try await task.value
            }
        }
    }

    func fuzzyMatch(items: [Item], searchText: String) throws -> [Item] {
        try items.filter {
            try Task.checkCancellation()
            return (fuseSearch.search(searchText, in: $0.name)?.score ?? 1) < 0.25
        }
    }
}

It is not terribly relevant, but the above uses these extensions:

extension Task where Success == Never, Failure == Never {
    public static func sleep(seconds: TimeInterval) async throws {
        let nanoseconds = seconds * .nanosecondsPerSecond
        try await sleep(nanoseconds: UInt64(nanoseconds))
    }
}

extension TimeInterval {
    static let nanosecondsPerSecond = Self(NSEC_PER_SEC)
}
  • Related