Using SwiftData Transaction History to Update Widgets
My app Lyrigraphy uses SwiftUI and SwiftData to manage its UI lifecycle and data storage, respectively. I was recently working on adding a Widget extension to it, and was considering how to handle updating the Widget views when the underlying data changes.
My goal was to selectively live update widgets when the underlying data model records are updated. The general flow of events I was envisioning was as follows:
- The app subscribes to data model changes (local change data capture)
- Filter changes to only those relevant to active widgets
- Trigger reload of those widgets
Just interested in the sample code? Check it out here.
Widget Updates
Apple has provided documentation on how widget updates work under the hood; essentially your widget provides a timeline of events to render to the system. The system renders each timeline event statically, your view is not active when it is visible on the screen. APIs are provided to trigger reload of widgets when necessary. This prevents battery drain on the device, especially when there are many active widgets.
Because of this behavior, without intervention my widgets appear stale if the user edits the details of an object. They would have to wait for the system to request new timeline entries for it to refresh, not ideal. My goal was to keep the widget as up to date as possible without negatively impacting performance or getting throttled by system APIs. Thus my preference for tying into data model updates.
For context, my app exposes a single “kind” of widgets in various sizes. Users can configure the widget to display a specific song, or a random song. Most updates to the model are user initiated, but also could be remotely pushed by iCloud sync.
It’s worth noting that WidgetKit’s WidgetCenter API only allows you to trigger a reload of either 1. all widgets, or 2. all widgets of a specific kind. So you have limited granularity on which widgets to reload on demand. The more kinds of widgets you vend, the more flexibility you have. In my case, I could improve performance by separating the song widget into two kinds, “Specific Song” and “Random Song”. There’s also a UX tradeoff here, as more kinds of widgets crowds the list view when you add a widget to your home screen.
You also can’t query timeline events of widgets; my random song widget would always have to reload when any data record changes. This is because we can’t tell which record objects were placed into the widget timeline since they are randomly generated.
SwiftData Transaction History
Apple recently added transaction history API to SwiftData in iOS 18. This allows you to easily query chronological transactions that were made to your data store. This WWDC talk goes into some more details of how it works. I thought this API would be a great fit for my use case. I could consume each transaction to determine if a relevant widget should be updated based on its update.
The transaction history APIs allow to query transactions using a history token as a cursor mechanism. Notably, there is no async/await API to live subscribe to new changes occurring, other than to frequently poll the query API. To bridge the gap I realized I could wait on NotificationCenter notifications for NSPersistentStoreRemoteChange, which notifies for both remote and local changes to the Core Data database.
So to recap the approach before getting into the code:
- Wait for
NSPersistentStoreRemoteChange
notifications - Poll SwiftData transaction history since the last history token
- Filter events to those relevant to widgets
- Reload relevant widget kinds
- Delete old transactions, store new history token
Implementation
First, let’s start by adding a ModelActor for performing these operations:
@ModelActor final actor DataMonitor {
func subscribeToModelChanges() async {
for await _ in NotificationCenter.default.notifications(
named: .NSPersistentStoreRemoteChange
).map({ _ in () }) {
await processNewTransactions()
}
}
...
Now, add our logic for storing and retrieving the history tokens. For simplicity, we’re storing them as JSON serialized strings in NSUserDefaults as recommended by Apple’s documentation. Note that if we don’t have a history token, we’ll try consuming from the start of the transactions table.
func processNewTransactions() async {
let tokenData = UserDefaults.standard.data(forKey: "historyToken")
var historyToken: DefaultHistoryToken? = nil
if let tokenData {
historyToken = try? JSONDecoder().decode(DefaultHistoryToken.self, from: tokenData)
}
let transactions = findTransactions(after: historyToken)
let (updatedModelIds, newHistoryToken) = findUpdatedModelIds(in: transactions)
if let newHistoryToken {
let newTokenData = try? JSONEncoder().encode(newHistoryToken)
UserDefaults.standard.set(newTokenData, forKey: "historyToken")
}
if let historyToken {
try? deleteTransactions(before: historyToken)
}
await maybeUpdateWidgets(relevantTo: updatedModelIds)
}
Now we fetch transactions from the store, this logic is very simple. Similarly, the logic for cleaning up old transactions is just deleting them from the ModelContext.
private func findTransactions(after token: DefaultHistoryToken?) -> [DefaultHistoryTransaction] {
var historyDescriptor = HistoryDescriptor<DefaultHistoryTransaction>()
if let token {
historyDescriptor.predicate = #Predicate { transaction in
(transaction.token > token)
}
}
var transactions: [DefaultHistoryTransaction] = []
do {
transactions = try modelContext.fetchHistory(historyDescriptor)
} catch {
logger.error("Error while fetching history transactions \(error, privacy: .public)")
}
return transactions
}
private func deleteTransactions(before token: DefaultHistoryToken) throws {
var descriptor = HistoryDescriptor<DefaultHistoryTransaction>()
descriptor.predicate = #Predicate {
$0.token < token
}
let context = ModelContext(modelContainer)
try context.deleteHistory(descriptor)
}
With the transactions in hand, I now need to convert the DefaultHistoryTransaction
objects to my actual model class (title SongArtworkViewModel). You can retrieve many details about the transactions, but in my case I just needed the object id
field to determine if they’re relevant to my widget.
private func findUpdatedModelIds(in transactions: [DefaultHistoryTransaction]) -> (Set<UUID>, DefaultHistoryToken?) {
let taskContext = ModelContext(modelContainer)
var updatedModelIds: Set<UUID> = []
for transaction in transactions {
for change in transaction.changes {
let transactionModifiedID = change.changedPersistentIdentifier
let fetchDescriptor = FetchDescriptor<SongArtworkViewModel>(predicate: #Predicate { model in
model.persistentModelID == transactionModifiedID
})
let fetchResults = try? taskContext.fetch(fetchDescriptor)
guard let matchedModel = fetchResults?.first else {
continue
}
switch change {
case .insert(_ as DefaultHistoryInsert<SongArtworkViewModel>):
break
case .update(_ as DefaultHistoryUpdate<SongArtworkViewModel>):
updatedModelIds.update(with: matchedModel.id)
case .delete(_ as DefaultHistoryDelete<SongArtworkViewModel>):
updatedModelIds.update(with: matchedModel.id)
default: break
}
}
}
return (updatedModelIds, transactions.last?.token)
}
Finally, the logic of conditionally updating my widgets. The configuration intents can be cast to your specific configuration intent to retrieve their parameters. For my specific case, I want to reload if a random widget is active, or the specific song used was updated.
private func maybeUpdateWidgets(relevantTo modelIds: Set<UUID>) async {
let configurations = try? await WidgetCenter.shared.currentConfigurations()
guard let configurations else { return }
let relevantConfigurationKinds = configurations.filter { configuration in
let config = configuration.widgetConfigurationIntent(of: SongConfigurationAppIntent.self)
guard let config else {
return false
}
if config.mode == .random {
return true
}
guard let entityId = config.specificSong?.id else {
return false
}
return modelIds.contains(entityId)
}.map { $0.kind }
Array(Set(relevantConfigurationKinds)).forEach { kind in
WidgetCenter.shared.reloadTimelines(ofKind: kind)
}
}
With all the logic completed, I could then add a modifier to my app so that this occurs on startup.
.task {
Task {
let monitor = DataMonitor(modelContainer: ModelContainer.sharedModelContainer)
await monitor.subscribeToModelChanges()
}
}
Build and run and everything would work for sure. …Right?
SwiftData/DataUtilities.swift:1305: Fatal error: Unexpected class type: CodableColor
Womp womp. Unfortunately this has been similar to much of my experience with SwiftData. Hard to decipher fatal errors that you cannot safely catch. In this case my model class was quite complex including several @Attribute
annotations with.transformable(by: ). I’m not sure specifically what was causing this issue, it was hard to reproduce it outside of my app. I ended up refactoring my data model significantly to simplify its structure, and this resolved this crash issue. I may write another follow up article on my data model refactor.
After simplifying the structure of my model, the History Transaction APIs worked as expected! The widget reloads itself when relevant changes are made as expected.
While working on this, I realized I also could just use the simple solution of always reloading all widgets whenever I receive the NSPersistentStoreRemoteChange
notification. In actuality this is much simpler and in my experience does not result in system throttling because most updates are made when the app is in the foreground. But it may be different for your use case if you receive many updates to the model while the app is not in the foreground. And this transactions harness would also be useful for other use cases in the future like exposing app data to Core Spotlight.
If you’re interested in trying this architecture for yourself, you check out my sample code.
Takeaways:
- For WidgetKit refresh granularity, it is better to vend multiple kinds of widgets
- The SwiftData Transaction History APIs can be used in conjunction with the
NSPersistentStoreRemoteChange
notification to trigger off of new updates to your data model - SwiftData’s Transaction History APIs may have issues with very complex data models
- This approach would also be useful for other problem spaces like exposing your SwiftData to Spotlight
Thanks for reading!