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.

4 Likes

For a async work related to something on screen the .task modifier removes the need for a reference type at all, i.e. you don't need a class anymore and can simply .task(id:) with the more efficient @State, e.g.

@State var query = ""

.task(id: query) {
    if query.isEmpty { return }
    results = await myAsyncFunc(query)
}

Where you place myAsyncFunc is up to you but you could put it in a struct to make it testable where the test makes its own mutable state the same as the View has. And if the struct is an EnvironmentKey that would make it mockable for Previews. This task starts on appear hence the isEmpty check and it is cancelled on disappear.

If your async work is not UI related, e.g. not tied to something appearing or disappearing, then you can use an @Observable class to hold your data and then you can manage an async lifecycle yourself somewhere using Task and monitor the @Observable using a stream as follows:

let stream = AsyncStream {
    await withCheckedContinuation { continuation in
        let _ = withObservationTracking { // let _ required to fix compilation error
            model.text
        } onChange: {
            continuation.resume()
        }
    }
}
var iterator = stream.makeAsyncIterator()

repeat
{
    let text = model.text
    print("task \(text)")
}
while await iterator.next() != nil

By having the async code external to the @Observable class you will avoid retain cycles and don't need [weak self]. Where to start this app level Task is still a bit of an unknown since in SwiftUI there is only .backgroundTask no .appTask or .foregroundTask yet. Currently I do it in WindowGroup{...}.onChange(of: 0, initial: true) { which only happens once no matter how many scenes/windows come and go and the model is a singleton.

Thanks, this debouncing was what I was looking for.
I do need to specifically check for cancellation though, otherwise debouncing has no effect for me.

e.g.,:

    @Observable class ViewModel {
        var searchTask: Task<Void, Never>?
        var searchText: String = "" { didSet {
            searchTask?.cancel()
            self.searchTask = Task { [weak self] in
                try? await Task.sleep(for: .seconds(0.3))
                guard !Task.isCancelled else { return }
                try? await self?.updateResults()
            }
        }}
           // updateResults() etc.
    }

That works, but there's also a way to simplify. You can use a Task<Void, Error> so that the task can throw an error, and then cancellation works automatically when Task.sleep throws an error:

var searchTask: Task<Void, Error>?
var searchText: String = "" { 
  didSet {
    searchTask?.cancel()
    self.searchTask = Task { [weak self] in
      try await Task.sleep(for: .seconds(0.3))
      try await self?.updateResults()
    }
  }
}
2 Likes