CloudKit stores app data in iCloud, syncs it across the user's devices, and can share it between users. Enable it under Signing and Capabilities, which creates a container holding three databases: public (all users), private (per user, counts against their iCloud storage), and shared (data others shared with the user).
Check the Account First
Saving fails when no Apple ID is signed in or iCloud Drive is off, so check
CKContainer.default().accountStatus() before touching the database and alert when it is not
.available.
final class CloudKitService {
func checkAccountStatus() async throws -> CKAccountStatus {
try await CKContainer.default().accountStatus()
}
}
Saving Records
Create the record type in the CloudKit Console schema first. In code, model the value as a
struct and convert it to a CKRecord, keeping field names in one enum so they cannot drift.
struct Fasting: Hashable {
var start: Date
var end: Date
var goal: TimeInterval
}
enum FastingRecordKeys: String {
case type = "Fasting"
case start, end, goal
}
extension Fasting {
var record: CKRecord {
let record = CKRecord(recordType: FastingRecordKeys.type.rawValue)
record[FastingRecordKeys.goal.rawValue] = goal
record[FastingRecordKeys.start.rawValue] = start
record[FastingRecordKeys.end.rawValue] = end
return record
}
}
extension CloudKitService {
func save(_ record: CKRecord) async throws {
try await CKContainer.default().privateCloudDatabase.save(record)
}
}
Fetching with CKQuery
Queries fail silently without indexes: mark each queried or sorted field in the dashboard under
Schema, then Indexes. Add a failable initializer from CKRecord and query with an
NSPredicate.
extension Fasting {
init?(from record: CKRecord) {
guard
let start = record[FastingRecordKeys.start.rawValue] as? Date,
let end = record[FastingRecordKeys.end.rawValue] as? Date,
let goal = record[FastingRecordKeys.goal.rawValue] as? TimeInterval
else { return nil }
self = .init(start: start, end: end, goal: goal)
}
}
extension CloudKitService {
func fetchFastingRecords(in interval: DateInterval) async throws -> [Fasting] {
let predicate = NSPredicate(
format: "\(FastingRecordKeys.start.rawValue) >= %@ AND \(FastingRecordKeys.end.rawValue) <= %@",
interval.start as NSDate,
interval.end as NSDate
)
let query = CKQuery(recordType: FastingRecordKeys.type.rawValue, predicate: predicate)
query.sortDescriptors = [.init(key: FastingRecordKeys.end.rawValue, ascending: true)]
let result = try await CKContainer.default().privateCloudDatabase.records(matching: query)
let records = result.matchResults.compactMap { try? $0.1.get() }
return records.compactMap(Fasting.init)
}
}
In the view, .task fetches on appear, .refreshable re-fetches, and
.redacted(reason: isLoading ? .placeholder : []) covers the loading state.
Environments
Debug builds use the development environment automatically. Deploy the schema to production in the CloudKit dashboard before shipping to TestFlight or the App Store, or production queries hit an empty schema.