Skip to content

Instantly share code, notes, and snippets.

@juanarzola
Last active June 8, 2024 17:31
Show Gist options
  • Save juanarzola/d7225f17bffc62b941ed3f6a1317dfe2 to your computer and use it in GitHub Desktop.
Save juanarzola/d7225f17bffc62b941ed3f6a1317dfe2 to your computer and use it in GitHub Desktop.
ViewModel (or controller(!)) updateLoop pattern that keeps the model up-to-date in a task.
// MARK: - TodayView
@MainActor
struct TodayView: View {
@State private var viewModel = TodayViewModel()
@Environment(\.modelContext) private var modelContext
var body: some View {
HStack {
switch viewModel.content {
case .loading(let prevValue):
// render loading states (loading/reloading)
...
case .done(let content)
TodayViewContent(content: content)
}
)
// an async loop that keeps viewModel up-to-date as a result of external events (can change
// its content state from loading/done)
//
// the viewModel ensures that updates are still being generated even after
// the task ends - next time the task executes (when TodayView is re-added to the hierarchy)
// it'll receive any buffered events.
.task(priority: .userInitiated) {
await viewModel.updateLoop(modelContext.container)
}
}
}
// MARK: - TodayViewModel
@MainActor
@Observable final class TodayViewModel {
enum LoadError: Error {}
struct Content {
let dayStats: DayStats?
let someSection: EventGroupsSection?
let anotherSection: EventGroupsSection?
}
private(set) var content: Loadable<Content, LoadError> = .notStarted
init() {
self.updates = Self.createUpdatesSequence(isInitialLoad: true)
}
/// Update content and listen for updates. Use in a task in your view to listen for updates
func updateLoop(_ container: ModelContainer) async {
let actor = StudyStatsActor(modelContainer: container)
for await _ in updates {
await load(with: actor)
}
// keep observing (buffering the last update) after the task is cancelled
self.updates = Self.createUpdatesSequence(isInitialLoad: false)
}
func load(with statsActor: StudyStatsActor, calendar: Calendar = Calendar.current) async {
// set to loading (or reloading, if previous value is not nil)
self.content = .loading(previousValue: self.content.value)
let thisMonth = calendar.dateComponents([.month, .year], from: Date.now)
let monthStats = await statsActor.stats(forMonth: thisMonth)
// build day stats and sections, then set the content to .done
...
...
// why animate here? because animation() or transaction with value of content in the view doesn't animate.
withAnimation {
self.content = .done(
.init(
dayStats: dayStats,
someSection: someSection,
anotherSection: anotherSection
)
)
}
}
// MARK: - Update streams
private static func createUpdatesSequence(isInitialLoad: Bool) -> AsyncMerge3Sequence<AsyncStream<Void>, AsyncStream<Void>, AsyncStream<Void>> {
let initialLoad = AsyncStream<Void>(bufferingPolicy: .bufferingNewest(1)) { continuation in
// Generate an item in the initial load stream only on initial load.
// This allows `updateLoop` to iterate at least once the first time it's awaited by the client.
// Future awaits won't include this initial load item.
if isInitialLoad {
continuation.yield()
}
continuation.finish()
}
let allUpdates = merge(
initialLoad,
coreDataUpdates,
significantTimeChangeUpdates
)
return allUpdates
}
private static var coreDataUpdates: AsyncStream<Void> {
AsyncStream<Void>(bufferingPolicy: .bufferingNewest(1)) { continuation in
Task {
// observe updates and continuation.yield() on the relevant ones
...
}
}
}
private static var significantTimeChangeUpdates: AsyncStream<Void> {
AsyncStream<Void>(bufferingPolicy: .bufferingNewest(1)) { continuation in
Task {
// observes significant time update notifications
...
}
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment