SwiftData @Query not triggering automatic SwiftUI redraw after update/insert

143 views Asked by At

I'm building a SwiftUI app using SwiftData @Query and struggling quite a bit with redraws and slow inserts.

  1. How can I ensure that redraws are automatically triggered on my Views (both ShowView and ContentView) after data is updated?
  2. How can I speed up my model update inserts?

Here's a simplified and representative version of my app:

// MARK: - Complete Copy+Paste Example:

import SwiftUI
import SwiftData

// MARK: - Entry

@main
struct SampleApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
        .modelContainer(for: Show.self)
    }
}

// MARK: - View

struct ContentView: View {
    @Environment(\.modelContext) private var modelContext: ModelContext
    @State private var path = NavigationPath()
    @Query private var shows: [Show]

    var body: some View {
        NavigationStack(path: $path) {
            List {
                ForEach(shows) { show in
                    Row(show: show)
                }
            }
            .navigationDestination(for: Show.self) { show in
                ShowView(show: show)
            }
            .toolbar {
                Button {
                    // mimic 1st network call for basic show info
                    // works fine
                    let newShow = Show(name: .random(length: 5))
                    modelContext.insert(newShow)
                } label: {
                    Image(systemName: "plus")
                }
            }
        }
    }

    struct Row: View {
        var show: Show

        var body: some View {
            NavigationLink(value: show) {
                VStack(alignment: .leading) {
                    Text(show.name)
                    if let date = show.nextDate {
                        Text(date.formatted(date: .abbreviated, time: .shortened))
                    }
                }
            }
        }
    }
}

struct ShowView: View {
    @Environment(\.modelContext) private var modelContext: ModelContext
    @Bindable var show: Show

    var body: some View {
        VStack(alignment: .leading) {
            Text(show.name)
            if !show.episodes.isEmpty {
                Text("Episodes: \(show.episodes.count)")
            }
            if let date = show.nextDate {
                Text(date.formatted(date: .abbreviated, time: .shortened))
            }
            Button {
                // 1. ISSUE: doesn't automatically force a SwiftUI redraw on this ShowView, or the main ContentView?
                Task {
                    let actor = ShowActor(modelContainer: modelContext.container)
                    try await actor.update(show.persistentModelID)
                }
            } label: {
                Text("Update")
            }
        }
    }
}

// MARK: - ModelActor

@ModelActor
actor ShowActor {}

extension ShowActor {
    func update(_ identifier: PersistentIdentifier) async throws {
        guard let show = modelContext.model(for: identifier) as? Show else { return }
        // mimics 2nd network call to add nextDate + episode info adds
        show.nextDate = .randomDateInNext7Days()
        // ISSUE: inserts are very slow, how to speed up?
        for _ in 0...Int.random(in: 10...100) {
            let episode = Episode(name: .random(length: 10))
            modelContext.insert(episode) // crashes if episode isn't first insert before adding show?
            episode.show = show
        }
        try modelContext.save()
    }
}

// MARK: - Models

@Model
class Show {
    var name: String
    var nextDate: Date?
    @Relationship(deleteRule: .cascade, inverse: \Episode.show) var episodes: [Episode]

    init(name: String, nextDate: Date? = nil, episodes: [Episode] = []) {
        self.name = name
        self.nextDate = nextDate
        self.episodes = episodes
    }
}

@Model
class Episode {
    var show: Show?
    var name: String

    init(show: Show? = nil, name: String) {
        self.show = show
        self.name = name
    }
}

// MARK: - Helpers

extension String {
    static func random(length: Int) -> String {
        let letters = "abcdefghijklmnopqrstuvwxyz"
        return String((0..<length).map{ _ in letters.randomElement()! })
    }
}

extension Date {
    static func randomDateInNext7Days() -> Date {
        Calendar.current.date(byAdding: .day, value: Int.random(in: 1...7), to: .now)!
    }
}

// MARK: - Preview

#Preview {
    let config = ModelConfiguration(isStoredInMemoryOnly: true)
    let container = try! ModelContainer(for: Show.self, configurations: config)
    return ContentView()
        .modelContainer(container)
}

Thanks in advance for your help!

1

There are 1 answers

2
Joakim Danielson On

Performance:

Don't update the relationship inside the loop, do it for all episodes afterwards.

var episodes = [Episode]()
for _ in 0...99 {
    let episode = Episode(name: .random(length: 10))
    episodes.append(episode)
}
show.episodes.append(contentsOf: episodes)
try modelContext.save()

Update UI:

You can post a notification from the actor when it's done

await MainActor.run {
    NotificationQueue.default.enqueue(Notification(name: Notification.Name("ActorIsDone")),
                                      postingStyle: .now)
}

and then use .onReceive in your views to somehow trigger a refresh

.onReceive(NotificationCenter.default.publisher(for: Notification.Name("ActorIsDone")), perform: { _ in
   // ...
})