SwiftData, meet iCloud

SwiftData has similar superpowers to NSPersistentCloudKitContainer, infact i'm pretty sure its just wrapping that, so lets get to know them.

If you missed it, i've got an intro to Swift Data here, which walks you through the basics.

Get your head in the clouds

CloudKit has been easy for a while, and now, its even easier. It takes just a few steps to get started syncing to the cloud.

Make sure you have the iCloud entitlements enabled, and double check CloudKit is ticked. You'll have to have a container setup too.

Turn on background modes & remote notifications whilst you're here, or your container won't load.

Once you enable all these, CloudKit sync will just work. It detects it from your enitlements, and uses it.

For some more explicit control, you can explicitly provide the container identifier to your ModelConfiguration.

We've seen this code before, but here it is again just in-case.

@main
struct Brew_BookApp: App {
    let container: ModelContainer = {
        // Don't force unwrap for real šŸ‘€
        try! ModelContainer(
            for: [Brewer.self, Brew.self],
            .init(
                cloudKitContainerIdentifier: "icloud.uk.co.alexanderlogan.samples.Brew-Book"
            )
        )
    }()

    var body: some Scene {
        WindowGroup {
            ContentView()
        }
        .modelContainer(container)
    }
}
@main
struct Brew_BookApp: App {
    let container: ModelContainer = {
        // Don't force unwrap for real šŸ‘€
        try! ModelContainer(
            for: [Brewer.self, Brew.self],
            .init(
                cloudKitContainerIdentifier: "icloud.uk.co.alexanderlogan.samples.Brew-Book"
            )
        )
    }()

    var body: some Scene {
        WindowGroup {
            ContentView()
        }
        .modelContainer(container)
    }
}

Fixing our models

If you enable this, there's a high chance your app will suddenly break. Your container may fail to load, and your console will be full of errors.

If you haven't run this for yourself, i've added one here - they're super useful!

error: Store failed to load.  <NSPersistentStoreDescription: 0x600000c5d6b0> (type: SQLite, url: file:///Users/alex/Library/Developer/CoreSimulator/Devices/95D42E8D-CA19-481B-9150-ACD1642F8194/data/Containers/Data/Application/6564C0E2-2907-490F-A5E0-E8ED505C705B/Library/Application%20Support/default.store) with error = Error Domain=NSCocoaErrorDomain Code=134060 "A Core Data error occurred." UserInfo={NSLocalizedFailureReason=CloudKit integration requires that all attributes be optional, or have a default value set. The following attributes are marked non-optional but do not have a default value:
Brew: brewDate
Brew: brewIdentifier
Brew: rating
Brew: type
CloudKit integration requires that all relationships be optional, the following are not:
Brewer: brews
CloudKit integration does not support unique constraints. The following entities are constrained:
Brew: brewIdentifier} with userInfo {
    NSLocalizedFailureReason = "CloudKit integration requires that all attributes be optional, or have a default value set. The following attributes are marked non-optional but do not have a default value:\nBrew: brewDate\nBrew: brewIdentifier\nBrew: rating\nBrew: type\nCloudKit integration requires that all relationships be optional, the following are not:\nBrewer: brews\nCloudKit integration does not support unique constraints. The following entities are constrained:\nBrew: brewIdentifier";
}
error: Store failed to load.  <NSPersistentStoreDescription: 0x600000c5d6b0> (type: SQLite, url: file:///Users/alex/Library/Developer/CoreSimulator/Devices/95D42E8D-CA19-481B-9150-ACD1642F8194/data/Containers/Data/Application/6564C0E2-2907-490F-A5E0-E8ED505C705B/Library/Application%20Support/default.store) with error = Error Domain=NSCocoaErrorDomain Code=134060 "A Core Data error occurred." UserInfo={NSLocalizedFailureReason=CloudKit integration requires that all attributes be optional, or have a default value set. The following attributes are marked non-optional but do not have a default value:
Brew: brewDate
Brew: brewIdentifier
Brew: rating
Brew: type
CloudKit integration requires that all relationships be optional, the following are not:
Brewer: brews
CloudKit integration does not support unique constraints. The following entities are constrained:
Brew: brewIdentifier} with userInfo {
    NSLocalizedFailureReason = "CloudKit integration requires that all attributes be optional, or have a default value set. The following attributes are marked non-optional but do not have a default value:\nBrew: brewDate\nBrew: brewIdentifier\nBrew: rating\nBrew: type\nCloudKit integration requires that all relationships be optional, the following are not:\nBrewer: brews\nCloudKit integration does not support unique constraints. The following entities are constrained:\nBrew: brewIdentifier";
}

Just like with NSPersistentCloudkitContainer, we have to make some data model changes to have everything work in the cloud.

The primary things to be aware of are that unique constraints are not supported, relationships must be optional ( even if you default them to empty arrays), and all values must have a default.

Lets walk through taking a model that already exists, and making it CloudKit friendly.

Our first model, Brewer, just needs to have default values set, and the relationship set to optional.

// Before
@Model final class Brewer {
    var name: String

    @Relationship(.cascade, inverse: \Brew.brewer)
    var brews: [Brew]

    init(name: String) {
        self.name = name
    }
}
// Before
@Model final class Brewer {
    var name: String

    @Relationship(.cascade, inverse: \Brew.brewer)
    var brews: [Brew]

    init(name: String) {
        self.name = name
    }
}
// After
@Model final class Brewer {
    var name: String = ""

    @Relationship(.cascade, inverse: \Brew.brewer)
    var brews: [Brew]? = []

    init(name: String) {
        self.name = name
    }
}
// After
@Model final class Brewer {
    var name: String = ""

    @Relationship(.cascade, inverse: \Brew.brewer)
    var brews: [Brew]? = []

    init(name: String) {
        self.name = name
    }
}

Next, lets fix our Brew model.

This one is a little more complicated, as we had a unique parameter, which has to go.

// Before
@Model
final class Brew {
    @Attribute(.unique) var brewIdentifier: UUID
    var type: BrewType.RawValue
    var rating: Int
    var brewDate: Date

    var brewer: Brewer?

    init(
        brewIdentifier: UUID = .init(),
        type: BrewType,
        rating: Int,
        brewDate: Date

    ) {
        self.brewIdentifier = brewIdentifier
        self.type = type.rawValue
        self.rating = rating
        self.brewDate = brewDate
    }
}
// Before
@Model
final class Brew {
    @Attribute(.unique) var brewIdentifier: UUID
    var type: BrewType.RawValue
    var rating: Int
    var brewDate: Date

    var brewer: Brewer?

    init(
        brewIdentifier: UUID = .init(),
        type: BrewType,
        rating: Int,
        brewDate: Date

    ) {
        self.brewIdentifier = brewIdentifier
        self.type = type.rawValue
        self.rating = rating
        self.brewDate = brewDate
    }
}
// After
@Model
final class Brew {
    var brewIdentifier: UUID = .init()
    var type: BrewType.RawValue = BrewType.espresso.rawValue
    var rating: Int = 5
    var brewDate: Date = Date()

    var brewer: Brewer? = nil

    init(
        brewIdentifier: UUID = .init(),
        type: BrewType,
        rating: Int,
        brewDate: Date

    ) {
        self.brewIdentifier = brewIdentifier
        self.type = type.rawValue
        self.rating = rating
        self.brewDate = brewDate
    }
}
// After
@Model
final class Brew {
    var brewIdentifier: UUID = .init()
    var type: BrewType.RawValue = BrewType.espresso.rawValue
    var rating: Int = 5
    var brewDate: Date = Date()

    var brewer: Brewer? = nil

    init(
        brewIdentifier: UUID = .init(),
        type: BrewType,
        rating: Int,
        brewDate: Date

    ) {
        self.brewIdentifier = brewIdentifier
        self.type = type.rawValue
        self.rating = rating
        self.brewDate = brewDate
    }
}

If you now run your app on a device where you're signed in, save a couple things, delete the app, re-install it, your content will come back!

Live Updates

You'll notice when you initially open the app, even with content in the cloud, nothing shows up. This is because @Query doesn't update itself when installed on a view and it gets a push from the web - but there's a neat trick to get around it.

We can actually use an underlying NSPersistentCloudKitContainer.eventChangedNotification to detect when things have updated as a result of CloudKit, and force our query to update.

Lets add this functionality to our list of brewers.

.onReceive(NotificationCenter.default.publisher(
    for: NSPersistentCloudKitContainer.eventChangedNotification
)) { notification in
    guard let event = notification.userInfo?[NSPersistentCloudKitContainer.eventNotificationUserInfoKey] as? NSPersistentCloudKitContainer.Event else {
        return
    }
    if event.endDate != nil && event.type == .import {
        // TODO
    }
}
.onReceive(NotificationCenter.default.publisher(
    for: NSPersistentCloudKitContainer.eventChangedNotification
)) { notification in
    guard let event = notification.userInfo?[NSPersistentCloudKitContainer.eventNotificationUserInfoKey] as? NSPersistentCloudKitContainer.Event else {
        return
    }
    if event.endDate != nil && event.type == .import {
        // TODO
    }
}

Here, we're listening for the eventChangedNotification, checking that the event type is import ( data coming down ), and that we have an end data indicating its finished.

Now, for the trick.

When this notification fires, our data has downloaded, and it's good to go, yet our query doesn't update.

We can trick it into updating, by firing off a manual fetch.

.onReceive(NotificationCenter.default.publisher(
    for: NSPersistentCloudKitContainer.eventChangedNotification
)) { notification in
    guard let event = notification.userInfo?[NSPersistentCloudKitContainer.eventNotificationUserInfoKey] as? NSPersistentCloudKitContainer.Event else {
        return
    }
    if event.endDate != nil && event.type == .import {
        Task { @MainActor in
            let brewersFetchDescriptor = FetchDescriptor<Brewer>(
                predicate: nil,
                sortBy: [.init(\.name)]
            )
            _ = try? context.fetch(brewersFetchDescriptor)
        }
    }
}
.onReceive(NotificationCenter.default.publisher(
    for: NSPersistentCloudKitContainer.eventChangedNotification
)) { notification in
    guard let event = notification.userInfo?[NSPersistentCloudKitContainer.eventNotificationUserInfoKey] as? NSPersistentCloudKitContainer.Event else {
        return
    }
    if event.endDate != nil && event.type == .import {
        Task { @MainActor in
            let brewersFetchDescriptor = FetchDescriptor<Brewer>(
                predicate: nil,
                sortBy: [.init(\.name)]
            )
            _ = try? context.fetch(brewersFetchDescriptor)
        }
    }
}

When this fetch is fired, it tricks the query into updating too.

Now, when you run the app from a fresh install, your content will fetch itself from the web and update as expected, as it will for further updates.


The sample for both this version of the data store, and the local only one, can be found on Github.

If you fancy reading a little more, or sharing your experiences, Iā€™m @SwiftyAlex on twitter.