1

I need a way to keep a global count of all model items in SwiftData.

My goal is to:

  • track how many entries exist in the model.
  • have the count be reactive (update when items are inserted or deleted).
  • handle a lot of pre-existing records.

This is for an internal app with thousands of records already, and potentially up to 50k after bulk imports.

I know there are other platforms, I want to keep this conversation about SwiftData though.

What I’ve tried:

  • @Query in .environment

    • Works, but it loads all entries in memory just to get a count.
    • Not scalable with tens of thousands of records.
  • modelContext.fetchCount

    • Efficient, but only runs once.
    • Not reactive, would need to be recalled every time
  • NotificationCenter in @Observable

    • Tried observing context changes, but couldn’t get fetchCount to update reactively.

      @Observable
      final class EntityCountManager {
      
          private(set) var total: Int
          private var context: ModelContext
          private var cancellable: AnyCancellable?
      
          init(context: ModelContext) {
              self.total = 0
              self.context = context
              self.cancellable = NotificationCenter.default
                  .publisher(for: .NSManagedObjectContextObjectsDidChange, object: context)
                  .sink { [weak self] _ in
                      self?.refresh()
                  }
              self.refresh()
          }
      
          private func refresh() {
              let descriptor = FetchDescriptor<Intake>()
              self.total = (try? context.fetchCount(descriptor)) ?? 0
          }
      }
      
  • Custom Property Wrapper

    • This works like @Query, but still loads everything in memory.

    • E.g.:

          @propertyWrapper
          struct ItemCount<T: PersistentModel>: DynamicProperty {
              @Environment(\.modelContext) private var context
              @Query private var results: [T]
      
              var wrappedValue: Int {
                  results.count
              }
      
              init(filter: Predicate<T>? = nil, sort: [SortDescriptor<T>] = []) {
                  _results = Query(filter: filter, sort: sort)
              }
          }
      

What I want:

  • A way to get .fetchCount to work reactively with insertions/deletions.
  • Or some observable model I can use as a single source of truth, so the count and derived calculations are accessible across multiple screens, without duplicating @Query everywhere.

Is there a SwiftData way to maintain a reactive count of items without loading all the models into memory every time I need it?

5
  • Can you show a minimal reproducible example of the NotificationCenter approach? Every time you receive the notification, just call fetchCount, right? What about that is not reactive? Commented Sep 11 at 2:18
  • @Sweeper - Added the Notification Center approach. I originally had it listening on .NSManagedObjectContextDidSave but then changed it to . NSManagedObjectContextObjectsDidChange Commented Sep 11 at 2:30
  • The fact that the initialiser takes a ModelContext is a red flag to me. Did you initialise the State in init of the view? self._manager = State(wrappedValue: EntityCountManager(modelContext)) will not work because the @Environment has not been initialised at that time. Commented Sep 11 at 3:01
  • Why did you observe CoreData notifications? Why not observe ModelContext.didSave instead? You don't even have access to a NSManagedObjectContext instance. Commented Sep 11 at 3:03
  • 100% fair! I didn't even think I was calling CoreData Commented Sep 11 at 3:54

1 Answer 1

0

EntityCountManager should not take a ModelContext in its initialiser. Otherwise you have no way of correctly creating a EntityCountManager in your views. This is incorrect:

struct SomeView: View {
    @State private var manager: EntityCountManager
    @Environment(\.modelContext) var modelContext
    init() {
        // the environment value is not ready yet! You are not passing in the "real" model context
        manager = EntityCountManager(context: modelContext)
    }
}

You should pass in the model context in onAppear. Instead of observing CoreData notifications, you should observe ModelContext.didSave. While you could observe didSave for specifically the given model context, I assume you want everything to be in sync when another model context is saved. In that case you should observe all didSave notifications.

private(set) var total: Int = 0
private var context: ModelContext?
private var cancellable: AnyCancellable?

// call this in .onAppear { ... }
func registerContext(_ modelContext: ModelContext) {
    guard context == nil else { return }
    self.context = modelContext
    self.cancellable = NotificationCenter.default
        .publisher(for: ModelContext.didSave)
        .sink { [weak self] notification in
            // check containers, just in case your app has multiple containers
            guard let context = notification.object as? ModelContext, context.container == modelContext.container else { return }
            // could also check whether deletedIdentifiers and insertedIdentifiers in the userInfo dictionary is empty
            // to avoid unnecessary refreshes
            self?.refresh()
        }
    self.refresh()
}

Also consider using an async approach, getting the notifications as an AsyncSequence:

// call this in .task { ... }
nonisolated(nonsending) func waitForNotifications(_ modelContext: ModelContext) async {
    guard context == nil else { return }
    self.context = modelContext
    self.refresh()
    for await notification in NotificationCenter.default.notifications(named: ModelContext.didSave) {
        guard let context = notification.object as? ModelContext, context.container == modelContext.container else { continue }
        // could also check whether deletedIdentifiers and insertedIdentifiers in the userInfo dictionary is empty
        // to avoid unnecessary refreshes
        self.refresh()
    }
}

Here is an example view demonstrating both approaches:

@Model
class Foo {
    var name = ""
    init() { }
}

struct ContentView: View {
    @State private var managerCombine = EntityCountManager()
    @State private var managerAsync = EntityCountManager()
    @Query var foos: [Foo]
    @Environment(\.modelContext) private var modelContext
    
    @State private var total = 0
    
    var body: some View {
        
        VStack {
            Text("Query Total: \(foos.count)")
            Text("Observed Total (Combine): \(managerCombine.total)")
            Text("Observed Total (Async): \(managerAsync.total)")
            Button("Add") {
                modelContext.insert(Foo())
                try! modelContext.save()
            }
            Button("Remove 1") {
                if let foo = try? modelContext.fetch(FetchDescriptor<Foo>()).first {
                    modelContext.delete(foo)
                    try! modelContext.save()
                }
            }
        }
        .onAppear {
            managerCombine.registerContext(modelContext)
        }
        .task {
            await managerAsync.waitForNotifications(modelContext)
        }
    }
}

Unlike @Query, for this to work, you must save the context every transaction where you inserted/removed models.

Sign up to request clarification or add additional context in comments.

Comments

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.