Fundamentals
스토어에 일어나 변경을 질의하고 추척한다
모델이 저장될 떄마다 transaction이 저장되고, 여기에 변경에 대한 메타데이터가 다 들어 있다.
Transaction이 일어난 순서대로 정렬되어 있고 일어난 변경들을 그룹화하고 있다
토큰이라는 개념을 사용해서 transaction의 북마크처럼 사용한다.
모델이 삭제되면 데이터가 버려지는데, 이때 히스토리를 처리하기 위해서 필요한 데이터가 같이 삭제될 수 있다.
그래서 히스토리는 특정 attribute를 tombstone value로 따로 남겨서 삭제된 모델에 대한 처리를 할 수 있게 한다.
preserveValueOnDeletion attribute를 사용하면 된다.
import SwiftData
@Model
class Trip {
#Unique<Trip>([\\.name, \\.startDate, \\.endDate])
@Attribute(.preserveValueOnDeletion)
var name: String
var destination: String
@Attribute(.preserveValueOnDeletion)
var startDate: Date
@Attribute(.preserveValueOnDeletion)
var endDate: Date
var bucketList: [BucketListItem] = [BucketListItem]()
var livingAccommodation: LivingAccommodation?
}
Transactions and changes
HistoryDescriptor로 히스토리 가져오기
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
}
변경 사항 처리
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)
}
UI 업데이트
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
}
struct ContentView: View {
@Environment(\\.scenePhase) private var scenePhase
@State private var showAddTrip = false
@State private var selection: Trip?
@State private var searchText: String = ""
@State private var tripCount = 0
@State 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
}
}
Custom stores