Using CloudKit + CoreData, how to update UI from remote cloud update without SwiftUI's @FetchRequest?

Most CloudKit+CoreData tutorials use SwiftUI, and their implementation includes @FetchRequest which automatically detects changes in the CoreData fetch and refreshes the UI.

https://www.hackingwithswift.com/quick-start/swiftui/what-is-the-fetchrequest-property-wrapper

How would I achieve this without SwiftUI? I want to be able to control how I refresh the UI, in response to detecting the CoreData changing due to an iCloud update.

I have this to set up the NSPersistentCloudKitContainer and register for remote notifications:

let storeDescription = NSPersistentStoreDescription()
    storeDescription.setOption(true as NSNumber, forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey)

    let container = NSPersistentCloudKitContainer(name: "CoreDataDemo")
    container.persistentStoreDescriptions = [storeDescription]
    
    container.loadPersistentStores(completionHandler: { (storeDescription, error) in
        if let error = error as NSError? {
            
            fatalError("Unresolved error \(error), \(error.userInfo)")
        }
    })
    
    container.viewContext.automaticallyMergesChangesFromParent = true
    container.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
    
    NotificationCenter.default.addObserver(self, selector: #selector(didReceiveCloudUpdate), name: .NSPersistentStoreRemoteChange, object: container.persistentStoreCoordinator)

However I do not know how to handle .NSPersistentStoreRemoteChange the same way the SwiftUI implementation automatically does it. The method is called very frequently from many different threads (many times on startup alone).


Solution 1:

Here is a complete working example that updates the UI when something changes in CloudKit using CoreData + CloudKit + MVVM. The code related to the notifications is marked with comments, see CoreDataManager and SwiftUI files. Don't forget to add the proper Capabilities in Xcode, see the image below.

Persistence/Data Manager

import CoreData
import SwiftUI

class CoreDataManager{
    
    static let instance = CoreDataManager()
    let container: NSPersistentCloudKitContainer
    
    let context: NSManagedObjectContext

    init(){
        container = NSPersistentCloudKitContainer(name: "CoreDataContainer")
        
       
        guard let description = container.persistentStoreDescriptions.first else{
            fatalError("###\(#function): Failed to retrieve a persistent store description.")
        }
        
        description.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey)

        // Generate NOTIFICATIONS on remote changes
        description.setOption(true as NSNumber, forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey)

        container.loadPersistentStores { (description, error) in
            if let error = error{
                print("Error loading Core Data. \(error)")
            }
        }
        container.viewContext.automaticallyMergesChangesFromParent = true
        container.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy

        context = container.viewContext
    }
    
    func save(){
        do{
            try context.save()
            print("Saved successfully!")
        }catch let error{
            print("Error saving Core Data. \(error.localizedDescription)")
        }
    }
}

View Model

import CoreData

class CarViewModel: ObservableObject{
    let manager = CoreDataManager.instance
    @Published var cars: [Car] = []
    
    init(){
        getCars()
    }

    func addCar(model:String, make:String?){
        let car = Car(context: manager.context)
        car.make = make
        car.model = model

        save()
        getCars()
    }
    
    func getCars(){
        let request = NSFetchRequest<Car>(entityName: "Car")
        
        let sort = NSSortDescriptor(keyPath: \Car.model, ascending: true)
        request.sortDescriptors = [sort]

        do{
            cars =  try manager.context.fetch(request)
        }catch let error{
            print("Error fetching cars. \(error.localizedDescription)")
        }
    }
    
    func deleteCar(car: Car){
        manager.context.delete(car)
        save()
        getCars()
    }

    func save(){
        self.manager.save()
    }
}

SwiftUI

import SwiftUI
import CoreData

struct ContentView: View {
    @StateObject var carViewModel = CarViewModel()
    
    @State private var makeInput:String = ""
    @State private var modelInput:String = ""
    
    // Capture NOTIFICATION changes
    var didRemoteChange = NotificationCenter.default.publisher(for: .NSPersistentStoreRemoteChange).receive(on: RunLoop.main)

    @State private var deleteCar: Car?
    
    var body: some View {
        NavigationView {
            VStack{
                List {
                    if carViewModel.cars.isEmpty {
                        Text("No cars")
                            .foregroundColor(.gray)
                            .fontWeight(.light)
                    }
                    ForEach(carViewModel.cars) { car in
                        HStack{
                            Text(car.model ?? "Model")
                            Text(car.make ?? "Make")
                                .foregroundColor(Color(UIColor.systemGray2))
                        }
                        .swipeActions{
                            Button( role: .destructive){
                                carViewModel.deleteCar(car: car)
                            }label:{
                                Label("Delete", systemImage: "trash.fill")
                            }
                        }
                    }

                }
                // Do something on NOTIFICATION
                .onReceive(self.didRemoteChange){ _ in
                    carViewModel.getCars()
                }

                Spacer()
                Form {
                    TextField("Make", text:$makeInput)
                    TextField("Model", text:$modelInput)
                }
                .frame( height: 200)
                
                Button{
                    saveNewCar()
                    makeInput = ""
                    modelInput = ""
                }label: {
                    Image(systemName: "car")
                    Text("Add Car")
                }
                .padding(.bottom)
            }
        }
    }
    
    func saveNewCar(){
        if !modelInput.isEmpty{
            carViewModel.addCar(model: modelInput, make: makeInput.isEmpty ? nil : makeInput)
        }
    }
}

Core Data Container

ENTITIES

Car

Attributes

make String
model String

Xcode/CloudKit setup

enter image description here

Thanks to Didier B. from this thread.