Skip to Content
Course content

214: Building a Simple Journaling App with CloudKit Sync

Click on the "Edit" button in the top corner of the screen to edit your slide content.

Up until now, we've been keeping our data local. That's fine for a prototype, but for a journaling app, it's a non-starter. If a user writes a heartfelt entry on their iPad and then opens their iPhone to find a blank screen, they're going to delete your app. Today, we're building a minimal sync engine for a journaling app I'm calling "MindDump."

We'll be using CloudKit. I prefer it for these types of apps because it handles the authentication via the user's iCloud account, meaning we don't have to build a custom login system. Let's dive in.

Connecting to the Private Database

First, we need to decide where the data lives. CloudKit gives us public, private, and shared databases. Since a journal is deeply personal, we're going exclusively with the privateCloudDatabase. The user is the only one who can see this data, and it doesn't count against your app's developer storage quota—it counts against theirs.

import CloudKit

class CloudKitManager {
    let container = CKContainer.default()
    let privateDB = CKContainer.default().privateCloudDatabase
    
    // We'll use this to identify our record type in the dashboard
    let recordType = "JournalEntry"
}

I'm wrapping this in a manager class. It keeps the view controllers from getting cluttered with CloudKit boilerplate, which can get messy quickly.

Turning a Journal Entry into a CKRecord

CloudKit doesn't store Swift structs directly. It uses CKRecord, which is essentially a dictionary of keys and values. I've already defined a JournalEntry struct in a previous lesson, so now we just need a way to convert it.

extension CloudKitManager {
    func saveEntry(_ entry: JournalEntry, completion: @escaping (Result<Void, Error>) -> Void) {
        let record = CKRecord(recordType: recordType)
        record["content"] = entry.text as CKRecordValue
        record["timestamp"] = entry.date as CKRecordValue
        
        privateDB.save(record) { _, error in
            if let error = error {
                completion(.failure(error))
            } else {
                completion(.success(()))
            }
        }
    }
}

The "Why is this crashing?" Moment

At this point, I usually hit "Run," try to save an entry, and the app immediately throws a permission error or crashes. I did it again this morning. I wrote the code perfectly, but I forgot the most annoying part of CloudKit: the Capabilities.

If you're seeing an error about "container not found" or "permission denied," it's because you haven't told Xcode that this app is allowed to talk to iCloud. You have to go to your Target settings, hit + Capability, add iCloud, and then check the CloudKit box. You also need to create a container ID (usually iCloud.com.yourname.MindDump). I always forget this because it's a project setting, not a code setting. Don't be like me.

Bringing the Journal Entries Home

Saving is the easy part. The real work is fetching the entries back down and sorting them so the newest journal entry is at the top. We'll use a CKQuery for this.

func fetchEntries(completion: @escaping (Result<[JournalEntry], Error>) -> Void) {
    let predicate = NSPredicate(value: true) // We want everything
    let query = CKQuery(recordType: recordType, predicate: predicate)
    query.sortDescriptors = [NSSortDescriptor(key: "timestamp", ascending: false)]
    
    privateDB.perform(query, inZoneWith: nil) { records, error in
        if let error = error {
            completion(.failure(error))
            return
        }
        
        let entries = records?.compactMap { record in
            guard let text = record["content"] as? String,
                  let date = record["timestamp"] as? Date else { return nil }
            return JournalEntry(text: text, date: date)
        } ?? []
        
        completion(.success(entries))
    }
}

One thing to keep in mind: perform(query:) is fine for a small journal, but if your user writes ten thousand entries, this will eventually time out or hit a limit. For a production app, you'd want to use CKQueryOperation and cursor to paginate the results. For MindDump, this is plenty.




📋 Practical Task

Exercise: Implementing the "Sync Now" Manual Refresh

Currently, our CloudKitManager has the logic to fetch and save, but the UI doesn't trigger it. Your task is to integrate this manager into a SwiftUI View.

  • Create a JournalViewModel that holds an array of JournalEntry objects.
  • Add a function to the ViewModel called syncWithCloud() that calls fetchEntries and updates the published array on the main thread.
  • In your View, add a Button labeled "Sync with iCloud" that triggers this function.
  • Add a simple ProgressView (spinner) that shows up while the fetch is in progress and disappears once the completion handler returns.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.