I am trying to create a simple application that leverages CloudKit to sync between multiple iOS devices (via iCloud) and possibly macOS devices (still via iCloud).
Problem: I have a core data entity which seems to work great locally in my app. When I switch to using Cloudkit I am unable to see changes on another device without closing/reopening the app.
I am using the Cloudkit template in Xcode with the SwiftUI lifecycle. ie, PersistenceController and managed object context.
I think this is a view not refreshing issue, but am not 100% sure. Once the app on a different device is closed and reopened then it loads the new data successfully. This applies to additions and deletes.
Testing: I have tested this using Testflight as well as running two simulators locally.
Code:
PersistenceController.swift
import CoreData
struct PersistenceController {
static var shared = PersistenceController()
static var preview: PersistenceController = {
let result = PersistenceController(inMemory: true)
let viewContext = result.container.viewContext
for i in 0..<10 {
let newItem = Card(context: viewContext)
newItem.cardValue = String(i)
newItem.cardOrder = Int32(i)
}
do {
try viewContext.save()
} catch {
// Replace this implementation with code to handle the error appropriately.
// fatalError() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development.
let nsError = error as NSError
fatalError("Unresolved error \(nsError), \(nsError.userInfo)")
}
return result
}()
//Is this needed?
lazy var updateContext: NSManagedObjectContext = {
let _updateContext = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType)
_updateContext.parent = PersistenceController.shared.updateContext
return _updateContext
}()
let container: NSPersistentCloudKitContainer
init(inMemory: Bool = false) {
container = NSPersistentCloudKitContainer(name: "Cards")
if inMemory {
container.persistentStoreDescriptions.first!.url = URL(fileURLWithPath: "/dev/null")
}
container.loadPersistentStores(completionHandler: { (storeDescription, error) in
if let error = error as NSError? {
fatalError("Unresolved error \(error), \(error.userInfo)")
}
})
container.viewContext.automaticallyMergesChangesFromParent = true
container.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
}
}
ContentView.swift
import SwiftUI
import CoreData
struct ContentView: View {
@Environment(\.managedObjectContext) var managedObjectContext
@FetchRequest(entity: Card.entity(),
sortDescriptors: [NSSortDescriptor(key: "cardOrder", ascending: true)])
var cards: FetchedResults<Card>
var body: some View {
NavigationView {
VStack {
Text("Scrum +")
.font(.largeTitle)
.fontWeight(.regular)
Spacer()
List(cards, id: \.id) {card in
NavigationLink(destination: PointRow(card: card)) {
PointRow(card: card)
}
}
Spacer()
NavigationLink(destination: SettingsView().environment(\.managedObjectContext, self.managedObjectContext)
) {
Text("Card Settings")
}
.padding(.bottom)
}
}
}
}
SettingsView.swift (where saving/deleting/reordering happens)
import SwiftUI
struct SettingsView: View {
@State private var editMode = EditMode.inactive
@State private var showModal = false
@Environment(\.managedObjectContext) var managedObjectContext
@FetchRequest(sortDescriptors: [NSSortDescriptor(key: "cardOrder", ascending: true)])
var cards: FetchedResults<Card>
var body: some View {
VStack {
Text("Card Values")
.font(.largeTitle)
HStack {
EditButton()
.padding(.leading)
Spacer()
addButton
.padding(.trailing)
}
.padding(.vertical)
List {
ForEach(cards) {
item in
Text(item.cardValue!)
}
.onDelete(perform: onDelete)
.onMove(perform: onMove)
.environment(\.editMode, $editMode)
}
.sheet(isPresented: $showModal) {
//Show the view to add a new card.
SettingsModal(showModal: self.$showModal, numberOfCards: cards.count).environment(\.managedObjectContext, self.managedObjectContext)
}
}
}
private var addButton: some View {
switch editMode {
case .inactive:
return AnyView(Button(action: onAdd) { Image(systemName: "plus") })
default:
return AnyView(EmptyView())
}
}
func onAdd() {
showModal = true
}
func onDelete(indexSet: IndexSet) {
// print("Deleting card at index -> " + indexSet.first)
let cardToDelete = self.cards[indexSet.first!]
self.managedObjectContext.delete(cardToDelete)
do {
try self.managedObjectContext.save()
} catch {
print(error)
}
reorder()
}
private func onMove(source: IndexSet, destination: Int) {
// pointCards.points.move(fromOffsets: source, toOffset: destination)
// source.
let firstCard = self.cards[source.first!]
//If there is a card located in the destination.
if (destination < self.cards.count) {
let secondCard = self.cards[destination]
let tmp = Int(secondCard.cardOrder)
//Increment all place holders from the destination on.
for i in tmp..<self.cards.count {
self.cards[i].cardOrder += 1
}
}
firstCard.cardOrder = Int32(destination)
do {
try self.managedObjectContext.save()
} catch {
print(error)
}
self.managedObjectContext.refreshAllObjects()
//Reorder just in case moved to the end.
reorder()
}
private func reorder() {
for i in 0..<cards.count {
cards[i].cardOrder = Int32(i)
}
do {
try self.managedObjectContext.save()
} catch {
print(error)
}
}
}
Screenshots of my Core Data model using Cloudkit and of the Entitlement selection. Capabilities
UPDATE: I have found the solution. The Persistent Controller should have the following three lines added to the init function:
//Setup auto merge of Cloudkit data
container.viewContext.automaticallyMergesChangesFromParent = true
container.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
//Set the Query generation to .current. for dynamically updating views from Cloudkit
try? container.viewContext.setQueryGenerationFrom(.current)
Then this must be ran on real devices. For whatever reason I am unable to get this working in the simulator, but on real devices it syncs in about 30 seconds without closing the app.
Thanks everyone for your help!