2024 App ServicesSwift
WWDC24 · 17 min · App Services / Swift
Track model changes with SwiftData history
Reveal the history of your model’s changes with SwiftData! Use the history API to understand when data store changes occurred, and learn how to use this information to build features like remote server sync and out-of-process change handing in your app. We’ll also cover how you can build support for the history API into a custom data store.
Watch at developer.apple.com ↗Chapters
Code shown on screen · 5 snippets
Preserve values in history on deletion
// Add .preserveValueOnDeletion to capture unique columns
import SwiftData
class Trip {
#Unique<Trip>([\.name, \.startDate, \.endDate])
(.preserveValueOnDeletion)
var name: String
var destination: String
(.preserveValueOnDeletion)
var startDate: Date
(.preserveValueOnDeletion)
var endDate: Date
var bucketList: [BucketListItem] = [BucketListItem]()
var livingAccommodation: LivingAccommodation?
} Fetch transactions from history
private func findTransactions(after token: DefaultHistoryToken?, author: String) -> [DefaultHistoryTransaction] {
var historyDescriptor = HistoryDescriptor<DefaultHistoryTransaction>()
if let token {
historyDescriptor.predicate = #Predicate { transaction in
(transaction.token > token) && (transaction.author == author)
}
}
var transactions: [DefaultHistoryTransaction] = []
let taskContext = ModelContext(modelContainer)
do {
transactions = try taskContext.fetchHistory(historyDescriptor)
} catch let error {
print(error)
}
return transactions
} Process history changes
private func findTrips(in transactions: [DefaultHistoryTransaction]) -> (Set<Trip>, DefaultHistoryToken?) {
let taskContext = ModelContext(modelContainer)
var resultTrips: Set<Trip> = []
for transaction in transactions {
for change in transaction.changes {
let modelID = change.changedPersistentIdentifier
let fetchDescriptor = FetchDescriptor<Trip>(predicate: #Predicate { trip in
trip.livingAccommodation?.persistentModelID == modelID
})
let fetchResults = try? taskContext.fetch(fetchDescriptor)
guard let matchedTrip = fetchResults?.first else {
continue
}
switch change {
case .insert(_ as DefaultHistoryInsert<LivingAccommodation>):
resultTrips.insert(matchedTrip)
case .update(_ as DefaultHistoryUpdate<LivingAccommodation>):
resultTrips.update(with: matchedTrip)
case .delete(_ as DefaultHistoryDelete<LivingAccommodation>):
resultTrips.remove(matchedTrip)
default: break
}
}
}
return (resultTrips, transactions.last?.token)
} Save and use a history token
private func findUnreadTrips() -> Set<Trip> {
let tokenData = UserDefaults.standard.data(forKey: UserDefaultsKey.historyToken)
var historyToken: DefaultHistoryToken? = nil
if let tokenData {
historyToken = try? JSONDecoder().decode(DefaultHistoryToken.self, from: tokenData)
}
let transactions = findTransactions(after: historyToken, author: TransactionAuthor.widget)
let (unreadTrips, newToken) = findTrips(in: transactions)
if let newToken {
let newTokenData = try? JSONEncoder().encode(newToken)
UserDefaults.standard.set(newTokenData, forKey: UserDefaultsKey.historyToken)
}
return unreadTrips
} Update the user interface
struct ContentView: View {
(\.scenePhase) private var scenePhase
private var showAddTrip = false
private var selection: Trip?
private var searchText: String = ""
private var tripCount = 0
private var unreadTripIdentifiers: [PersistentIdentifier] = []
var body: some View {
NavigationSplitView {
TripListView(selection: $selection, tripCount: $tripCount,
unreadTripIdentifiers: $unreadTripIdentifiers,
searchText: searchText)
.toolbar {
ToolbarItem(placement: .topBarLeading) {
EditButton()
.disabled(tripCount == 0)
}
ToolbarItemGroup(placement: .topBarTrailing) {
Spacer()
Button {
showAddTrip = true
} label: {
Label("Add trip", systemImage: "plus")
}
}
}
} detail: {
if let selection = selection {
NavigationStack {
TripDetailView(trip: selection)
}
}
}
.task {
unreadTripIdentifiers = await DataModel.shared.unreadTripIdentifiersInUserDefaults
}
.searchable(text: $searchText, placement: .sidebar)
.sheet(isPresented: $showAddTrip) {
NavigationStack {
AddTripView()
}
.presentationDetents([.medium, .large])
}
.onChange(of: selection) { _, newValue in
if let newSelection = newValue {
if let index = unreadTripIdentifiers.firstIndex(where: {
$0 == newSelection.persistentModelID
}) {
unreadTripIdentifiers.remove(at: index)
}
}
}
.onChange(of: scenePhase) { _, newValue in
Task {
if newValue == .active {
unreadTripIdentifiers += await DataModel.shared.findUnreadTripIdentifiers()
} else {
// Persist the unread trip names for the next launch session.
await DataModel.shared.setUnreadTripIdentifiersInUserDefaults(unreadTripIdentifiers)
}
}
}
#if os(macOS)
.onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in
Task {
unreadTripIdentifiers += await DataModel.shared.findUnreadTripIdentifiers()
}
}
.onReceive(NotificationCenter.default.publisher(for: NSApplication.willTerminateNotification)) { _ in
Task {
await DataModel.shared.setUnreadTripIdentifiersInUserDefaults(unreadTripIdentifiers)
}
}
#endif
}
} Resources
Related sessions
-
14 min