Search code examples
swiftswiftuiswiftdata

Changing the non-existent Swift Data object changes the database and updates the views


Application description:

There is a TabView, on different tabs from Swiftdata, data are loaded and somehow modified (@Transient fields) for a particular View

On one of the tabs there is NavigationLink, which leads to the page of creating an element I use .isAutoSaving: false

enum mode {
    case create, update
}

struct EditAccount: View {
    
    @Environment(\.dismiss) var dismiss
    @Environment(\.modelContext) var modelContext
    @Query var currencies: [Currency]
    @Bindable var account: Account
    var oldAccount: Account = Account()
    
    var mode: mode
    
    init(_ account: Account) {
        mode = .update
        self.oldAccount = account
        _account = .init(wrappedValue: account)
    }
    
    init(accountType: AccountType) {
        mode = .create
        _account = .init(wrappedValue: Account(
                id: UInt32.random(in: 10000..<10000000),
                type: accountType
            )
        )
    }
        
    var body: some View {
        Form {
            Section {
                TextField("Account name", text: $account.name)
                TextField("Budget", value: $account.budget, format: .number)
                    .keyboardType(.decimalPad)
            }
            Section {
                if mode == .create {
                    Picker("Currency", selection: $account.currency) {
                        ForEach(currencies) { currency in
                            Text(currency.isoCode)
                                .tag(currency as Currency?)
                        }
                    }
                }
            }
            Section {
                Button("Save") {
                    Task {
                        dismiss()
                        switch mode {
                        case .create:
                            await createAccount()
                        case .update:
                            await updateAccount()
                        }
                    }
                }
            }
            .frame(maxWidth: .infinity)
        }
        .navigationTitle(mode == .create ? "Create account" : "Update account")
    }

    func createAccount() async {
        do {
            let id = try await AccountAPI().CreateAccount(req: CreateAccountReq(...)
            account.id = id
            try modelContext.save()
        } catch {
            modelContext.rollback()
        }
    }
    
    func updateAccount() async {
        do {
            try await AccountAPI().UpdateAccount(req: UpdateAccountReq(
                id: account.id,
                accounting: oldAccount.accounting == account.accounting ? account.accounting : nil,
                // same code
            )
            try modelContext.save()
        } catch {
            modelContext.rollback()
        }
    }
}

When I launch this code, I load other tabs and put print() on the functions where objects are modifying for View, I see in the console for each change in the new (Mode = .create) account, how can I change this behavior?

Also, if I choose Currencies in a new account picketer, then even before saving this account in the next tab in the list of accounts, my account will appear, which has not yet been saved

I want to make sure that I have not pressed on Save, the new element does not exist in the database, do I have the opportunity to make such behavior?

Because with each change in the amount or currency in the picker I feel the freezing of the interface, because other screens are recalculated

Also, please tell me how you can make a copy of the database element before changing for comparison whether there were changes in a specific field or not (this is necessary for the implementation of logic PATCH HTTP method)?


Solution

  • The reason why you directly see new objects before saving is because you are using the same ModelContext object in all of your views. So the way to handle the edit (or creation) of objects is to use a separate ModelContext in the view used for editing.

    So remove the @Environment property in the EditAccount and replace it with an ordinary property

    private let modelContext: ModelContext
    

    Then you create an instance in the init from the apps main ModelContainer object

    init(_ account: Account, modelContainer: ModelContainer) {
        modelContext = ModelContext(modelContainer)
        modelContext.autosaveEnabled = false
        //... 
    }
    

    Since we have a separate context new you should load the account from this context in the init

    init(_ account: Account, modelContainer: ModelContainer) {
        modelContext = ModelContext(modelContainer)
        modelContext.autosaveEnabled = false
        
        mode = .update
        guard let model = modelContext.model(for: account.persistentModelID) as? Account else {
            // this should never happen since we are not dealing with concurrency here 
            // so it's up to you if you want some better error handling
            fatalError()   
        }
        self.account = model
    }
    

    And now nothing will happen to your main context until you do

    try modelContext.save()
    

    in this view.

    In my solution for this I only pass the id of the object to the init but since you want to compare the account for changes I guess this way is better and you should just store a reference to the old account as you do now

    self.oldAccount = account
    

    But remember to do the comparison before you call save() :)