4

Summary

I have a View Model class, paired with a SwiftUI View, that fetches filtered entries from SwiftData using a ModelActor. I've used SwiftUI's .task(id:) to trigger re-loads in the View Model whenever my filter predicate changes, but I want to also trigger re-loads whenever the database itself is updated throughout my UI. How can I tell my View Model to re-load whenever SwiftData commits my insert() and delete() calls?

Context and Example Code

I'm fetching a large number of items from a SwiftData store using a frequently-changing predicate. Traditional @Query setups did not provide the flexibility I wanted (specifically for rendering loading states), so I created a background actor to handle fetching the data:

import Foundation
import SwiftData

@Model
final class Item {
    var timestamp: Date
    
    init(timestamp: Date) {
        self.timestamp = timestamp
    }
}

struct ItemView: Identifiable, Sendable {
    var id: PersistentIdentifier
    var timestamp: Date

    init(_ model: Item) {
        id = model.id
        timestamp = model.timestamp
    }
}

@ModelActor
actor ThreadsafeBackgroundActor: Sendable {
    private var context: ModelContext { modelExecutor.modelContext }

    func fetchData(_ predicate: Predicate<Item>? = nil) throws -> [ItemView] {
        let descriptor = if let p = predicate {
            FetchDescriptor<Item>(predicate: p)
        } else {
            FetchDescriptor<Item>()
        }
        let items = try context.fetch(descriptor)
        return items.map(ItemView.init)
    }
}

I've also got a view model calling the actor:

import SwiftUI
import SwiftData

@Observable
class ItemListViewModel {
    enum State {
        case idle
        case loading
        case failed(Error)
        case loaded([ItemView])
    }

    private(set) var state = State.idle

    func fetchData(container: ModelContainer, predicate: Predicate<Item>) async throws -> [ItemView] {
        let service = ThreadsafeBackgroundActor(modelContainer: container)
        return try await service.fetchData(predicate)
    }

    @MainActor func load(container: ModelContainer, predicate: Predicate<Item>) async {
        state = .loading

        do {
            // Artificial delay to visualize loading state
            try await Task.sleep(for: .seconds(1))
            let items = try await fetchData(container: container, predicate: predicate)
            state = .loaded(items)
        } catch is CancellationError {
            state = .idle
        } catch {
            state = .failed(error)
        }
    }
}

And I've got a task on my SwiftUI view to kick off the initial load:

import SwiftUI
import SwiftData

struct ContentView: View {
    @State private var viewModel = ItemListViewModel()
    @Environment(\.modelContext) private var modelContext
    @State private var refreshCount = 0

    var body: some View {
        NavigationSplitView {
            Group {
                switch viewModel.state {
                case .idle:
                    EmptyView()
                case .loading:
                    ProgressView()
                case .failed(let error):
                    Text("Error: \(error)")
                case .loaded(let items):
                    List {
                        ForEach(items) { item in
                            NavigationLink {
                                Text("Item at \(item.timestamp, format: Date.FormatStyle(date: .numeric, time: .standard))")
                            } label: {
                                Text(item.timestamp, format: Date.FormatStyle(date: .numeric, time: .standard))
                            }
                        }
                    }
                }
            }
#if os(macOS)
            .navigationSplitViewColumnWidth(min: 180, ideal: 200)
#endif
            .toolbar {
#if os(iOS)
                ToolbarItem(placement: .navigationBarTrailing) {
                    EditButton()
                }
#endif
                ToolbarItem {
                    Button(action: addItem) {
                        Label("Add Item", systemImage: "plus")
                    }
                }
                ToolbarItem {
                    Button {
                        do {
                            try modelContext.save()
                        } catch {
                            fatalError(error.localizedDescription)
                        }
                    } label: {
                        Label("Save", systemImage: "square.and.arrow.down")
                    }
                }
                ToolbarItem {
                    Button {
                        refreshCount += 1
                    } label: {
                        Label("Refresh", systemImage: "arrow.clockwise")
                    }
                }
            }
        } detail: {
            Text("Select an item")
        }
        .task(id: refreshCount) {
            // Typically the task ID would be tied to a dynamic predicate.
            // I have it tied to a UI button (and made the predicate static) for a minimal example.
            await viewModel.load(container: modelContext.container, predicate: #Predicate { _ in true })
        }
    }

    private func addItem() {
        withAnimation {
            let newItem = Item(timestamp: Date())
            modelContext.insert(newItem)
        }
    }
}

This setup works excellently until actions in the view update anything in the database. ModelContext actions, e.g. context.insert() in addItem, do not trigger my load function. Calling context.save() (in the save button's action) does not force an update, either.

How can I tell the load function to re-run whenever SwiftData commits my insert() and delete() calls throughout the codebase? Preferably, I'd like to minimize the additional code I'm putting in each location I call ModelContext functions. Letting SwiftData operate with minimal intervention from me is the goal.

Attempts and Research

  • I've considered manual (user-initiated) refresh triggers, like List's .refreshable utility, or the "refresh" button in the example above. While useful for users who know they need the data updated, I don't want the validity of the presented data to be dependent on the user's intervention.

  • I've attempted a brute-force route of introducing a refreshCount state variable, passed around wherever needed. Areas of the code that update the database increment refreshCount, and a separate task in my list view watches the count:

    .task(id: predicate) { /* Same call */ }
    .task(id: refreshCount) { // Eww...
         viewModel.load(container: modelContext.container, predicate: predicate)
    }
    

    Not only is this strategy tedious and brittle, but it also runs the risk of calling load multiple times unnecessarily (especially at the initial render time).

  • I've looked into Swift's streaming notification system. I'm fairly confident that NSPersistentStoreRemoteChange is what I want to watch. I just cannot figure out how/where to initialize that watcher. addObserver asks for Objective-C annotations. I don't think that .publisher().sink { } is the solution either, because I want to kick off the mutating call viewModel.load() in the (escaping) closure.

13
  • 2
    There is no way to reload SwiftData automatically outside of a View, only Query can do that now. Switch to CoreData if you want to use any of the "NS" features Commented Aug 22 at 18:25
  • 1
    Do database updates happen as a result of actions in the view or from other external sources? Commented Aug 22 at 22:39
  • 2
    You could try a simple setup of adding an observed var hasChanged: Bool = false in your @Observable class CardListViewModel, and whenever you do your database updates (inserts, modifications, deletes), toggle it. Then add a .onChange(of: viewModel.hasChanged) in your code to action a viewModel.load(...). Commented Aug 23 at 0:16
  • 1
    Send a local notification whenever you save and listen to that notification in your observable class. At least this is a possible solution if you have clear save points in your code and doesn’t rely on auto saving. Commented Aug 23 at 12:28
  • 1
    If all updates are being done from views then the most straightforward solution in my opinion is to listen to the ModelContext.didSave notification in the view model class and call load each time a notification is received. (I don’t have access to a computer right now so I can’t post this as an answer with a code example right now) Commented Aug 24 at 6:17

2 Answers 2

1

modelContext without @Query

To address one of my comments and an important aspect of SwiftData and modelContext that I actually wasn't aware of, it is important to note that to really benefit from most of its features, you have to use @Query.

That includes the ability for views to react to operations like .insert() and .save(), but also for observation using .onChange(of:). So my suggestion in comments about using .onChange(of: modelContext.hasChanges) { oldValue, newValue in to trigger your .load function will not work if you don't use @Query.

This is because without Query, modelContext is not observable in a way that will trigger views to update if it changes. You can use it as reference, but you'll need other mechanism to trigger view updates.

The same applies to other features, like FetchDescriptor for example, for which the documentation states:

If you’re displaying the fetched models in a SwiftUI view, use the descriptor with the Query(_:animation:) macro instead.

ModelContext.didSave notifications

One option for triggering a viewModel function when the context saves is registering for and listening to notifications posted by modelContext. This is very limiting, however, because only the .save() posts a notification, which constrains you to only knowing the state of data after it was saved and not before.

This prevents you from performing incremental or optimistic updates to the UI, and leaves you with no other choice but to refetch the entire data, even if only one item was added or removed. Depending on the data set, this can lead to unnecessarily heavy fetch operations that will require careful management of the threads, actors and identity to reconcile data and view updates without blocking the UI or causing undesirable flickers.

But since ModelContext.didSave is right now the only means available to know that a .save() occurred (together with the NotificationKey cases), you can use notifications(named:object:), which is a more modern way of working with notifications, rather than using AsyncStream or Combine's publisher.

// Use the modern, async listener for the .didSave notification.
let notifications = NotificationCenter.default.notifications(
    named: ModelContext.didSave
)

// This loop waits patiently and efficiently for notifications to arrive.
// It will run for as long as this ViewModel exists.
for await notification in notifications {
    
    // You could inspect the notification's userInfo here for details
    // about what was inserted, updated, or deleted.
    
    // Call your fetch function.
    await load()
}

One of the benefits of this style is automatic lifestyle management. Compared to using a Task<Void, Never>? that uses AsyncStream, and a cancel in deinit (a manual lifecycle management), the boilerplate (not to mention the mental model) is considerably reduced.

What about a modelContext wrapper?

If the limitation is that modelContext is not observable, you could implement an @Observable wrapper that will have the duty to manage model context state, allow for custom loading state management, listen for notifications like didSave and allow for optimistic updates (like @Query would) through its observable properties.

This means you'd have to adopt using the wrapper-provided functions to perform updates. So instead of modelContext.insert(item), you'd use:

contextWrapper.insert(item)

And similarly for all other related functions like .save(), .delete(), etc.

The good thing is that because it's @Observable, it would allow for optimistic UI updates, where adding or removing items can be immediately reflected in the view, without the need for a refetch of the entire data.

But once you will inevitably go down the rabbit whole of making it more complete, to cover all logical cases and scenarios of model context operations, you'll realize that this exercise is basically reinventing the wheel. That's because everything the wrapper provides (maybe minus the custom state management, like loading, idle, etc.) already exists in the form of a @Query.

So back to @Query then?

If you don't want to exhaust yourself building a full wrapper, but enjoy the idea of optimistic UI updates, maybe it's worth giving @Query another shot, because it will probably do what you need with the right setup.

Here's a fully working example that uses @Query (two, actually), initialized with a predicate provided by the viewModel for filtering, complete with a listener for didSave notifications and a simulated loading state indicator.

import SwiftUI
import SwiftData

@Model
final class Item {
    
    var timestamp: Date
    var isArchived: Bool = false
    var isSpecial: Bool = false
    var isFavorite: Bool = false
    
    init(timestamp: Date) {
        self.timestamp = timestamp
    }
}

@MainActor
@Observable
class ItemViewModel {
    
    // A nested struct to hold all filter states cleanly.
    struct Filters {
        var onlyArchived = false
        var onlySpecial  = false
        
        var description: String {
            var activeFilters: [String] = []
            
            if onlyArchived {
                activeFilters.append("Archived")
            }
            
            if onlySpecial {
                activeFilters.append("On Sale")
            }
            
            if activeFilters.isEmpty {
                return "All Items"
            } else {
                return activeFilters.joined(separator: " & ")
            }
        }
    }
    
    var isLoading = false
    var filters = Filters()

    var statusMessage: String?
    
    private var modelContext: ModelContext

    // The ViewModel is initialized with its required ModelContext dependency.
    init(modelContext: ModelContext) {
        self.modelContext = modelContext
    }

    // The View calls this method via .onChange to keep the ViewModel's

    /// A computed property that creates a live, compound predicate based on the filter struct.
    /// The View uses this to configure its @Query.
    var predicate: Predicate<Item> {
        let onlyArchived = filters.onlyArchived
        let onlySpecial = filters.onlySpecial
        
        return #Predicate<Item> { item in
            // An item must satisfy all active filters.
            // If a filter is 'false', the condition effectively becomes 'true' for that part.
            (onlyArchived ? item.isArchived == true : true) &&
            (onlySpecial ? item.isSpecial == true : true)
        }
    }

    func save() async {
        // A. Essential Check: Only proceed if there are actual changes to save.
        guard modelContext.hasChanges else {
            print("No changes to save.")
            self.statusMessage = "No Changes to Save"
            Task {
                try? await Task.sleep(for: .seconds(1))
                self.statusMessage = nil
            }
            return
        }

        // B. Turn the loading indicator ON.
        self.isLoading = true
        defer { self.isLoading = false }
        
        
        // --- Artificial Delay for UI Demonstration ---
        // This delay simulates a longer save operation (e.g., a network request).
        // It ensures the loading indicator is visible. Remove for production.
        try? await Task.sleep(for: .seconds(1))
        
        // C. Use a modern, concurrent listener for the .didSave notification.
        let notifications = NotificationCenter.default.notifications(
            named: ModelContext.didSave
        )
        
        do {
            try modelContext.save()
        } catch {
            print("Save failed: \(error)")
            return
        }
        
        // D. Wait patiently for the .didSave notification to arrive.
        // This confirms the save transaction is complete and other parts of the app,
        // like @Query, have been notified to update.
        for await _ in notifications {
            // As soon as the first notification arrives, we know the process is complete.
            break
        }
        
        //Reminder that loading state is reverted by the defer specified higher up, to ensure the view cannot remain in a stuck loading state
    }
    
    func toggleFavorite(_ item: Item) {
        item.isFavorite.toggle()
    }
}


struct FilteredDataView: View {
    @Environment(\.modelContext) private var modelContext
    
    // This view receives the single source of truth for state.
    @Bindable var viewModel: ItemViewModel
    
    // The @Query is configured using the ViewModel's live predicate (via init below)
    @Query private var filteredItems: [Item]

    // Optional - This second query fetches ALL items, just for the total count. It's efficient because no properties are accessed. Similar to SELECT COUNT(*) FROM Item;
    @Query private var allItems: [Item]
    
    init(viewModel: ItemViewModel) {
        self.viewModel = viewModel
        // Configure the @Query with the ViewModel's predicate
        // and sort by the timestamp (newest first).
        _filteredItems = Query(filter: viewModel.predicate, sort: \.timestamp, order: .reverse, animation: .default)
    }

    var body: some View {
        List {
            Section {
                ForEach(filteredItems) { item in
                    RowItemView(item: item)
                        .swipeActions(edge: .trailing) {
                            //Delete
                            Button {
                                withAnimation {
                                    modelContext.delete(item)
                                }
                            } label: {
                                Image(systemName: "trash")
                            }
                            .tint(.red)
                        }
                        .swipeActions(edge: .leading) {
                            //Favorite
                            Button {
                                withAnimation {
                                    viewModel.toggleFavorite(item)
                                }
                            } label: {
                                Image(systemName: "star")
                            }
                            .tint(.teal)
                        }
                }
            } header: {
                Text(viewModel.filters.description)
            } footer: {
                Text("Showing \(filteredItems.count) of \(allItems.count) items")
                    .font(.footnote)
                    .foregroundStyle(.secondary)
                    .frame(maxWidth: .infinity, alignment: .center)
                
            }
        }
        .toolbar {
            
            //Add item button
            ToolbarItem(placement: .primaryAction) {
                Button("Add Item") {
                    let newItem = Item(timestamp: Date())
                    newItem.isSpecial = Bool.random()
                    newItem.isArchived = Bool.random()
                    newItem.isFavorite = Bool.random()
                    
                    modelContext.insert(newItem)
                }
            }
            
            //Loading indicator
            ToolbarItem(placement: .principal) {
                Group {
                    if let message = viewModel.statusMessage {
                        Text(message)
                    }
                    
                    if viewModel.isLoading {
                        HStack {
                            Text("Loading")
                            ProgressView()
                        }
                    }
                }
                .font(.footnote)
                .foregroundStyle(.secondary)
                .transition(.opacity.animation(.easeInOut))
                .padding(.top, 2)
            }

            //Save button
            ToolbarItem(placement: .topBarLeading) {
                Button("Save") {
                    Task { await viewModel.save() }
                }
                .disabled(viewModel.isLoading)
            }
            
        }
    }
}

struct RowItemView: View {
    
    //Parameters
    let item: Item
    
    //Body
    var body: some View {
        LabeledContent {
            if item.isSpecial {
                Image(systemName: "tag.fill")
                    .foregroundStyle(.orange)
            }
        } label: {
            Label {
                Text(item.timestamp, format: Date.FormatStyle(date: .numeric, time: .standard))
            } icon: {
                if item.isFavorite {
                    Image(systemName: item.isFavorite ? "star.fill" : "circle.fill")
                        .imageScale(.medium)
                        .foregroundStyle(item.isFavorite ? .teal : .green)
                }
                else {
                    Image(systemName: "circle")
                        .hidden()
                }
            }
            let statusText = item.isArchived ? "Archived" : "Active"
            let statusColor: Color = item.isArchived ? .indigo : .green
            let saleStatusText = item.isSpecial ? "On sale" : "Regular"
            let saleStatusColor: Color = item.isSpecial ? .orange : .gray
            
            HStack(spacing: 0) {
                Text(statusText)
                    .foregroundStyle(statusColor)
                Text(", ")
                Text(saleStatusText)
                    .foregroundStyle(saleStatusColor)
            }
        }
    }
}

struct FiltersView: View {
    @Bindable var viewModel: ItemViewModel
    
    var body: some View {
        HStack {
            Picker("Status", selection: $viewModel.filters.onlyArchived) {
                Text("All").tag(false)
                Text("Archived").tag(true)
            }
            .pickerStyle(.segmented)
            
            Picker("Special", selection: $viewModel.filters.onlySpecial) {
                Text("All").tag(false)
                Text("On Sale").tag(true)
            }
            .pickerStyle(.segmented)
        }
        .padding([.horizontal, .top])
    }
}


struct ContentView: View {

    @State private var viewModel: ItemViewModel

    init(modelContext: ModelContext) {
        _viewModel = State(initialValue: ItemViewModel(modelContext: modelContext))
    }

    var body: some View {
        NavigationStack {
            VStack {
                // The FiltersView directly modifies the ViewModel.
                FiltersView(viewModel: viewModel)
                
                FilteredDataView(viewModel: viewModel)
            }
            .navigationTitle("Items")
        }
    }
}

#Preview {
    let container = try! ModelContainer(for: Item.self, configurations: .init(isStoredInMemoryOnly: true))
    let context = container.mainContext
    
    // Preview seed data
    for _ in 0..<5 {
        let newItem = Item(timestamp: Date())
        newItem.isSpecial = Bool.random()
        newItem.isArchived = Bool.random()
        context.insert(newItem)
    }
    try? context.save()   // <- saving seeded items so they're available when Preview loads
    
    return ContentView(modelContext: context)
        .modelContainer(container)
}

enter image description here

Sign up to request clarification or add additional context in comments.

6 Comments

When using a custom FetchDescriptor with @Query, it is also possible to use @Query just for observation of modelContext changes by setting the fetchCount property of the descriptor to 0. This would allow for views to react to changes in context without any fetching done by Query, allowing the viewModel to use its own fetch logic. But it feels counterintuitive and somewhat hacky, which is why I didn't detail it in the answer.
Gave your answer an initial read-through: this looks outstanding, thank you for the detailed write-up! I want to give the code a try before formally accepting the answer, but either way this is a fantastic summary of the conversation we had below. Again, I appreciate your time!
Switching back to using @Query and the custom save() utility works almost perfectly. However, I'm noticing sluggishness and hanging in the UI after the adjustment. For example, when navigating between a list and detail view, the UI hangs for about half a second with the item (or back button) highlighted. When typing in a TextField bound to a Model item, the letters appear in bursts. I think this sort of behavior was what originally led me down my over-engineered path of @ModelActor in the first place! Perhaps it's just a compromise I have to make (code elegance vs UI snappiness).
(and, for reference, my app has a list of thousands of Models. It's no wonder performance is a concern. I may also consider limiting the rendered item count to a few hundred at a time, given how unlikely it is for someone to scroll through the entire unfiltered list... but that's a discussion outside the scope of this answer! Just pointing it out for future readers.)
Thanks for the follow up. Just to clarify, are you using one @Query or two? I did some additional testing the other day and it turns out that even if setting fetchCount to 0, @Query will still load all the items in memory, so having two wouldn't be efficient. I will update my answer with this finding.
I was going to suggest paginating using the fetchLimit and fetchOffset of the FetchDescriptor, but doesn't List lazy fetch anyway? Typing lag will happen if you bind the textfield to a model item, because it's saving changes to the persistent store on the main thread with every keystroke. You should bind the textfield to a local state instead and update the actual model only when editing is finished. The navigation lag with the detail view may have to do more with the work done by the detail view, rather than the list. But yes, this may be more suitable as a separate question.
0

The solution I ended up with so far did in fact use NSPersistentStoreRemoteChange, observed using an AsyncStream construct.

I'm adding my code below for reference, but I encourage others to consider the suggestions and wisdom in the question's comments!


I adjusted the fetchData method to return an AsyncStream<[ItemView]>:

@ModelActor
actor ThreadsafeBackgroundActor: Sendable {
    private var context: ModelContext { modelExecutor.modelContext }

    func fetchData(_ predicate: Predicate<Item>? = nil) -> AsyncStream<[ItemView]> {
        let descriptor = if let p = predicate {
            FetchDescriptor<Item>(predicate: p)
        } else {
            FetchDescriptor<Item>()
        }

        return AsyncStream { continuation in
            let task = Task {
                for await _ in NotificationCenter.default.notifications(named: .NSPersistentStoreRemoteChange).map({ _ in () }) {
                    do {
                        let items = try context.fetch(descriptor)
                        continuation.yield(items.map(ItemView.init))
                    } catch {
                        // Ignore errors
                    }
                }
            }

            continuation.onTermination = { _ in
                task.cancel()
            }

            do {
                let items = try context.fetch(descriptor)
                continuation.yield(items.map(ItemView.init))
            } catch {
                // Ignore errors
            }
        }
    }
}

In my view model, I added a bit of scaffolding to digest the updates from the stream:

@Observable
class ItemListViewModel {
    enum State {
        case idle
        case loading
        case failed(Error)
        case loaded([ItemView])
    }

    private(set) var state = State.idle
    private var observationTask: Task<Void, Never>?

    deinit {
        observationTask?.cancel()
    }

    func fetchData(container: ModelContainer, predicate: Predicate<Item>) async -> AsyncStream<[ItemView]> {
        let service = ThreadsafeBackgroundActor(modelContainer: container)
        return await service.fetchData(predicate)
    }

    @MainActor func load(container: ModelContainer, predicate: Predicate<Item>) async {
        state = .loading

        observationTask?.cancel()
        observationTask = Task { [weak self] in
            guard let self else { return }

            let stream = await self.fetchData(container: container, predicate: predicate)
            for await items in stream {
                self.state = .loaded(items)
            }
        }
    }
}

Now, whenever context.save() is called anywhere in the app, the view model is reloaded. The changes to the predicate (i.e. the refreshCount in the question's MRE) also trigger a reload. No additional code is needed "in place" at the point of calling context.save() or the .task.

11 Comments

Also, while this may work for you, it feels overly wrangled, if not borderline gory :). If you have to use AsyncStream, NotificationCenter, continuation and task cancelling just to react to model context changes, in the era of @Observable, SwiftData and concurrency, chances are things are not set up right. What makes it hard to pinpoint is the lack of a reproducible example. If you can put one together, we may be able to figure this out the right way.
I'm happy to keep working on making the example in the question clear and reproducible, but I am unsure what more to add without making the question unnecessarily verbose. Genuine question, would it be overkill for me to create an entirely new iOS Swift project to demonstrate what I'm looking for?
That can work, as long as it's simplified. Most of the structure was already provided, I think all's missing is the views/UI to make it all work. I actually just came across the need to react to context changes myself and I went about it differently, so I would be curious to try with your setup and see if it works.
I updated my question to include a full MRE, based on the default App project template in XCode. Let me know if it's either too verbose, or still lacking detail. I'll also work on updating my answer accordingly, as time allows. Thanks for your interest and patience!
Thanks, I am gonna test it but I see you've simplified it to not use a predicate. I was hoping the MRE would include an example with a formed predicate (not just true).
Is there a reason why you're using a ModelActor (and a separate context) for fetching data? From what I see, you're inserting into one context (without saving), and fetching in another. So the ModelActor cannot return non-saved inserted models from the other context. It would be simpler to fetch in the same context, without switching actors (and contexts).
I chose the ModelActor strategy because I wanted to run complex queries/predicates on a background thread, and avoid freezing the UI. You're right that I need to save() before accessing any updates across actors/contexts. I've been searching for the cleanest way to get the background actor to re-fetch whenever a save() happens anywhere. But I may have posed an XY problem here, if there is some way to run asynchronous (and reactive) queries without switching actors or contexts!
Ideally, shouldn't a reactive UI happen as soon as the data changes? That is, when data is added or removed and before it is actually saved/persisted? I believe that's what Query makes possible normally. It reacts to model context changes and saves in the background. You shouldn't have to force a save() in order to update the UI.
This comment thread is getting long: would you mind moving the discussion to chat? (Thank you again for your interest in this problem!)
@Neill There is indeed a newer way to listen to notifications without using AsyncStream, using NotificationCenter.notifications. To avoid having to manually cancel tasks in deinit, you can use automatic lifecycle management. I added an answer with details.

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.