Core Data, CloudKit, and toggles


The more I create using Swift, the more I realise that persistence is what really makes or breaks a user experience.

Sure, there is the UI and the UX of the overall app, but when things "just work" and it feels like magic ✨ things just feel more polished.

We used say in the editing world, the best edits are the ones you don't see. There was a fairly famous video back in the day of a VFX artist walking down an alleyway talking to the camera.

He was simply explaining, what it takes to do VFX, and by mid-way at the end of his spiel asks, "how many did you spot?" After the footage rewinds, there is a breakdown of all the VFX in that short clip which the viewer wouldn't have noticed.

This kind of seamless magic happens all the time, from passwords being remembered, to even your account being stored so you don't have to log in every time.

With these things, the more we expect it and the more adoption there is - the more likely we notice when it's not there.

There was a running joke (or maybe general confusion) to the AppStore Connect's Keep me logged in checkbox that never worked. It was such a trivial checkbox, but the expectation was that a website could remember your session, log in details, and not have to re-authenticate every session.

Keep me logged in checkbox has now been removed
Keep me logged in checkbox has now been removed

What does this have to do with Core Data

One of the nicest feelings when building and running an app is the persistence from each launch. What's even nicer is when jumping from iOS to iPadOS to macOS and all your data has transferred across.

But setting up those things are not as easy as using a package like Firebase or similar. Often you want to reach all the users available not pigeonholing yourself to just Apple users. However, I like the Apple ecosystem, and it's something I'm more familiar with than Android - so my focus is there.

Writing a do-it-all

I've had this manager for a while without the CloudKit portion and it worked really well. However, I wanted that sparkle of transferring all my stored data in Serial Box to other devices allowing access to everything no matter what device I had.

I went down the road of implementing CloudKit on top of Core Data. All the blogs say it is as easy of changing one line. Even Apple in the WWDC video, Using Core Data With CloudKit make it sound as simple as converting NSPersistentContainer to NSPersistentCloudKitContainer and be on your way.

Thinking it's as simple as that, that was all I did - and it worked. Seamlessly and instantly data was being moved between devices, and Core Data was doing all the heavy lifting for me.

Until I listened to Under the Radar #258: A Less-Cloudy Outlook where Marco Arment talks about all the caveats of CloudKit.

  • Does the user have an Apple ID
  • Is the user logged in to an Apple ID
  • Is iCloud Drive enabled for the user

Which I then started to think of all the edge cases which I assumed others would have enabled in order to use their Apple devices. I stupidly built with rose-tinted glasses.

I went back and re-wrote some of the manager to reflect the additions, checks and balances, and so far it feels and seems very robust.

CoreDataManager.swift

Below is the full CoreDataManager.swift code. I've commented the hell out of it so it is easy to follow along.

I've also got a Gist of it, if there are any changes other's might think work better or fix something I'm missing.

final class CoreDataManager {

  // -- singleton initalisation
  static let shared = CoreDataManager()

  var container: NSPersistentContainer

  // -- managed object context
  var managedObjectContext: NSManagedObjectContext {
    container.viewContext
  }

  // -- initialiser
  private init() {

    // -- get the model
    container = NSPersistentCloudKitContainer(name: "Core Data")

    // -- get the description
    guard let description = container.persistentStoreDescriptions.first else {
      fatalError("###\(#function): Failed to retrieve a persistent store description.")
    }

    // -- listen to changes from cloudkit
    description.setOption(
      true as NSNumber, 
      forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey
    )

    container.loadPersistentStores { storeDescription, error in
      if let error = error as NSError? {
        fatalError("Unresolved error \(error), \(error.userInfo)")
      }
      storeDescription.shouldInferMappingModelAutomatically = false
    }

    managedObjectContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
    managedObjectContext.automaticallyMergesChangesFromParent = true
  }


  // MARK: - CRUD operations (create / fetch / update / delete)

  /// Creates a new managed object of the specified type and executes the 
  /// completion block with the new object.
  /// - Parameters:
  ///   - objectType: The type of the managed object to create.
  ///   - completion: A block that takes the new managed object as its parameter.
  func create<T: NSManagedObject>(
    _ objectType: T.Type, 
    completion: (T) -> Void, saveError: @escaping (Error?) -> Void
  ) {
    let newObject = NSEntityDescription.insertNewObject(
      forEntityName: String(describing: objectType),
      into: managedObjectContext
    ) as! T
    completion(newObject)
    self.save(completion: saveError)
  }

  /// Fetches a list of `objectType` objects from the Core Data store.
  /// - Parameters:
  ///   - objectType: The type of object to fetch.
  ///   - predicate: An optional predicate for filtering the results.
  ///   - sortDescriptors: An optional list of sort descriptors for sorting the results.
  /// - Returns: An array of `objectType` objects.
  func fetch<T: NSManagedObject>(_ objectType: T.Type,
                   predicate: NSPredicate? = nil,
                   sortDescriptors: [NSSortDescriptor]? = nil) -> [T] {
    let fetchRequest = NSFetchRequest<T>(entityName: String(describing: objectType))
    fetchRequest.predicate = predicate
    fetchRequest.sortDescriptors = sortDescriptors
    do {
      let objects = try managedObjectContext.fetch(fetchRequest)
      return objects
    } catch {
      print("[x] Error: \(error.localizedDescription)")
      return []
    }
  }

  /// Saves the managed object context.
  /// - Parameter completion: A completion block that is called after the save is 
  /// complete. The block takes an optional Error as its only argument.
  func save(completion: @escaping (Error?) -> Void = { _ in }) {
    if managedObjectContext.hasChanges {
      do {
        try managedObjectContext.save()
      } catch {
        completion(error)
        managedObjectContext.rollback()
      }
    } else {
      completion(nil)
    }
  }

  /// Deletes an NSManagedObject from the managed object context.
  /// - Parameters:
  ///   - object: The NSManagedObject to delete.
  ///   - completion: A closure to be called after the delete operation is completed.  
  /// If the delete operation was successful, the error parameter is nil. If the  
  /// delete operation failed, the error parameter contains the error that occurred.
  func delete<T: NSManagedObject>(_ object: T, completion: @escaping (Error?) -> Void = { _ in }) {
    managedObjectContext.delete(object)
    save(completion: completion)
  }

  /// Deletes all the given objects from the managed object context.
  /// - Parameter objects: An array of NSManagedObject instances to delete.
  func deleteAll<T: NSManagedObject>(_ objects: [T]) {
    for object in objects {
      delete(object)
    }
  }


  // MARK: - iCloud functionality

  /// Determines if the user is logged into an Apple ID and using iCloud.
  /// - Returns: A boolean indicating if the user is logged into an Apple ID and using iCloud.
  func canUseCloudFunctionality() -> Bool {
    isLoggedIntoAppleID() && isUsingiCloud()
  }

  /// Determines whether the user is currently using iCloud or not.
  /// - Returns: `true` if the user is using iCloud, `false` otherwise.
  private func isUsingiCloud() -> Bool {
    let iCloudAccountStatus = FileManager.default.ubiquityIdentityToken
    return iCloudAccountStatus != nil
  }

  /// Determines whether the user is logged into an Apple ID.
  /// - Remark:
  ///  - `available`: user is logged in to an iCloud account and iCloud is enabled
  ///  - `noAccount`: user is not logged in to an iCloud account or iCloud is not enabled
  ///  - `restricted`: the device is in a restricted or parental controlled environment
  ///  - `couldNotDetermine`: an error occurred trying to determine the user's iCloud account status
  ///  - `temporarilyUnavailable`: user’s iCloud account is available, but isn’t ready  
  /// to support CloudKit operation
  /// - Returns: A boolean value indicating whether the user is logged into an Apple ID.
  private func isLoggedIntoAppleID() -> Bool {
    let container = CKContainer.default()
    var status: Bool = false
    container.accountStatus { (accountStatus, error) in
      switch accountStatus {
        case .available:
          status = true
        case .noAccount,
           .restricted,
           .couldNotDetermine,
           .temporarilyUnavailable:
          status = false
        @unknown default:
          status = false
      }
    }
    return status
  }

  /// Syncs the local data store with the cloud data store.
  /// - Parameter pull: A boolean value indicating whether to pull data from the  
  /// cloud (true) or push data to the cloud (false).
  func syncWithCloud(pull: Bool) {
    if canUseCloudFunctionality() {
      container.viewContext.perform {
        do {
          if pull {
            self.managedObjectContext.refreshAllObjects()
          } else {
            try self.managedObjectContext.save()
          }
        } catch {
          print("Error syncing with cloud: \(error)")
        }
      }
    } else {
      print("Error: User is not logged into an Apple ID or not using iCloud")
    }
  }

  /// Forces a sync with the cloud.
  /// - Parameter pull: A boolean value indicating whether to pull data from the cloud.
  func forceSyncWithCloud(pull: Bool) {
    if canUseCloudFunctionality() {
      container.viewContext.perform {
        do {
          self.managedObjectContext.reset()
          if pull {
            self.managedObjectContext.refreshAllObjects()
          } else {
            try self.managedObjectContext.save()
          }
        } catch {
          print("Error force syncing with cloud: \(error)")
        }
      }
    }
  }

  /// Toggles the CloudKit synchronization for the Core Data stack.
  /// - Parameter isEnabled: A boolean value indicating whether CloudKit  
  /// synchronization should be enabled or disabled.
  func toggleCloudSync(isEnabled: Bool) {
    if !canUseCloudFunctionality() {
      print("Cannot use cloud functionality")
      return
    }
    guard let description = container.persistentStoreDescriptions.first else {
      return
    }
    description.setOption(
      isEnabled as NSNumber, 
      forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey
    )
    container.persistentStoreCoordinator.performAndWait {
      do {
        try container.persistentStoreCoordinator.remove(
          container.persistentStoreCoordinator.persistentStores.first!
        )
        try container.persistentStoreCoordinator.addPersistentStore(
          ofType: NSSQLiteStoreType, 
          configurationName: nil, 
          at: description.url, 
          options: description.options
        )
      } catch {
        print(error)
      }
    }
  }
}

Usage

What I think is important for this manager is that is makes the usage simple and abstracted away from UI components.

Though these examples are print() out the errors, the proper use case would be to alert your user of any issues.

// -- fetching data
let _ = CoreDataManager.shared.fetch(Person.self)

// -- creating new data
let _ = CoreDataManager.shared.create(Person.self) { person in
  person.name = ""
} saveError: { error in
  if let error = error {
    print(error.localizedDescription)
  }
}

// -- updating data
let _ = CoreDataManager.shared.save { error in
  if let error = error {
    print(error.localizedDescription)
  }
}

// -- deleting data
let people = CoreDataManager.shared.fetch(Person.self)
guard let firstPerson = people.first else { return }
let _ = CoreDataManager.shared.delete(firstPerson) { error in
  if let error = error {
    print(error.localizedDescription)
  }
}

Help me write and make more!

You can help me continue to provide valuable content like this. If you found this article helpful, please consider supporting me.

Coffee Pizza Dinner