iCloudKit/CoreData won't update the UI on any other iCloud connected devices after making a change on the device in hand.
I have one ViewController which holds a UITableView and 2 buttons (Add and Refresh). The Add button successfully adds a new row to the UITableView as well as to the iCloudKit container. Any changes to the UITableView are reflected on the device in hand (immediately) and in the iCloudKit container (eventually - ie, within 10-15 seconds).
The problem is that I can't figure out how to get any other devices' UI to update automatically. I wait several minutes, and there's never any update. I can verify that my other devices are signed in to the same iCloud account by waiting a few minutes and then hitting the Refresh button on those devices. I designed the Refresh button to force a NSFetchRequest from iCloudKit and then use that data to reload the UITableView. If I manually instigate the NSFetchRequest, the data is shared amongst all my devices - but there is never any automatic update based on some background notification.
The code below represents my best attempt to make sense of a wide array of opinions (and quite-possibly out-dated strategies) - and I'm not sure if I may be mixing metaphors in the code I'm posting here. I'm using XCode 12.4, Swift 5, UIKit, iCloudKit and CoreData. In the main.storyboard, I control-dragged to establish the ViewController as the UITableView's delegate. I've also added Background Modes -> Remote notifications and Push Notifications to my project's Signing & Capabilities tab.
Here is the AppDelegate code:
import UIKit
import CoreData
@main
class AppDelegate: UIResponder, UIApplicationDelegate {
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
// note: is this needed? - only one tutorial/answer mentioned this
application.registerForRemoteNotifications()
return true
}
func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
// note: this print() statement never executes - so I'm guessing this func is superfluous
print("AppDelegate.application(didRegisterForRemoteNotificationsWIthDeviceToken) - deviceToken=\(deviceToken)")
}
func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) {
// note: this print() statement never executes - so I'm guessing this func is superfluous
print("AppDelegate.application(didFailToRegisterForRemoteNotificationsWithError) - error=\(error)")
}
// MARK: - UISceneSession Lifecycle
func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration {
return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role)
}
func application(_ application: UIApplication, didDiscardSceneSessions sceneSessions: Set<UISceneSession>) {
}
// MARK: - Core Data stack
lazy var persistentContainer: NSPersistentCloudKitContainer = {
let dataContainer = NSPersistentCloudKitContainer(name: "coreData_1")
// turn on persistent history tracking
// https://developer.apple.com/documentation/coredata/consuming_relevant_store_changes
// note: I've seen SwiftUI tutorials where this wasn't needed
// - the background refreshes seem to be handled entirely by the SwiftUI's implementation of UITableView
let id = "iCloud.iCloud.org.anon.CoreData1"
let options = NSPersistentCloudKitContainerOptions(containerIdentifier: id)
let description = dataContainer.persistentStoreDescriptions.first
description?.cloudKitContainerOptions = options
description?.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey)
description?.setOption(true as NSNumber, forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey)
// this is supposed to make background updates from iCloud available to the context.
dataContainer.viewContext.automaticallyMergesChangesFromParent = true
dataContainer.viewContext.mergePolicy = NSMergeByPropertyStoreTrumpMergePolicy
// call this LAST, after the persistentStoreDescriptions configuration.
dataContainer.loadPersistentStores(completionHandler: { (storeDescription, error) in
if let error = error as NSError? {
fatalError("Unresolved error \(error), \(error.userInfo)")
}
})
return dataContainer
}()
// MARK: - Core Data Saving support
func saveContext () {
let context = persistentContainer.viewContext
if context.hasChanges {
do {
try context.save()
} catch {
let nserror = error as NSError
fatalError("Unresolved error \(nserror), \(nserror.userInfo)")
}
}
}
}
Here is the ViewController code:
import UIKit
import CoreData
class ViewController: UIViewController {
@IBOutlet weak var tableView: UITableView!
var movies: [NSManagedObject] = []
lazy var managedContext = NSManagedObjectContext(concurrencyType:.mainQueueConcurrencyType)
lazy var cloudContainer = NSPersistentCloudKitContainer()
var notificationCount = 0 // for debugging
override func viewDidLoad() {
super.viewDidLoad()
title = "The List"
tableView.register(UITableViewCell.self, forCellReuseIdentifier: "Cell")
// set up core data accessors once - these are used later
guard let appDelegate = UIApplication.shared.delegate as? AppDelegate else { return }
let mgdCtx = appDelegate.persistentContainer.viewContext
managedContext = mgdCtx
cloudContainer = appDelegate.persistentContainer
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
// I never receive these notifications - actOnNotification() is never called
NotificationCenter.default.addObserver(self, selector: #selector(actOnNotification), name: .NSPersistentStoreRemoteChange, object: cloudContainer)
updateTableView()
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
// these notifications aren't working for me in UIKit
NotificationCenter.default.removeObserver(self, name: .NSPersistentStoreRemoteChange, object: cloudContainer)
}
@IBAction func refresh(_ sender: UIBarButtonItem) {
// manually refresh UITableView after giving up on background refresh
updateTableView()
// and just display how many times we've recieved notifications
let alert = UIAlertController(title: "NotificationCount", message: "notificationCount=\(notificationCount)", preferredStyle: .alert)
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))
present(alert, animated: true)
}
@objc func actOnNotification() {
// this function never gets called
notificationCount += 1
updateTableView()
}
@objc func updateTableView() {
// load data using iCloudKit + CoreData
let fetchRequest = NSFetchRequest<NSManagedObject>(entityName: "Movie")
let sort = NSSortDescriptor(key: "fileName", ascending: true)
fetchRequest.sortDescriptors = [sort]
do {
movies = try managedContext.fetch(fetchRequest)
} catch let error as NSError {
print("Could not fetch. \(error), \(error.userInfo)")
}
// then update UI
tableView.reloadData()
}
@IBAction func addName(_ sender: UIBarButtonItem) {
let alert = UIAlertController(title: "New Name", message: "Add a new name", preferredStyle: .alert)
alert.addTextField(configurationHandler: { textField in textField.placeholder = "Enter name..." })
let saveAction = UIAlertAction(title: "Save", style: .default) {
[unowned self] action in
guard let textField = alert.textFields?.first,
let nameToSave = textField.text else {
return
}
self.save(name: nameToSave)
}
let cancelAction = UIAlertAction(title: "Cancel", style: .cancel)
alert.addAction(saveAction)
alert.addAction(cancelAction)
present(alert, animated: true)
}
func save(name: String) {
// update iCloudKit data
guard let entity = NSEntityDescription.entity(forEntityName: "Movie", in: managedContext) else { return }
let person = NSManagedObject(entity: entity, insertInto: managedContext)
person.setValue(name, forKey: "fileName")
do {
try managedContext.save()
movies.append(person)
} catch let error as NSError {
print("Could not save. \(error), \(error.userInfo)")
}
print("movies=\(movies)")
// then update UI
tableView.reloadData()
}
}
extension ViewController: UITableViewDataSource {
func tableView(_ tableView: UITableView, numberOfRowsInSection: Int) -> Int {
return movies.count
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
// select cellForRowAt in UI
print("ViewController.tableView(cellForRowAt)")
let person = movies[indexPath.row]
let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath)
cell.textLabel?.text = person.value(forKeyPath: "fileName") as? String
print("ViewController.tableView.cellForRow(\(indexPath)")
return cell
}
func tableView(_ tableView: UITableView, commit editingStyle: UITableViewCell.EditingStyle, forRowAt indexPath: IndexPath) {
if editingStyle == .delete {
// update iCloudKit data
managedContext.delete(movies[indexPath.row])
do {
try managedContext.save()
// then update UI
movies.remove(at: indexPath.row)
tableView.deleteRows(at: [indexPath], with: .fade)
tableView.reloadData()
} catch let error as NSError {
print("Could not delete. \(error), \(error.userInfo)")
}
} else {
print("ViewController.tableView.commit(\(editingStyle)")
}
}
}
I have followed a tutorial using SwiftUI - and the NotificationCenter.default.addObserver() and .removeObserver() calls in the viewWillAppear() and viewWillDisappear() functions do not appear in that tutorial. In fact, all that tutorial did was to add the background Signing & Capabilities to the project - and it worked perfect. I'm guessing that SwiftUI has default behavior for the master/view classes used in the tutorial that are NOT present in my use of UIKit's UITableView.
Any help will be greatly appreciated...