finn voorhees

April 28th, 2024

Subscribing to SwiftData changes outside SwiftUI

SwiftData logo

While there are plenty of articles about using SwiftData outside of SwiftUI, many fail to mention how you might observe insertions and deletions similarly to how SwiftUI’s Query macro works.

Because SwiftData is backed by CoreData, changes can be detected using the .NSPersistentStoreRemoteChange notification. A simple Database wrapper class can be created that vends AsyncStream’s of SwiftData models given a FetchDescriptor.

This AsyncStream can then be used to, for example, update a diffable data source to display your data in a UICollectionView.

Database.swift

import Foundation
import SwiftData

@MainActor final class Database {
    // MARK: Lifecycle

    init(isStoredInMemoryOnly: Bool = false) {
        do {
            let configuration = ModelConfiguration(isStoredInMemoryOnly: isStoredInMemoryOnly)
            container = try ModelContainer(for: Link.self, configurations: configuration)
        } catch {
            fatalError("\(error)")
        }
    }

    // MARK: Public

    public var context: ModelContext { container.mainContext }

    // MARK: Internal

    func models<T: PersistentModel>(
        filter: Predicate<T>? = nil,
        sort keyPath: KeyPath<T, some Comparable>,
        order: SortOrder = .forward
    ) -> AsyncStream<[T]> {
        let fetchDescriptor = FetchDescriptor(
            predicate: filter,
            sortBy: [SortDescriptor(keyPath, order: order)]
        )
        return models(matching: fetchDescriptor)
    }

    func models<T: PersistentModel>(matching fetchDescriptor: FetchDescriptor<T>) -> AsyncStream<[T]> {
        AsyncStream { continuation in
            let task = Task {
                for await _ in NotificationCenter.default.notifications(
                    named: .NSPersistentStoreRemoteChange
                ).map({ _ in () }) {
                    do {
                        let models = try container.mainContext.fetch(fetchDescriptor)
                        continuation.yield(models)
                    } catch {
                        // log/ignore the error, or return an AsyncThrowingStream
                    }
                }
            }
            continuation.onTermination = { _ in
                task.cancel()
            }
            do {
                let models = try container.mainContext.fetch(fetchDescriptor)
                continuation.yield(models)
            } catch {
                // log/ignore the error, or return an AsyncThrowingStream
            }
        }
    }

    // MARK: Private

    private let container: ModelContainer
}

Usage

for await items in database.models(sort: \TodoItem.creationTime, order: .reverse) {
    var snapshot = NSDiffableDataSourceSnapshot<Int, TodoItem>()
    snapshot.appendSections([0])
    snapshot.appendItems(items, toSection: 0)
    dataSource.apply(snapshot)
}

Note: You might want to throw a .removeDuplicates() from swift-async-algorithms on the stream to avoid unnecessary updates.