swiftuiswiftdata

What is the best way to check if a SwiftData model is updated in SwiftUI


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
    }
}

Solution

  • 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.