fuzzyCap
fuzzyCap

Reputation: 421

iCloud on off button in SwiftUI results in a crash

I've added iCloud into my SwiftUI app and everything seems to be working great, however I need to implement an on off toggle for it. After searching, I found a couple forum posts that suggested to re-create the container when icloud is toggled on off. Here's the code:

lazy var persistentContainer: NSPersistentContainer = {
    return setupContainer()
}()

/* This is called when the iCloud setting is turned on and off */
func refreshCoreDataContainer() {
    /* Save changes before reloading */
    try? self.persistentContainer.viewContext.save()
    /* Reload the container */
    self.persistentContainer = self.setupContainer()
}

private func setupContainer() -> NSPersistentContainer {
    let useCloudSync = UserSettings.shared.enableiCloudSync
    let container: NSPersistentContainer!

    /* Use the icloud container if the user enables icloud, otherwise use the regular container */
    if useCloudSync {
        container = NSPersistentCloudKitContainer(name: "App")
    } else {
        container = NSPersistentContainer(name: "App")
        let description = container.persistentStoreDescriptions.first
        description?.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey)
    }
    container.persistentStoreDescriptions.first?.setOption(true as NSNumber, forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey)

    /* Load the data */
    container.loadPersistentStores(completionHandler: { (storeDescription, error) in
        if let error = error as NSError? {
            fatalError("Unresolved error \(error), \(error.userInfo)")
        }
        container.viewContext.automaticallyMergesChangesFromParent = true
    })
    return container
}

The problem is that once I reload the container, the app crashes with this error:

Thread 1: "executeFetchRequest:error: A fetch request must have an entity."
Multiple NSEntityDescriptions claim the NSManagedObject subclass 'App.ColorCollection' so +entity is unable to disambiguate.
'ColorCollection' (0x60f000022000) from NSManagedObjectModel (0x607000067260) claims 'App.ColorCollection'.
`

I think the crash has to do with SwiftUI keeping a reference to the old container. When the window is created it is passing the container to it using the enviroment:

let contentView = MyContentView().environment(\.managedObjectContext, persistentContainer.viewContext)

So I tried to close the window, reload the container, then re create the window below, but the app still crashes.

func refreshCoreDataContainer() {
    windowController.window?.close()

    /* Save changes before continuing */
    try? self.persistentContainer.viewContext.save()
    self.persistentContainer = self.setupContainer()

    self.createAndShowMainWindow()
}

How do I implement a iCloud toggle in SwiftUI without it crashing?

Upvotes: 1

Views: 582

Answers (1)

JLively
JLively

Reputation: 152

SwiftUI 2.0 & IOS 14.3

I'm going through this exact problem right now. Here's the answer I've puzzled together using these two resources:

Using Core Data with SwiftUI 2.0 and Xcode 12

CoreData+CloudKit | On/off iCloud sync toggle

If anyone comes across a better answer please let me know!

final class PersistentContainer {
    
    private static var _model: NSManagedObjectModel?
    
    private static func model(name: String) throws -> NSManagedObjectModel {
        if _model == nil {
            _model = try loadModel(name: name, bundle: Bundle.main)
        }
        return _model!
    }
    
    private static func loadModel(name: String, bundle: Bundle) throws -> NSManagedObjectModel {
        guard let modelURL = bundle.url(forResource: name, withExtension: "momd") else {
            throw CoreDataModelError.modelURLNotFound(forResourceName: name)
        }

        guard let model = NSManagedObjectModel(contentsOf: modelURL) else {
            throw CoreDataModelError.modelLoadingFailed(forURL: modelURL)
        }
        return model
    }

    enum CoreDataModelError: Error {
        case modelURLNotFound(forResourceName: String)
        case modelLoadingFailed(forURL: URL)
    }
 
    public static func getContainer(iCloud: Bool) throws -> NSPersistentContainer {
        let name = "YOUR APP" // Put your model name here
        if iCloud {
            return NSPersistentCloudKitContainer(name: name, managedObjectModel: try model(name: name))
        } else {
            return NSPersistentContainer(name: name, managedObjectModel: try model(name: name))
        }
    }
}


class CoreDataManager {

    static let shared = CoreDataManager()

    lazy var persistentContainer: NSPersistentContainer = {
        setupContainer()
    }()
    
    func refreshCoreDataContainer() {
        try? self.persistentContainer.viewContext.save()
        self.persistentContainer = self.setupContainer()
    }
    
    private func setupContainer() -> NSPersistentContainer {
        
        let iCloud = UserDefaults.standard.bool(forKey: "iCloudOn") // Replace with your UserDefaults boolean here
            
        do {
            let newContainer = try PersistentContainer.getContainer(iCloud: iCloud)
            guard let description = newContainer.persistentStoreDescriptions.first else { fatalError("No description found") }
            
            if iCloud {
                newContainer.viewContext.automaticallyMergesChangesFromParent = true
                newContainer.viewContext.mergePolicy = NSMergeByPropertyStoreTrumpMergePolicy
            } else {
                description.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey)
            }

            description.setOption(true as NSNumber, forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey)

            newContainer.loadPersistentStores { (storeDescription, error) in
                if let error = error as NSError? { fatalError("Unresolved error \(error), \(error.userInfo)") }
            }
            
            return newContainer
            
        } catch {
            print(error)
        }
        
        fatalError("Could not setup Container")
    }
}

Then to use just put this in your App.swift file:

let persistenceController = CoreDataManager.shared

And this within the App.swift var body

.environment(\.managedObjectContext, persistenceController.persistentContainer.viewContext)

To use put this on your toggle

.onChange(of: toggleVariable) { value in
    CoreDataManager.shared.refreshCoreDataContainer()
}

Upvotes: 1

Related Questions