April 28th, 2024
Subscribing to SwiftData changes outside SwiftUI
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.