Highlight
iOS 15 adds full CloudKit Record Zone Sharing support to
NSPersistentCloudKitContainer. Add a shared store description, callshare(_:to:completion:), and handleUICloudSharingControllercallbacks—Core Data objects sync across iCloud users in real time, with UI tailored to each participant’s permissions.
Core Content
Starting from Photos Shared Albums
Imagine implementing Photos-style shared albums in your app. A user creates a content collection, invites friends, everyone can view, some can edit.
Previously this required heavy custom code: backend, real-time sync, permissions, conflict resolution. CloudKit provided infrastructure, but integrating into Core Data was still a lot of work.
iOS 15’s NSPersistentCloudKitContainer packages it all.
Two Databases, One Context
The core architecture change: the app now connects to two CloudKit databases:
- Private Database: Data the user owns
- Shared Database: Data others have shared with the user
Each mirrors to a local persistent store. Key point: one NSManagedObjectContext can access both stores. Querying shared data uses the same code as private data.
Enabling Shared Storage
First step: add a shared store description to the Core Data stack.
// [5:20](https://developer.apple.com/videos/play/wwdc2021/10015/?time=320)
let privateStoreDescription = container.persistentStoreDescriptions.first!
let storesURL = privateStoreDescription.url!.deletingLastPathComponent()
privateStoreDescription.url = storesURL.appendingPathComponent("private.sqlite")
privateStoreDescription.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey)
privateStoreDescription.setOption(true as NSNumber, forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey)
let sharedStoreURL = storesURL.appendingPathComponent("shared.sqlite")
let sharedStoreDescription = privateStoreDescription.copy()
sharedStoreDescription.url = sharedStoreURL
let containerIdentifier = privateStoreDescription.cloudKitContainerOptions!.containerIdentifier
let sharedStoreOptions = NSPersistentCloudKitContainerOptions(containerIdentifier: containerIdentifier)
sharedStoreOptions.databaseScope = .shared
sharedStoreDescription.cloudKitContainerOptions = sharedStoreOptions
container.persistentStoreDescriptions.append(sharedStoreDescription)
Key points:
- Copy private store description; change URL to shared.sqlite
- Create
NSPersistentCloudKitContainerOptionswithdatabaseScope = .shared - Both stores share the same CloudKit container identifier
- Enable persistent history tracking and remote change notification
Initiating a Share
Sharing an object takes just a few lines. share(_:to:completion:) is designed to work with UICloudSharingController.
// [6:00](https://developer.apple.com/videos/play/wwdc2021/10015/?time=360)
@IBAction func shareNoteAction(_ sender: Any) {
guard let barButtonItem = sender as? UIBarButtonItem else {
fatalError("Not a UI Bar Button item??")
}
guard let post = self.post else {
fatalError("Can't share without a post")
}
let container = AppDelegate.sharedAppDelegate.coreDataStack.persistentContainer
let cloudSharingController = UICloudSharingController {
(controller, completion: @escaping (CKShare?, CKContainer?, Error?) -> Void) in
container.share([post], to: nil) { objectIDs, share, container, error in
if let actualShare = share {
post.managedObjectContext?.performAndWait {
actualShare[CKShare.SystemFieldKey.title] = post.title
}
}
completion(share, container, error)
}
}
cloudSharingController.delegate = self
if let popover = cloudSharingController.popoverPresentationController {
popover.barButtonItem = barButtonItem
}
present(cloudSharingController, animated: true) {}
}
Key points:
container.share([post], to: nil)creates a new share containing the postto: nilcreates a new share; pass an existing share to add objects to it- Set share metadata like title in the completion block
UICloudSharingControllerhandles the invitation flow
Accepting Share Invitations
When a user taps a share link in email, the app must accept the invitation.
// In AppDelegate
func application(_ application: UIApplication, userDidAcceptCloudKitShareWith cloudKitShareMetadata: CKShare.Metadata) {
let container = coreDataStack.persistentContainer
container.acceptShareInvitations(from: [cloudKitShareMetadata], into: sharedPersistentStore) { shares, error in
// NSPersistentCloudKitContainer automatically syncs shared objects to local store
}
}
Key points:
acceptShareInvitations(from:into:completion:)accepts the invitation- Pass the shared store; the container syncs shared objects locally
- After sync, the app’s UI reflects the new data automatically
How Record Zone Sharing Works
NSPersistentCloudKitContainer uses CloudKit Record Zone Sharing, not traditional hierarchical sharing.
In hierarchical sharing, records link to a root record (share). In Record Zone Sharing:
- Shared
CKRecords live in a sharedCKRecordZone - Each shared zone is identified by a
CKSharerecord CKSharecontains owner, participants, permissions, etc.- NSPersistentCloudKitContainer automatically manages zone and record assignment
Each participant has data in both databases:
- Private Database: Zones they own (shared or not)
- Shared Database: Zones others shared with them
Building Share-Aware UI
Sharing isn’t just data sync—the UI must communicate share state. Key scenarios:
Marking shared objects
// [17:58](https://developer.apple.com/videos/play/wwdc2021/10015/?time=1078)
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
// ... dequeue cell ...
let post = dataProvider.fetchedResultsController.object(at: indexPath)
cell.title.text = post.title
if sharingProvider.isShared(object: post) {
let attachment = NSTextAttachment(image: UIImage(systemName: "person.circle")!)
let attributedString = NSMutableAttributedString(attachment: attachment)
attributedString.append(NSAttributedString(string: " " + (post.title ?? "")))
cell.title.text = nil
cell.title.attributedText = attributedString
}
return cell
}
Key points:
- Use
person.circleSF Symbol to mark shared content sharingProvider.isShared(object:)checks if object is shared
SharingProvider protocol
For testing and modularity, the presenter designed a SharingProvider protocol:
// [17:06](https://developer.apple.com/videos/play/wwdc2021/10015/?time=1026)
protocol SharingProvider {
func isShared(object: NSManagedObject) -> Bool
func isShared(objectID: NSManagedObjectID) -> Bool
func participants(for object: NSManagedObject) -> [RenderableShareParticipant]
func shares(matching objectIDs: [NSManagedObjectID]) throws -> [NSManagedObjectID: RenderableShare]
func canEdit(object: NSManagedObject) -> Bool
func canDelete(object: NSManagedObject) -> Bool
}
Benefits:
- Each method maps to a specific UI decision
- Easy to inject mock implementations for unit tests
- Decouples CloudKit details from UI code
Implementing SharingProvider
// [20:01](https://developer.apple.com/videos/play/wwdc2021/10015/?time=1201)
extension CoreDataStack: SharingProvider {
func isShared(object: NSManagedObject) -> Bool {
return isShared(objectID: object.objectID)
}
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 nil != shares.first {
isShared = true
}
} catch let error {
print("Failed to fetch share for \(objectID): \(error)")
}
}
}
return isShared
}
func canEdit(object: NSManagedObject) -> Bool {
return persistentContainer.canUpdateRecord(forManagedObjectWith: object.objectID)
}
func canDelete(object: NSManagedObject) -> Bool {
return persistentContainer.canDeleteRecord(forManagedObjectWith: object.objectID)
}
}
Key points:
- Check if object’s persistent store is the shared store
- Use
fetchShares(matching:)for share info canUpdateRecord(forManagedObjectWith:)checks edit permissioncanDeleteRecord(forManagedObjectWith:)checks delete permission
Permission Control
Share creators set participant permissions:
- Read-Only: View only; no edit or delete
- Read-Write: View, edit, add content
UI must adapt dynamically:
- Read-only participants don’t see Edit
- Can’t swipe-delete read-only shared content
- Can’t delete read-only content in edit mode
Detailed Content
Testing Share UI
The presenter stressed testing share-related UI. The SharingProvider protocol makes mock injection easy:
// [18:44](https://developer.apple.com/videos/play/wwdc2021/10015/?time=1124)
func testSharedPostsGetDisclosure() {
var sharedObjectIDs: Set<NSManagedObjectID> = Set()
let context = coreDataStack.persistentContainer.viewContext
self.generatePosts(in: context, postSaveBlock: { posts in
for (index, post) in posts.enumerated where (index % 4) == 0 {
sharedObjectIDs.insert(post.objectID)
}
})
let provider = BlockBasedShareProvider(stack: coreDataStack)
provider.isSharedBlock = sharedObjectIDs.contains
mainViewController.sharingProvider = provider
// ... perform fetch and verify cells ...
for index in 0..<rowCount {
let indexPath = IndexPath(row: index, section: 0)
let post = mainViewController.dataProvider.fetchedResultsController.object(at: indexPath)
guard let cell = mainViewController.tableView(
mainViewController.tableView,
cellForRowAt: indexPath
) as? PostCell else {
XCTFail("Encountered an unexpected cell type")
return
}
if sharedObjectIDs.contains(post.objectID) {
guard let attachment = cell.title.attributedText?
.attributes(at: 0, effectiveRange: nil)[.attachment] as? NSTextAttachment else {
XCTFail("Expected an image attachment at the first character")
return
}
XCTAssertEqual(expectedSharedImage, attachment.image)
} else {
XCTAssertEqual(cell.title.text, title)
}
}
}
Key points:
- Create
BlockBasedShareProviderwith custom behavior isSharedBlockcontrols which objects count as shared- Verify shared cells show person.circle icon
- Verify non-shared cells show normal text title
Share Boundaries
NSPersistentCloudKitContainer infers which related records to include when sharing. If object A relates to B, sharing A includes B.
You can also pass an explicit CKShare parameter to control which share zone objects belong to.
Core Takeaways
1. Build a family-shared todo app
- What: Family shopping list or task app with real-time collaboration
- Why:
NSPersistentCloudKitContainerhandles sync, conflict resolution, and permissions—you focus on UI - How: Add shared store description to Core Data stack; use
UICloudSharingControllerto share
2. Add collaboration to a notes app
- What: Let users share specific notes with colleagues for co-editing
- Why: Share granularity down to a single
NSManagedObject—other private notes stay private - How: Add share button on notes; call
container.share([note], to: nil)
3. Implement permission-aware UI
- What: Adapt UI by role (owner/read-only/read-write)
- Why:
canEditandcanDeletesimplify permission checks - How: Implement
SharingProvider; call incellForRowAtand edit button logic
4. Design a testable sharing module
- What: Decouple sharing logic from UI with
SharingProvider - Why: Unit test share/non-share and read-only/read-write without real CloudKit
- How: Define
SharingProvider; createBlockBasedShareProvidermock
5. Build cross-device shared experiences
- What: Shared content available on all devices via automatic sync
- Why: After accepting on one device, other devices on the same Apple ID get access automatically
- How: Handle
acceptShareInvitationscorrectly; use the same Core Data stack config on all devices
Related Sessions
- What’s new in CloudKit — CloudKit Record Zone Sharing internals
- Bring Core Data concurrency to Swift and SwiftUI — Core Data Swift Concurrency; combine with shared data
- Synchronizing a local store to the cloud — Core Data and CloudKit sync fundamentals
Comments
GitHub Issues · utterances