Home iOS & Swift Tutorials

Sharing Core Data With CloudKit in SwiftUI

Learn to share data between CoreData and CloudKit in a SwiftUI app.

Version

  • Swift 5.5, iOS 15, Xcode 13

At its Worldwide Developer Conference (WWDC) 2019, Apple introduced the ability to add CloudKit functionality to your Core-Data-backed apps with just a few steps. In theory, you needed only three steps. First, update your container type to NSPersistentCloudKitContainer. Second, enable the iCloud capability in your app. Third, create an iCloud container that hosts your data in iCloud.

Once you’ve completed these steps, the data in your app then “automagically” sync with iCloud. Amazing! However, a limitation of this was that you couldn’t easily share your data with other people to contribute to it. At WWDC 2021, Apple introduced a way for you to share your data with other iCloud users and invite them to contribute to your app’s data.

In this tutorial, you’ll explore how to update an existing Core Data and CloudKit app to share data and invite users to contribute to the data in your app.

Note: You’ll need the following prerequisites to complete this tutorial.
  • A paid developer account — To use CloudKit.
  • Two separate iCloud accounts — To initiate the sharing process.
  • At least one real device – To send and accept share invitation(s). Also, to change the sharing permissions because it doesn’t work properly on a simulator.

Getting Started

Download the starter project by clicking the Download Materials button at the top or bottom of this tutorial. Then, open the starter project. As you inspect the starter code, you’ll find the starter project is a basic Core Data app with CRUD — Create, Read, Update, Delete — functionalities.

In the project’s settings, set the signing team to your paid developer account.

Build and run. You see a travel journal screen with an empty state message and a button.

App screen without records

Tap the Add Destination button. This takes you to a screen to add a recent destination you visited. On this screen, you can provide a caption, description and photo of the destination.

Add Destination screen

Once you add a destination, you’ll see it on the main screen like below.

Destination added in a simulator

To delete the destination, swipe left to reveal the delete button. Tapping this button removes a destination from your travel journal.

To edit or update the caption or description, tap the destination cell to see the detail screen. From this screen, you can take a few actions.

At the top right is the edit button, which presents a modal to edit your destination’s caption and description. There’s also a share action that does nothing now. You’ll build this action in this tutorial.

Types of CloudKit Databases

Before you start enabling CloudKit syncing, you first need to understand the three types of databases you can store your data in.

  • Public: Data stored here is public, and every user, whether they sign in to the iCloud account or not, can read it. You’ll store data in this database for this tutorial.
  • Private: Data stored here is private data associated with the currently signed-in user.
  • Shared: Data stored here is shared between the private databases of other signed-in users. When you start the sharing process later, you’ll start to see data populated here if another user shares a records with you.

Enabling CloudKit Syncing

The next step of preparing your shared data is to enable storing your data in iCloud. When you create a destination, the data is being persisted via Core Data locally in your app.

In the Signing & Capabilities section, add the iCloud capability. You’ll need to ensure that you have set a unique bundle identifier at this point. Then, select the CloudKit checkbox to enable the feature.

iCloud capability added and CloudKit enabled

The next step is to create the container your data will live in. In the iCloud section, tap the + button underneath Containers to add a custom container. In the window that comes up, enter your container’s name. A general guideline is to use com.company_name.bundle_identifier. Xcode prefixes the container name with iCloud.

Add a new container for iCloud container

The final step is to add the Background Modes capability and enable Remote Notifications. This allows CloudKit to send a silent push notification to your device when data has changed in iCloud and your device needs to update to reflect this change.

Remote notifications added in Capabilities

Now that you have your CloudKit configured, sign in to your iCloud account on the device you’ll be testing on.

Launch a simulator. From the home screen, open Settings. Sign in to your Apple ID.

Build and run. Add a destination.

Add Destination animated gif

Last, head to the CloudKit Console so you can verify your data.

CloudKit Console main page

CloudKit Console Dashboard

When storing data via CloudKit, CloudKit Console allows you to interact with the data in question and perform several other functions such as viewing logs. After you have logged into the console, open CloudKit Database.

Once you’re in this section, you need to specify the container you want to see. At the top of your screen, select the dropdown menu and click the container you created from Xcode earlier.

Below the Data section, click Records. Select Private Database. This is the default database that data gets written to.

Select Private Database in CloudKit Console

If you attempt to select record type as CD_Destination and query records from here, you receive an error stating Field recordName isn’t marked queryable. Now, you’ll resolve this error.

Under the Schema section, select Indexes. Select CD_Destination. This is your Destination entity in Core Data. CloudKit prefixes your entities with CD to distinguish them from traditional CloudKit records.

Click Add Basic Index. Select recordName from the list and ensure the index type is Queryable. Save the changes.

Add a basic index in the CloudKit Console

Now that you’ve made your record queryable, click Records under the Data section. Select Private Database. Specify record type as CD_Destination. Update the selected zone from defaultZone to the automatically generated com.apple.coredata.cloudkit.zone.

Click Query Records to see a listing of the record(s) you created in the app earlier! The most amazing part is that because your data is now synced in iCloud, you can run your app on a completely different device that’s signed in to the same iCloud account and see all your data!

Viewing records in the CloudKit Dashboard

Updating NSPersistentCloudKitContainer to Prepare for Share

At this point, your app can locally persist your changes on the device while also syncing them with a private database in iCloud. However, to allow other users to interact with this data, you need to update your NSPersistentCloudKitContainer. Open CoreDataStack.swift. The class contains all the necessary methods and properties you need to interact with Core Data. To begin the sharing process, add the following code to your persistentContainer below the // TODO: 1 comment:

let sharedStoreURL = storesURL?.appendingPathComponent("shared.sqlite")
guard let sharedStoreDescription = privateStoreDescription
  .copy() as? NSPersistentStoreDescription else {
  fatalError(
    "Copying the private store description returned an unexpected value."
  )
}
sharedStoreDescription.url = sharedStoreURL

This code configures the shared database to store records shared with you. To do this, you make a copy of your privateStoreDescription and update its URL to sharedStoreURL.

Next, add the following code under the // TODO: 2 comment:

guard let containerIdentifier = privateStoreDescription
  .cloudKitContainerOptions?.containerIdentifier else {
  fatalError("Unable to get containerIdentifier")
}
let sharedStoreOptions = NSPersistentCloudKitContainerOptions(
  containerIdentifier: containerIdentifier
)
sharedStoreOptions.databaseScope = .shared
sharedStoreDescription.cloudKitContainerOptions = sharedStoreOptions

This code creates NSPersistentContainerCloudKitContainerOptions, using the identifier from your private store description. In addition to this, you set databaseScope to .shared. The final step is to set the cloudKitContainerOptions property for the sharedStoreDescription you created.

Next, add the following code below the // TODO: 3 comment:

container.persistentStoreDescriptions.append(sharedStoreDescription)

This code adds your shared NSPersistentStoreDescription to the container.

Last and under the // TODO: 4, replace:

container.loadPersistentStores { _, error in
  if let error = error as NSError? {
    fatalError("Failed to load persistent stores: \(error)")
  }
}

With the following:

container.loadPersistentStores { loadedStoreDescription, error in
  if let error = error as NSError? {
    fatalError("Failed to load persistent stores: \(error)")
  } else if let cloudKitContainerOptions = loadedStoreDescription
    .cloudKitContainerOptions {
    guard let loadedStoreDescritionURL = loadedStoreDescription.url else {
      return
    }
    if cloudKitContainerOptions.databaseScope == .private {
      let privateStore = container.persistentStoreCoordinator
        .persistentStore(for: loadedStoreDescritionURL)
      self._privatePersistentStore = privateStore
    } else if cloudKitContainerOptions.databaseScope == .shared {
      let sharedStore = container.persistentStoreCoordinator
        .persistentStore(for: loadedStoreDescritionURL)
      self._sharedPersistentStore = sharedStore
    }
  }
}

The code above stores a reference to each store when it’s loaded. It checks databaseScope and determines whether it’s private or shared. Then, it sets the persistent store based on the scope.

Presenting UICloudSharingController

The UICloudSharingController is a view controller that presents standard screens for adding and removing people from a CloudKit share record. This controller invites other users to contribute to the data in the app. There’s just one catch: This controller is a UIKit controller, and your app is SwiftUI.

The solution is in CloudSharingController.swift. CloudSharingView conforms to the protocol UIViewControllerRepresentable and wraps the UIKit UICloudSharingController so you can use it in SwiftUI. The CloudSharingView has three properties:

  • CKShare: The record type you use for sharing.
  • CKContainer: The container that stores your private, shared or public databases.
  • Destination: The entity that contains the data you’re sharing.

In makeUIViewController(context:), the following actions occur. It:

  1. Configures the title of the share. When UICloudSharingController presents to the user, they must have some context of the shared data. In this scenario, you use the caption of the destination.
  2. Creates a UICloudSharingController using the share and container properties. The presentation style is set to .formSheet and delegate is set using the CloudSharingCoordinator. This is responsible for conforming to the UICloudSharingControllerDelegate. This delegate contains the convenience methods that notify you when certain actions happen with the share, such as errors and sharing status. Now that you’re aware of how CloudSharingView works, it’s time to connect it to your share button.

Now, open DestinationDetailView.swift. This view contains the logic for your share button. The first step is to create a method that prepares your shared data. To achieve this, iOS 15 introduces share(_:to:). Add the following code to the extension block:

private func createShare(_ destination: Destination) async {
  do {
    let (_, share, _) = 
    try await stack.persistentContainer.share([destination], to: nil)
    share[CKShare.SystemFieldKey.title] = destination.caption
    self.share = share
  } catch {
    print("Failed to create share")
  }
}

The code above calls the async version of share(_:to:) to share the destination you’ve selected. If there’s no error, the title of the share is set. From here, you store a reference to CKShare that returns from the share method. You’ll use the default share when you present the CloudSharingView.

Now that you have the method to perform the share, you need to present the CloudSharingView when you tap the Share button. Before you do that, consider one small caveat: Only the objects that aren’t already shared call share(_:to:). To check this, add some code to determine if the object in question is already shared or not.

Back in CoreDataStack.swift, add the following extension:

extension CoreDataStack {
  private func isShared(objectID: NSManagedObjectID) -> Bool {
    var isShared = false
    if let persistentStore = objectID.persistentStore {
      if persistentStore == sharedPersistentStore {
        isShared = true
      } else {
        let container = persistentContainer
        do {
          let shares = try container.fetchShares(matching: [objectID])
          if shares.first != nil {
            isShared = true
          }
        } catch {
          print("Failed to fetch share for \(objectID): \(error)")
        }
      }
    }
    return isShared
  }
}

This extension contains the code related to sharing. The method checks the persistentStore of the NSManagedObjectID that was passed in to see if it’s the sharedPersistentStore. If it is, then this object is already shared. Otherwise, use fetchShares(matching:) to see if you have objects matching the objectID in question. If a match returns, this object is already shared. Generally speaking, you’ll be working with an NSManagedObject from your view.

Add the following method to your extension:

func isShared(object: NSManagedObject) -> Bool {
  isShared(objectID: object.objectID)
}

With this code in place, you can determine if the destination is already shared and then take the proper action.

Add the following code to CoreDataStack:

var ckContainer: CKContainer {
  let storeDescription = persistentContainer.persistentStoreDescriptions.first
  guard let identifier = storeDescription?
    .cloudKitContainerOptions?.containerIdentifier else {
    fatalError("Unable to get container identifier")
  }
  return CKContainer(identifier: identifier)
}

Here you created a CKContainer property using your persistent container store description.

As you prepare to present your CloudSharingView, you need this property because the second parameter of CloudSharingView is a CKContainer.

With this code in place, navigate back to DestinationDetailView.swift to present CloudSharingView. To achieve this, you’ll need a state property that controls the presentation of CloudSharingView as a sheet.

First, add the following property to DestinationDetailView:

@State private var showShareSheet = false

Second, you need to add a sheet modifier to the List to present CloudSharingView. Add the following code, just above the existing sheet modifier:

.sheet(isPresented: $showShareSheet, content: {
  if let share = share {
    CloudSharingView(
      share: share, 
      container: stack.ckContainer, 
      destination: destination
    )
  }
})

This code uses showShareSheet to present the CloudSharingView, when the Boolean is true. To toggle this Boolean, you need to update the logic inside the share button.

Repace:

print("Share button tapped")

With:

if !stack.isShared(object: destination) {
  Task {
    await createShare(destination)
  }
}
showShareSheet = true 

This logic first checks to see whether the object is shared. If it’s not shared, create the share from the destination object. Once you’ve completed that task, set showShareSheet, which presents CloudSharingView, to true. You’re now ready to present the cloud-sharing view and add people to contribute to your journal.

Log in to your iCloud account on a real device. Build and run. Add a destination. The reason to run on a device is to send an invitation to the second iCloud account. The most common options are via email or text message.

Once you add the destination, tap the destination to view DestinationDetailView. From here, tap the Share button in the top-right corner. Select your desired delivery method and send the invitation.

Add People in CloudKitSharingController

Note: You can also share invitations using a simulator. Open the data you want to share and tap the Share button. From here, copy link and send it via an email using Safari browser in the simulator.

Accepting Share Invitations

Now that you’ve sent an invitation to your second user, you need to set the app to accept the invitation and add the data into the shared store. Navigate to AppDelegate.swift and you’ll see SceneDelegate is empty. Here, you’ll add the code to accept the share.

The first step is to implement the UIKit scene delegate method windowScene(_:userDidAcceptCloudKitShareWith:). When the user taps the link that was shared earlier and accepts the invitation, the delegate calls this method and launches the app. Add the following method to SceneDelegate:

func windowScene(
  _ windowScene: UIWindowScene,
  userDidAcceptCloudKitShareWith cloudKitShareMetadata: CKShare.Metadata
) {
  let shareStore = CoreDataStack.shared.sharedPersistentStore
  let persistentContainer = CoreDataStack.shared.persistentContainer
  persistentContainer.acceptShareInvitations(
    from: [cloudKitShareMetadata], into: shareStore
  ) { _, error in
    if let error = error {
      print("acceptShareInvitation error :\(error)")
    }
  }
}

This code first gets a reference to the sharedPersistentStore that you created in the beginning of this tutorial. New in iOS 15 is acceptShareInvitations(from:into:completion:). persistentContainer calls this method. It accepts the share and adds the necessary metadata into the sharedPersistentStore. That’s it!

Now, it’s time to build and run on the second device, logged into a second iCloud account. If you sent the invitation from a real device, that can be a simulator.

When the app comes up, you’ll notice the shared journal entry doesn’t show up. This is because you haven’t accepted the invitation yet. At this point, if you shared the invitation via text message, open Messages and tap the invitation.

iMessage window showing a shared destination message

When the dialog asks whether you would like to open the invitation, choose Open.

An alert dialog with options to open the destination or not

You now see the shared entry on your second device. Amazing!

Destination added

Fetching Shared Data

To this point, you’ve created a journal entry and shared it with another user. Once you accept the share on the second device, it’s now part of your shared zone in iCloud. Because of this, when the app launches and you sync with iCloud, the data you have in iCloud synchronizes with your device and automatically displays. However, you don’t have any metadata about the share. The end goal is not only to display shared entries but also to get information about the people participating in the share.

To achieve this, you’ll implement fetchShares(matching:). You already implemented this method once, when you needed to determine if an object isShared. Open CoreDataStack.swift and add the following code to the extension:

func getShare(_ destination: Destination) -> CKShare? {
  guard isShared(object: destination) else { return nil }
  guard let shareDictionary = try? persistentContainer.fetchShares(matching: [destination.objectID]),
    let share = shareDictionary[destination.objectID] else {
    print("Unable to get CKShare")
    return nil
  }
  share[CKShare.SystemFieldKey.title] = destination.caption
  return share
}

The code above does the following:

  • Checks whether the object is shared. If it doesn’t have an associated share record, there’s no need to continue.
  • Using fetchShares(matching:), returns a dictionary of the matching NSManagedObjectID‘s and their associated CKShare.
  • Extracts CKShare from the dictionary.
  • Sets the title of the share, using the caption of the destination.
  • Returns CKShare.

To use this new method, open DestinationDetailView.swift. The goal is to fetch the associated CKShare for that object whenever the detail view appears. Add the following code as one of the modifiers for your List:

.onAppear(perform: {
  self.share = stack.getShare(destination)
})

This code uses getShare(_:) and retrieves CKShare. You need to extract information about the participants of this share. With this code in place, build and run on your second device. Tap the shared object to go to the detail screen. See that data now exists at the bottom in the Participants section.

Shared record shows participants' information

Notice the role and permission for each user. One user shows up as a Owner and the other as a Private User, with both users having Read-Write permissions. This means not only the owner but also the second user can modify the data shared with them.

To change the permissions and access to this data, Apple has done all the heavy lifting for you. Go to the first device you created the share from, because you need to be the Owner to access the Share Options. Build and run, then perform the following steps:

  1. Tap the entry you want to update the permissions for.
  2. From the details screen, tap the Share action.
  3. Notice CloudSharingView launches in a new context based on the information it has about CKShare. From this screen, you can update permissions globally or for a specific participant. Select Share Options and update the permission to View only. Everyone with access to this share will have only Read access.

Notice the user can currently read and write for the entry the permissions are being modified for.

Shared record with read-write permissions

Observe CloudSharingView in the context of updating permissions.

CloudSharingView with the share options

Look at the share options. Change the permission to View only.

Choose share options

Build and run again. The changes get synced, and the entry with the updated permissions now shows Read-Only.

Sharing record permissions updated to Read-Only

Displaying Private Data Versus Shared Data

At the moment, when you launch the app, entries in your journal all look the same. The only way to distinguish the private records versus shared records is to tap the detail and view the role in the participant’s list. To improve this, go back to HomeView.swift. Then, replace the following code:

VStack(alignment: .leading) {
  Image(uiImage: UIImage(data: destination.image ?? Data()) ?? UIImage())
    .resizable()
    .scaledToFill()

  Text(destination.caption)
    .font(.title3)
    .foregroundColor(.primary)

  Text(destination.details)
    .font(.callout)
    .foregroundColor(.secondary)
    .multilineTextAlignment(.leading)
}

With the following code:

VStack(alignment: .leading) {
  Image(uiImage: UIImage(data: destination.image ?? Data()) ?? UIImage())
    .resizable()
    .scaledToFill()

  Text(destination.caption)
    .font(.title3)
    .foregroundColor(.primary)

  Text(destination.details)
    .font(.callout)
    .foregroundColor(.secondary)
    .multilineTextAlignment(.leading)
                
  if stack.isShared(object: destination) {
    Image(systemName: "person.3.fill")
      .resizable()
      .scaledToFit()
      .frame(width: 30)
  }
}

This code uses isShared to determine whether a record is part of a share. Build and run. Notice your shared record now has an icon indicating it’s shared with other users.

Icon signals record is shared

Challenge Time

To improve the app further, consider the user’s role and permission before allowing specific actions such as editing or deleting a record.

Challenge One

Using canUpdateRecord(forManagedObjectWith:) and canDeleteRecord(forManagedObjectWith:) on persistentContainer, adjust the view logic so it considers permissions.

Were you able to figure it out? See the solution below:

[spoiler title=”Solution 1″]
Open CoreDataStack.swift. Under isShared(object:), add the following methods:

func canEdit(object: NSManagedObject) -> Bool {
  return persistentContainer.canUpdateRecord(
    forManagedObjectWith: object.objectID
  )
}
func canDelete(object: NSManagedObject) -> Bool {
  return persistentContainer.canDeleteRecord(
    forManagedObjectWith: object.objectID
  )
}

These methods return a Boolean based on the object’s permissions.

Next, open DestinationDetailView.swift. Look for the ToolBarItem that contains the Text("Edit") button. Add the following modifier to the Button:

.disabled(!stack.canEdit(object: destination))

The edit button is now disabled, unless you have read/write permissions for this data.

Last, open HomeView.swift and look for the swipeActions modifier. Now, you should see a Button with Label("Delete", systemImage: "trash"). Add the following modifier to the Button:

.disabled(!stack.canDelete(object: destination))

With this code in place, only users with the proper permissions can perform actions like editing or deleting.
[/spoiler]

Challenge Two

There’s one minor bug with your app at the moment. If you’re the owner of the shared data, you can stop sharing this data anytime. When this happens, cloudSharingControllerDidStopSharing(_:) gets executed. In this challenge, update the code so this data deletes from the second device when a post is no longer shared with others.

Did you figure it out? See the solution below:

[spoiler title=”Solution 2″]
Open CoreDataStack.swift. Under isShared(object:), add:

func isOwner(object: NSManagedObject) -> Bool {
  guard isShared(object: object) else { return false }
  guard let share = try? persistentContainer.fetchShares(matching: [object.objectID])[object.objectID] else {
    print("Get ckshare error")
    return false
  }
  if let currentUser = share.currentUserParticipant, currentUser == share.owner {
    return true
  }
  return false
}

The method:

  • Checks if the object has an associated CKShare. If not, it immediately returns false.
  • Attempts to get CKShare via the fetchShares(_:) method. If it doesn’t find any matching shares, it returns false.
  • Last, if the current user of that share is the owner, returns true. Otherwise, it returns false.

With this code in place, open CloudSharingController.swift and add the following code to cloudSharingControllerDidStopSharing(_:):

if !stack.isOwner(object: destination) {
  stack.delete(destination)
}

That’s it! Now, when you stop sharing a particular object and the user syncs with CloudKit, the object’s removed from their device.
[/spoiler]

Where to Go From Here?

You can download the completed project files by clicking the Download Materials button at the top or bottom of the tutorial.

In this tutorial, you learned the important steps to share Core Data with CloudKit, including:

  • Using NSPersistentCloudKitContainer to share the data.
  • Presenting a cloud-sharing view.
  • Accepting share invitations and fetching the shared data.
  • Differentiating private data and shared data in SwiftUI.

You learned the new methods introduced in iOS 15 and solved challenges and minor bugs in the app.

For more information, check Apple’s video on Build apps that share data through CloudKit and Core Data.

Here’s Apple’s sample project on Synchronizing a Local Store to the Cloud.

If you have any questions or comments, please join the forum discussion below.

Contributors

Comments

Reviews

More like this