I have a view hierarchy of SwiftUI objects and would like to efficiently update whenever the SwiftData object has changed. The issue I have is, I am using a isLoading
flag to update certain properties in a separate thread so that the updates are not always triggered. However, this flag is set back to false
after the update and does not get triggered if the SwiftData object is updated when using a .refreshable
modifier for example.
Here is a code segment within in an Asset in the example code below (included a fully functional example) where I am trying to update the properties
.task { //how do I check if the SwifData model has changed so that I can update balance?
guard isLoading else { return }
balance = asset.balance
isLoading = false
}
I created a fully functional example using a simple hierarchy: Portfolio:Account:Asset. In the example below, I have a Portfolio with 2 accounts and each account has 2 assets. It is displayed using a NavigationStack
. When you navigate to the Asset
view, you can pull to refresh and it updates the balance using a random change factor. the updated balance is not reflected unless you navigate back to the parent view and then navigate forward to the child view.
Appreciate very much!
The entire code is below if you would like to run it:
import SwiftUI
import SwiftData
struct ContentView: View {
@Environment(\.modelContext) private var context
@EnvironmentObject var router: Router
@State private var navPath = NavigationPath()
@Query var portfolios: [Portfolio]
var body: some View {
if portfolios.isEmpty {
let b = loadPortfolio()
VStack {
Image(systemName: "globe")
.imageScale(.large)
.foregroundStyle(.tint)
Text("Hello, world!")
}
} else {
NavigationStack(path: $router.path) {
PortfolioView(portfolioID: portfolios[0].persistentModelID)
.navigationTitle(portfolios[0].name!)
.navigationDestination(for: Account.self) { account in
AccountView(accountID: account.persistentModelID)
.navigationTitle(account.name!)
}
.navigationDestination(for: Asset.self) { asset in
AssetView(assetID: asset.persistentModelID)
.navigationTitle(asset.name!)
}
}
}
}
// Load some sample values in the portfolio
private func loadPortfolio() -> Double {
let portfolio = Portfolio(name: "Portfolio 1", accounts: [])
context.insert(portfolio)
var accounts : [Account] = []
let account1 = Account(name: "Account 1", assets: [])
accounts.append(account1)
context.insert(account1)
let account2 = Account(name: "Account 2", assets: [])
accounts.append(account2)
context.insert(account2)
let asset1 = Asset(name: "Asset 1", balance: 100)
let asset2 = Asset(name: "Asset 2", balance: 200)
context.insert(asset1)
context.insert(asset2)
let assets1 = [asset1, asset2]
account1.add(assets: assets1)
let asset3 = Asset(name: "Asset 3", balance: 300)
let asset4 = Asset(name: "Asset 4", balance: 400)
context.insert(asset3)
context.insert(asset4)
let assets2 = [asset3, asset4]
account2.add(assets: assets2)
portfolio.add(accounts: accounts)
try! context.save()
return portfolio.balance
}
}
struct PortfolioView: View {
@Environment(\.modelContext) private var context
@State private var isLoading: Bool = true
@State private var balance: Double = 0
let portfolioID: PersistentIdentifier
@Query private var portfolios: [Portfolio]
init(portfolioID: PersistentIdentifier) {
self.portfolioID = portfolioID
_portfolios = Query(filter: #Predicate<Portfolio> { $0.persistentModelID == portfolioID })
}
var body: some View {
if let portfolio = portfolios.first {
List {
Section {
ForEach(portfolio.accounts) { account in
NavigationLink(destination: AccountView(accountID: account.persistentModelID)) {
HStack(alignment: .center) {
Text(account.name!)
Text(": $\(account.balance, specifier: "%.2f")")
}
}
}
}
header: {
Text("Accounts")
}
footer: {
Text("Total: \(balance, format: .currency(code: Locale.current.currency?.identifier ?? "USD"))")
}
}
.task { //how do I check if the SwifData model has changed so that I can update balance?
guard isLoading else { return }
balance = portfolio.balance
isLoading = false
}
}
}
}
struct AccountView: View {
@Environment(\.modelContext) private var context
let accountID: PersistentIdentifier
@State private var balance: Double = 0
// Query to fetch the specific object by id
@State private var isLoading = true
@Query private var accounts: [Account]
init(accountID: PersistentIdentifier) {
self.accountID = accountID
_accounts = Query(filter: #Predicate<Account> { $0.persistentModelID == accountID })
}
var body: some View {
if let account = accounts.first {
List {
Section {
ForEach(account.assets) { asset in
NavigationLink(destination: AssetView(assetID: asset.persistentModelID)) {
HStack(alignment: .center) {
Text(asset.name!)
Text(": $\(asset.balance, specifier: "%.2f")")
}
}
}
}
header: {
Text("Assets")
}
footer: {
Text("Total: \(balance, format: .currency(code: Locale.current.currency?.identifier ?? "USD"))")
}
}
.task { //how do I check if the SwifData model has changed so that I can update balance?
guard isLoading else { return }
balance = account.balance
isLoading = false
}
}
}
}
struct AssetView: View {
@Environment(\.modelContext) private var context
let assetID: PersistentIdentifier
@State private var isLoading = true
@State private var balance: Double = 0
// Query to fetch the specific object by id
@Query private var assets: [Asset]
init(assetID: PersistentIdentifier) {
self.assetID = assetID
_assets = Query(filter: #Predicate<Asset> { $0.persistentModelID == assetID })
}
var body: some View {
if let asset = assets.first {
ScrollView {
HStack(alignment: .center) {
Text(asset.name!)
Text(": $\(balance, specifier: "%.2f")")
}
}
.refreshable {
Task {
if let asset = assets.first {
let random = Double.random(in: 0...2)
let changeFactor = random
let currentBalance = asset.balance
asset.balance = asset.balance * changeFactor
let account = asset.account
account?.balance += asset.balance - currentBalance
let portfolio = asset.account?.portfolio
portfolio?.balance += asset.balance - currentBalance
try! context.save()
}
}
}
.task { //how do I check if the SwifData model has changed so that I can update balance?
guard isLoading else { return }
balance = asset.balance
isLoading = false
}
}
else {
Text("No assets available")
}
}
}
#Preview {
ContentView()
}
class Router: ObservableObject {
@Published var path = NavigationPath()
func reset() {
path = NavigationPath()
}
}
@Model
final class Portfolio {
@Attribute(.unique) var id = UUID().uuidString
var name: String?
var balance: Double = 0
@Relationship(deleteRule: .cascade)
var accounts: [Account] = []
init(name: String, accounts: [Account]) {
self.name = name
self.accounts = accounts
}
func add(account: Account) {
if (!accounts.contains(account)) {
accounts.append(account)
balance = balance + account.balance
}
}
func add(accounts: [Account]) {
for account in accounts {
add(account: account)
}
}
}
@Model
final class Account {
@Attribute(.unique) var id = UUID().uuidString
var name: String?
var balance: Double = 0
@Relationship(inverse: \Portfolio.accounts)
var portfolio: Portfolio?
@Relationship(deleteRule: .cascade)
var assets: [Asset] = []
init(name: String, assets: [Asset]) {
self.name = name
if !assets.isEmpty {
self.assets = assets
}
}
func add(asset: Asset) {
if (!assets.contains(asset)) {
assets.append(asset)
balance = balance + asset.balance
}
}
func add(assets: [Asset]) {
for asset in assets {
add(asset: asset)
}
}
}
@Model
final class Asset {
@Attribute(.unique) var id = UUID().uuidString
@Relationship(inverse: \Account.assets)
var account: Account?
var name: String?
var balance: Double = 0
init(name: String, balance: Double) {
self.name = name
self.balance = balance
}
}
You can listen to the didSave
notifications that is now sent by ModelContext
after a save.
The notification objects has a userInfo
dictionary with keys "inserted", "updated" and "deleted" and the values are arrays of PersistentIdentifier
so in the AssetView
we can add an onReceive
modifier for the notification and check for the id of the asset.
.onReceive(NotificationCenter.default.publisher(for: ModelContext.didSave)) { notification in
if let updates = notification.userInfo?[ModelContext.NotificationKey.updatedIdentifiers.rawValue] as? [PersistentIdentifier],
updates.contains(asset.persistentModelID) {
isLoading.toggle()
}
}
Then I changed the task modifier to task(id:)
in the same view
.task(id: isLoading) {
guard isLoading else { return }
balance = asset.balance
isLoading = false
}
And finally, instead of passing an id and then use a @Query
I passed the whole object instead to the sub views. In the AccountView
I let
declared it since the account wasn't modified
let account: Account
But in the AssetView
I used @Bindable
since the object was modified in this view.
@Bindable var asset: Asset
This of course changes all the calls to the views and removes the need for custom init methods.