Viewing changes to @Observable values with AsyncSequence?

I've been playing around this afternoon to try and find a good way to handle viewing mutations to a property of an @Observable object using AsyncSequence.
Previously with ObservableObject, you could connect to the Publisher for that property and handle any changes there. However now that the @Observable macro no longer uses Combine under the hood, I wanted to use concurrency tools to achieve a similar outcome.

An example would be wanting to handle text that a user types into a SwiftUI TextField.

struct SearchView: View {
  @Bindable var viewModel: ViewModel
  
  var body: some View {
    TextField("Search", text: $viewModel.query)
      .task {
        await viewModel.observeChanges()
      }
  }
}

@Observable
class ViewModel {
  var query = ""
  
  private var continuation: AsyncStream<String>.Continuation?
  
  @ObservationIgnored private lazy var stream: AsyncStream<String> = {
    AsyncStream<String> { [weak self] continuation in
      self?.continuation = continuation
    }
  }()
  
  init() {
    startObservationTracking()
  }
  
  deinit {
    continuation?.finish()
  }
  
  func startObservationTracking() {
    withObservationTracking {
      _ = query
    } onChange: { [weak self] in
      guard let self else { return }
      continuation?.yield(query)
      startObservationTracking()
    }
  }
  
  func observeChanges() async {
    for await query in self {
      print(query)
    }
  }
  
}

extension ViewModel: AsyncSequence {
  typealias AsyncIterator = AsyncStream<String>.Iterator
  typealias Element = String
  
  func makeAsyncIterator() -> AsyncStream<String>.Iterator {
    return stream.makeAsyncIterator()
  }
  
}

Am I missing a simpler way to handle this scenario, or a way to make this approach more concise? For example, it would be nice to eliminate the .task modifier, but spinning up a Task in ViewModel.init felt like a bit too much of a code smell to me.

1 Like

There's onChange(of:initial:_:), if you don't need the continuity of a long-running Task. Though you could always pipe the change to a persistent task via e.g. an AsyncStream or AsyncChannel.

I haven't started using Observable, but I'd put task in model. One uses Combine or AsyncSequence usually because there are multiple async events occurring (otherwise didSet should suffice). The typical and most clean way to dealt with these async events are in non-UI code. I'm not sure why you think it's a code smell. I think it's the nature of what you try to implement.

P.S. I'd also put the AsyncStream related code in a protocol. That would greatly simplify the code.

Hi @seankit, when you say "handle text that a user types into a SwiftUI TextField", what kind of logic exactly do you want to execute?

You can get very far with simply overriding the didSet on the property:

@Observable
class ViewModel {
  var query = "" {
    didSet { /* do something here */ }
  }
}

It's not ideal for all situations, but it does work out a lot of the time.

Thanks for the replies. I think some of my hesitations around my code sample would be addressed if I moved the logic of handling the monitoring and streaming out of the view model, and into a object that the view model could use and observe.

@mbrandonw for this example, I was imagining a scenario where the user is performing a text search, and I want to execute a network request based on their query. With ObservableObject, you could handle that with the Combine publisher for the @Published property, so I was thinking through how I would approach that situation with @Observable.

This can be done using didSet with a bit of work:

var searchTask: Task<Void, Never>? {
  willSet { searchTask?.cancel() }
}
var query = "" {
  didSet {
    searchTask = Task {
      await search()     
    }
  }
}

func search() async {
  // Make API request
}

You can also debounce the logic easily:

var searchTask: Task<Void, Never>? {
  willSet { searchTask?.cancel() }
}
var query = "" {
  didSet {
    searchTask = Task {
      try await Task.sleep(for: .seconds(0.3))
      await search()     
    }
  }
}

func search() async {
  // Make API request
}

And you could easily cook up some library code to package this up more nicely and hide some details from you.

This is probably more straightforward than juggling async sequences and withObservationTracking. At least until the Observation framework comes with an official tool for subscribing to changes.

3 Likes