Listen to values of a Search Bar over time with Swift Concurrency and Observation Framework iOS 17

341 views Asked by At

I have a view model that holds the searchText variable that is used in a view as a Binding for a textField.

@Observable
final class SearchableViewModel {
    var searchText: String = ""
    
    func listenToSearchQuery() async {
        for await searchQuery in searchText.async {
            print(searchQuery)
        }
    }
}

struct SearchableView: View {
    @State private var vm = SearchableViewModel()

    var body: some View {
        NavigationStack {
            ScrollView {
                VStack(spacing: 20) {
                    Text("text")
                    Text("text")
                }
                .padding()
            }
            .searchable(text: $vm.searchText, prompt: Text("Search text..."))
            .navigationTitle("Title")
            .task {
                await vm.listenToSearchQuery()
            }
        }
    }
}

I know that prior iOS 17 we had @Published and I could take the values from that String and use swift concurrency to listen to changes. Like this:

final class SearchableViewModel: ObservableObject {
    @Published var searchText: String = ""
    
    func listenToSearchQuery() async {
        for await searchQuery in $searchText.values {
            print(searchQuery)
        }
    }
}


struct SearchableView: View {
    @StateObject private var vm = SearchableViewModel()

    var body: some View {
        NavigationStack {
            ScrollView {
                VStack(spacing: 20) {
                    Text("text")
                    Text("text")
                }
                .padding()
            }
            .searchable(text: $vm.searchText, prompt: Text("Search text..."))
            .navigationTitle("Title")
            .task {
                await vm.listenToSearchQuery()
            }
        }
    }
}

Even with this solution when I change one letter in the search query I get 3 printed lines and I do not know why but at least it works observing changes compared to Observation framework.

How can I do that in iOS 17?

1

There are 1 answers

4
Rob On BEST ANSWER

A few observations:

  • The async method “converts a non-asynchronous sequence into an asynchronous one.” A String is a sequence of Character elements. Thus, if a String contained the text “test”, the async function would return an asynchronous sequence of the characters “t”, “e”, “s”, and “t”. It is not for observing changes in a String over time, but rather for asynchronously iterating through its Element values (i.e., Character), which is not what we wanted here.

  • Instead, to observing changes of values over time with Observation framework, there are two basic approaches that leap to mind:

    • We might use withObservationTracking from within the view model. See SE-0395 – Observation for discussion/example re withObservationTracking.

      Warning: There are some weird idiosyncracies with this API. This does not really “track” anything. It only observes the first change. You have to call it recursively to observe all changes.

      Also, it has a very non-conventional way of identifying what property is to be observed, so please refer to SE-0395’s discussion carefully. E.g., you must simply refer to the property in the first closure (e.g., they literally suggest you just print the property). I hope they adopt a more idiomatic pattern in the future rather than relying on some side-effect of a print statement. Lol.

    • Or, we could use the traditional onChange view modifier:

      var body: some View {
          NavigationStack {
              …
              .searchable(text: $viewModel.searchText, prompt: Text("Search text..."))
              .onChange(of: viewModel.searchText) { _, _ in
                  viewModel.search()
              }
          }
      
  • Regarding the duplicate searches, I would advise deduplicating it programmatically, e.g.:

    • create a property for the “previous search string”;
    • create a local variable containing the new/current search string, and trim whitespace from it;
    • compare this local variable against the previous search string;
    • if different, do the search and save this local search string as the “previous search string”.

    Even when dealing with asynchronous sequences or Combine, we would do some sanitization like this, to avoid redundant searches. We would also frequently debounce to avoid too many soon-to-be-redundant searches, and you might want to do something like that here, too.