0

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...

Koa
  • 173
  • 8
  • Does this answer your question? [iCloud Sync does work on iPhone & Mac but not on iPad](https://stackoverflow.com/questions/65848216/icloud-sync-does-work-on-iphone-mac-but-not-on-ipad) – lorem ipsum May 03 '21 at 20:14
  • On the surface this sounds somewhat similar to my situation, but I believe there's something in the SwiftUI code that differs from the UIKit implementation of UITableView... – Koa May 03 '21 at 20:45
  • There isn't a whole lot of difference but I can tell you that `NSFetchRequest` only does a single `fetch` there isn't any observing and if you are implementing observes manually the issues could be anywhere. You should look at the [CoreData Programming Guide](https://developer.apple.com/library/archive/documentation/Cocoa/Conceptual/CoreData/nsfetchedresultscontroller.html#//apple_ref/doc/uid/TP40001075-CH8-SW1) it s a little old and there are some things that need modification (Xcode will tell you) but it has a really good setup using the `NSFetchedResultsController` that does the listening – lorem ipsum May 03 '21 at 21:19

0 Answers0