DEV Community

Kurt Frey
Kurt Frey

Posted on

Adding an in-app CloudKit sync toggle to your CoreData App

Adding iCloud sync to your CoreData is fairly easy. Either check the right boxes when you create a project or do some work to transition your app from a local CoreData App to a CoreData App with CloudKit sync:

  1. Replace NSPersistentContainer in PersistenceController with NSPersistentCloudKitContainer.
  2. Add container.viewContext.automaticallyMergesChangesFromParent = true in PersistenceController.init() as the last line.
  3. ⁣In "Signing & Capabilities" enable for each target:
    1. Background Modes: Remote Notifications
    2. iCloud: CloudKit (and add iCloud Identifiers for your app (create a new one with + if needed) - you could also use an existing one and share data between apps)

That’s it. Your app now syncs all CoreData entities via iCloud. If your app is not too complicated, even adding new entities is easy as pie and does not require you to do anything special (changing entities is a whole different story though). CloudKit specific limitations apply of course.

So, what’s the point of this blog post? Well. Let’s say you have an app which stores data some people might not want to sync via iCloud. But they don’t want to disable iCloud for the whole device. They need an in-app switch.

Apple has a whole support article about settings.

Switching CloudKit Sync on and off should not be done lightheartedly. You want to explain to the user what they are about to do and give them an option to abort once they tapped the on-off-slider. A settings interface in the System Settings App can’t do that. So I decided to use a sheet in the app with all the settings. That way I was also able to display important information about the current state of the on-off-switching. Why? You’ll see in a second.

Here’s the flow:

  1. the user opens the settings sheet
  2. the user toggles sync on (or off) via a relay function which flips an @AppStorage and allows the user to cancel (step 3)
  3. an alert appears and gives the user the option to abort
  4. once the user proceeds, the app toggles sync on (or off)
  5. the app informs the user right where they just tapped „on“ (or „off“) about the status of the switching
  6. the user restarts the app
  7. the status of the switching is updated

The Code

First things first: Here’s a snippet of what the toggle switch and the accompanying informative labels in the settings sheet can look like.

import SwiftUI

struct SettingsView: View {
    @AppStorage("iCloudSyncEnabled") var iCloudSyncEnabled: Bool = false

    @AppStorage("pendingPurge") var pendingCloudPurge: Bool = false

    @State private var isShowingSyncAlert = false

    private let syncActiveAtLaunch = PersistenceController.shared.isCloudSyncActive

    private func applySync(_ enable: Bool) {
        iCloudSyncEnabled = enable
        pendingCloudPurge = !enable
    }

    var body: some View {
        Form {
            Toggle("settings.iCloudSync.label", isOn: Binding(
                get: { iCloudSyncEnabled },
                set: { _ in isShowingSyncAlert = true }
            ))
            .alert(
                iCloudSyncEnabled ? Text("settings.iCloudSync.confirmOff.title")
                                  : Text("settings.iCloudSync.confirmOn.title"),
                isPresented: $isShowingSyncAlert
            ) {
                if iCloudSyncEnabled {
                    Button("settings.iCloudSync.confirmOff.button", role: .destructive) { applySync(false) }
                } else {
                    Button("settings.iCloudSync.confirmOn.button") { applySync(true) }
                }
                Button(role: .cancel) { }
            } message: {
                Text(iCloudSyncEnabled ? "settings.iCloudSync.confirmOff.message"
                                       : "settings.iCloudSync.confirmOn.message")
            }

            // Show informative labels about the current status of the switch
            if iCloudSyncEnabled != syncActiveAtLaunch {
                Label("settings.iCloudSync.restartRequired", systemImage: "arrow.clockwise")
                    .foregroundStyle(.orange)
            }

            if pendingCloudPurge && !iCloudSyncEnabled {
                Label("settings.iCloudSync.pendingPurge", systemImage: "icloud.slash")
                    .foregroundStyle(.secondary)
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

That’s a lot... Let’s go over it.

  1. We load the needed @AppStorage into the view.
  2. We add a @State that holds the alert state: isShowingSyncAlert
  3. We store the state of the CloudKit sync at launch in syncActiveAtLaunch (mainly for UX purposes to show if user action is still required)
  4. We have a function (applySync(_ enable: Bool)) that sets the @AppStorage values programmatically.

In the body we have the Toggle that does all the heavy lifting. Well, actually the attached alert does:

  1. The isOn is a Binding that is computed in place. It reads the current state of the @AppStorage in get and in set does not set self but isShowingSyncAlert.
  2. The title of the alert reflects the „direction“ of the switch.
  3. The buttons of the alert switch sync on and off with applySync(_ enable: Bool) or abort the whole endeavor.
  4. The message of the alert also reflects the „direction“ of the switch.

Now, toggling that doesn’t actually change things immediately. Setting the @AppStorage here would prevent us from showing a cancelable alert. So we use a relay function that changes the @AppStorage once the user confirmed.

Using the UserDefaults to switch sync

We use the UserDefaults in three places: PersistenceController, the @main struct that defines the App and a custom enum CloudSync.

PersistenceController

struct PersistenceController {
    static let shared = PersistenceController()
    let container: NSPersistentCloudKitContainer

    var isCloudSyncActive: Bool {
        container.persistentStoreDescriptions.first?.cloudKitContainerOptions != nil
    }

    init(inMemory: Bool = false) {
        container = NSPersistentCloudKitContainer(name: "myApp")
        let description = container.persistentStoreDescriptions.first!

        if inMemory {
            description.url = URL(fileURLWithPath: "/dev/null")
        }

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

        if inMemory || !UserDefaults.standard.bool(forKey: "iCloudSyncEnabled") {
            description.cloudKitContainerOptions = nil
        }

        container.loadPersistentStores(completionHandler: { (storeDescription, error) in
            if let error = error as NSError? {
                fatalError("Unresolved error \(error), \(error.userInfo)")
            }
            if !inMemory, let url = storeDescription.url {
                Self.excludeFromBackup(url, syncActive: storeDescription.cloudKitContainerOptions != nil)
            }
        })
        container.viewContext.automaticallyMergesChangesFromParent = true
    }

    private static func excludeFromBackup(_ storeURL: URL, syncActive: Bool) {
        for suffix in ["", "-wal", "-shm"] {
            var url = URL(fileURLWithPath: storeURL.path + suffix)
            guard FileManager.default.fileExists(atPath: url.path) else { continue }
            var values = URLResourceValues()
            values.isExcludedFromBackup = !syncActive
            try? url.setResourceValues(values)
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Our PersistenceController has a property called isCloudSyncActive that the toggle from above uses.

It also sets description.cloudKitContainerOptions to nil. That disables sync. The NSPersistentCloudKitContainer can stay.

Just to be thorough: PersistenceController excludes the files that are created by CoreData also from any (iCloud, Finder, etc.) backup (if CloudKit sync is disabled) a user might have enabled. Older backups can’t be changed.

Not thorough enough? Migrate the store to a custom folder. Or... Or use an app group and exclude the folder there. This also gives you the ability to use the CoreData data in a widget.

That’s it. The rest of PersistenceController is the same as any other CloudKit enabled CoreData app.

@main App

@main
struct MyApp: App {
    let persistenceController: PersistenceController

    init () {
        persistenceController = PersistenceController.shared
    }

    var body: some Scene {
        WindowGroup {
            RootView()
            .environment(\.managedObjectContext, persistenceController.container.viewContext)
            .task {
                await CloudSync.purgeIfNeeded()
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

The main App adds a task to the root view. That task purges data from iCloud (that’s where CloudKit syncs your CoreData data to). However, if another device of the same account still has CloudKit enabled, it may re-sync it’s data to iCloud and only purge (again) when sync is disabled there too.

enum CloudSync

import CloudKit

enum CloudSync {
    // Must match the iCloud container in the entitlement.
    static let containerIdentifier = "iCloud.com.your.app"

    // Zone that NSPersistentCloudKitContainer uses in the private database.
    private static let zoneID = CKRecordZone.ID(
        zoneName: "com.apple.coredata.cloudkit.zone",
        ownerName: CKCurrentUserDefaultName
    )

    // Deletes this app's iCloud data after sync was switched off.
    // Keeps the flag on failure, so the next app start tries again.
    static func purgeIfNeeded() async {
        let defaults = UserDefaults.standard
        guard defaults.bool(forKey: "pendingPurge"),
              !defaults.bool(forKey: "iCloudSyncEnabled") else { return }

        let container = CKContainer(identifier: containerIdentifier)
        do {
            guard try await container.accountStatus() == .available else { return }
            try await container.privateCloudDatabase.deleteRecordZone(withID: zoneID)
            defaults.set(false, forKey: "pendingPurge")
        } catch let error as CKError where error.code == .zoneNotFound {
            defaults.set(false, forKey: "pendingPurge")
        } catch {
            return
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

This enum handles purging the data from iCloud. We don’t want to keep old data in the cloud. Does it remove possible backups Apple stores somewhere in its datacenter apart from the user’s iCloud settings? I guess not.

Wrap up

Enabling CloudKit sync for CoreData is easy. Enabling the user to switch it on and off is not. Well, maybe now it is. Don’t forget the basics of CloudKit and CoreData (like deploying or fields that cannot change name like they can locally with lightweight migration).

Top comments (0)