Skip to content

Instantly share code, notes, and snippets.

@juanarzola
Last active June 18, 2024 06:17
Show Gist options
  • Save juanarzola/3c078df306daf530daff952465136658 to your computer and use it in GitHub Desktop.
Save juanarzola/3c078df306daf530daff952465136658 to your computer and use it in GitHub Desktop.
Proposed query API for SwiftData notifications (stream based)
// Our one super simple SwiftData Model
@Model
class Item {
...
}
// MARK: - TodayView
@MainActor
struct TodayView: View {
@State private var controller = TodayController()
@Environment(\.modelContext) private var modelContext
var body: some View {
HStack {
switch controller.model {
case .loading(let prevValue):
// render loading states (loading/reloading)
...
case .done(let content)
TodayViewContent(content: content)
}
)
.task {
// await all updates in the controller
await controller.updates(modelContext)
}
}
}
// MARK: - TodayViewModel
@MainActor
@Observable final class TodayController {
enum LoadError: Error {}
struct Model {
let sections: [TodaySection]
}
// some data that the view consumes (just a single model for now, but it's ok to have more than 1 property of data here, we are not trying to be MVC ;))
private(set) var model: Loadable<Model, LoadError> = .notStarted
// a stream of updates that the controller handles internally. The view calls the update(with:) function instead.
@ObservationIgnored private var updates: AsyncStream<[Item]>? = nil
/// Main function used by the View to observe updates in a task. It updates model whenever queries (and in the future, any other relevant inputs) change.
func updates(_ modelContext: ModelContext) async {
let updates = self.updates ?? Self.createUpdatesSequence(in: modelContext, isInitialLoad: true)
self.updates = updates
for await items in updates {
await load(with: items)
}
// this code hits when the view's task is cancelled. We want to keep observing (buffering the last update) after the task is cancelled, but set isInitialLoad to false so that we don't get an extra unnecessary update.
self.updates = Self.createUpdatesSequence(in: modelContext, isInitialLoad: false)
}
func load(with queryData: [Items]) async {
self.model = .loading(previousValue: self.content.value)
// build sections, then set the content to .done. The sections are structs built from SwiftData's result and are what the View displays
let sections = await Self.sections(from: queryData)
self.model = .done(.init(sections: sections))
}
// MARK: - Controller Updates Stream
/// This is where we use the proposed Query `updates` API to create and observe queries.
/// If we were to support more than 1 query in this controller, this stream can be replaced by an enum
/// for each type of update and use AsyncAlgorithms's `merge` to merge all the query streams)
private static func createUpdatesStream(in modelContext: ModelContext, isInitialLoad: Bool) -> AsyncStream<[Item]> {
// build the query
let descriptor = ...
let query = Query(descriptor, modelContext: modelContext)
// return the stream of the query
// if withCurrentValue is true the stream returns the current value, then observes subsequent ones. If false, it only observes subsequent ones.
// In order to properly implement this under the hood, SwiftData's stream needs a buffer size of 1 to always remember the latest value.
return query.updates(withCurrentValue: isInitialLoad)
}
// MARK: - Section building
private static func sections(from: [Items]) async -> [Section] {
// build sections from SwiftData Items
// it could be async (data loaded from an actor) or not, depends on your use case
...
return sections
}
}
@juanarzola
Copy link
Author

This Query macro demonstrates a similar idea as well (using a stored property in addition to observing) https://github.com/juanarzola/Queried

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment