To quote "Framework Engineer" from a similar question in the Apple Developer forums: "This is a fallacy". In a distributed system, you can't truly know if "sync is complete", as another device, which could be online or offline at the moment, could have unsynced changes.
That said, here are some techniques you can use to implement the use cases that tend to drive the desire to know the state of sync.
Adding default/sample data
Give them a button to add specific default/sample data rather than automatically adding it to the app. This both works better in a distributed environment, and makes the distinction between your app's functionality and the sample data clearer.
For example, in one of my apps, the user can create a list of "contexts" (e.g. "Home", "Work") into which they can add actions to do. If the user is using the app for the first time, the list of "Contexts" would be empty. This is fine, as they could add contexts, but it would be nice to provide some defaults.
Rather than detect first launch and add default contexts, I added a button that appears only if there are no contexts in the database. That is, if the user navigates to the "Next Actions" screen, and there are no contexts (i.e. contexts.isEmpty
), then the screen also contains a "Add Default GTD Contexts" button. The moment a context is added (either by the user or via sync), the button disappears.
Here's the SwiftUI code for the screen:
import SwiftUI
/// The user's list of contexts, plus an add button
struct NextActionsLists: View {
/// The Core Data enviroment in which we should perform operations
@Environment(\.managedObjectContext) var managedObjectContext
/// The available list of GTD contexts to which an action can be assigned, sorted alphabetically
@FetchRequest(sortDescriptors: [
NSSortDescriptor(key: "name", ascending: true)]) var contexts: FetchedResults<ContextMO>
var body: some View {
Group {
// User-created lists
ForEach(contexts) { context in
NavigationLink(
destination: ContextListActionListView(context: context),
label: { ContextListCellView(context: context) }
).isDetailLink(false)
.accessibility(identifier: "\(context.name)") // So we can find it without the count
}
.onDelete(perform: delete)
ContextAddButtonView(displayComplicationWarning: contexts.count > 8)
if contexts.isEmpty {
Button("Add Default GTD Contexts") {
self.addDefaultContexts()
}.foregroundColor(.accentColor)
.accessibility(identifier: "addDefaultContexts")
}
}
}
/// Deletes the contexts at the specified index locations in `contexts`.
func delete(at offsets: IndexSet) {
for index in offsets {
let context = contexts[index]
context.delete()
}
DataManager.shared.saveAndSync()
}
/// Adds the contexts from "Getting Things Done"
func addDefaultContexts() {
for name in ["Calls", "At Computer", "Errands", "At Office", "At Home", "Anywhere", "Agendas", "Read/Review"] {
let context = ContextMO(context: managedObjectContext)
context.name = name
}
DataManager.shared.saveAndSync()
}
}
Preventing changes/conflicts
This should be done via your data model. To use the example from WWDC2019, say you're writing a blogging app, and you have a "posts" entity:
Post
----
content: String
If the user modifies "content" on two devices at the same time, one will overwrite the other.
Instead, make content a "contribution":
Content
-------
post: Post
contribution: String
Your app would then read the contributions and merge them using a strategy appropriate for your app. The easiest/laziest approach would be to use a modifiedAt date and choose the last one.
For the app I mentioned above, I chose a couple of strategies:
- For simple fields, I just included them in the entity. Last writer wins.
- For notes (i.e. big strings - lots of data to lose), I created a a relationship (multiple notes per item), and allowed the user to add multiple notes to an item (which are automatically timestamped for the user). This both solves the data model issue and adds a Jira-comment-like feature for the user. Now, the user could edit an existing note, in which case the last device to write a change "wins".
Displaying "first-run" (e.g. onboarding) screens
I'll give a couple of approaches to this:
Store a first-run flag in UserDefaults. If the flag isn't there, display your first-run screens. This approach makes your first-run a per-device thing. Give the user a "skip" button too. (Example code from Detect first launch of iOS app)
let launchedBefore = UserDefaults.standard.bool(forKey: "launchedBefore")
if launchedBefore {
print("Not first launch.")
} else {
print("First launch, setting UserDefault.")
UserDefaults.standard.set(true, forKey: "launchedBefore")
}
Set up a FetchRequestController on a table that will definitely have data in it if the user's used your app before. Display your first-run screens if the results of your fetch are empty, and remove them if your FetchRequestController fires and has data.
I recommend the UserDefaults approach. It's easier, it's expected if the user just installed your app on a device, and it's a nice reminder if they installed your app months ago, played with it for a bit, forgot, got a new phone, installed your app on it (or found it auto-installed), and ran it.
Misc
For completeness, I'll add that iOS 14 and macOS 11 add some notifications/publishers to NSPersistentCloudKitContainer that let your app be notified when sync events happen. Although you can (and probably should) use these to detect sync errors, be careful about using them to detect "sync is complete".
Here's an example class using the new notifications.
import Combine
import CoreData
@available(iOS 14.0, *)
class SyncMonitor {
/// Where we store Combine cancellables for publishers we're listening to, e.g. NSPersistentCloudKitContainer's notifications.
fileprivate var disposables = Set<AnyCancellable>()
init() {
NotificationCenter.default.publisher(for: NSPersistentCloudKitContainer.eventChangedNotification)
.sink(receiveValue: { notification in
if let cloudEvent = notification.userInfo?[NSPersistentCloudKitContainer.eventNotificationUserInfoKey]
as? NSPersistentCloudKitContainer.Event {
// NSPersistentCloudKitContainer sends a notification when an event starts, and another when it
// ends. If it has an endDate, it means the event finished.
if cloudEvent.endDate == nil {
print("Starting an event...") // You could check the type, but I'm trying to keep this brief.
} else {
switch cloudEvent.type {
case .setup:
print("Setup finished!")
case .import:
print("An import finished!")
case .export:
print("An export finished!")
@unknown default:
assertionFailure("NSPersistentCloudKitContainer added a new event type.")
}
if cloudEvent.succeeded {
print("And it succeeded!")
} else {
print("But it failed!")
}
if let error = cloudEvent.error {
print("Error: \(error.localizedDescription)")
}
}
}
})
.store(in: &disposables)
}
}
CKContainer.default().privateCloudDatabase
performing a query to the CoreData record but no luck. – Plotinus