0

I'm working on an app where I have a view AddMonthView where you put in the monthly balance for financial accounts. That account list is dynamic. So I spent some time trying to figure out how to bind the balance input for each Account and got to a point where the UI works and it looks like the binding works however when I click into a TextField it creates a new duplicate line item with the same account name and balance number and I don't know why this is happening. If I click back into either of those line items then it modifies the other and never creates a third duplicate line item. Another issue I'm seeing is when I try to default a balance amount in the init it doesn't actually set that value and instead the UI is showing it as nil. I'm not sure, but guessing the issue could be with this line: _accountBalances = State(initialValue: tempAccountBalances) but I don't know if there is another way I should be initializing a @State property with passed in data. For what it's worth accounts can also be queried. Currently it's being queried in the parent view and being passed in.

EDIT: I tried updating my Dictionary to be [String : Double] and that seemed to fix the duplication issue, so it must be with my Account model?

import Foundation
import SwiftData
import SwiftUI

struct AddMonthView: View {
    @Environment(\.modelContext) private var modelContext
    
    @Binding var showAddMonth: Bool
    @State var accountBalances = [Account : Double]()
    
    init (showAddMonth: Binding<Bool>, accounts: [Account], date: Date) {
        self._showAddMonth = showAddMonth
        
        var tempAccountBalances = [Account: Double]()
        for account in accounts {
            tempAccountBalances[account] = 13 //defaulting to any number doesn't appear to work
        }
         
        _accountBalances = State(initialValue: tempAccountBalances)
    }
    
    var body: some View {
        NavigationStack {
            Form {
                List {
                    ForEach(accountBalances.keys.sorted(), id: \.self) {account in
                        HStack {
                            Text(account.name)
                                .frame(maxWidth: .infinity, alignment: .leading)
                            
                            TextField(value: $accountBalances[account], format: .currency(code: Locale.current.currency?.identifier ?? "USD")) {
                                Text("Balance")
                            }
                            .keyboardType(.decimalPad)
                            .submitLabel(.done)
                        }
                    }
                }
            }
            .navigationTitle("New Month").navigationBarTitleDisplayMode(.inline)
            .toolbar {
                ToolbarItem(placement: .topBarTrailing) {
                    Button(action: saveMonth) {
                        Label("Add Month", systemImage: "checkmark")
                    }
                }
                ToolbarItem(placement: .topBarLeading) {
                    Button("Cancel", role: .cancel) {
                        self.showAddMonth.toggle()
                    }
                }
            }
        }
    }
    
    func saveMonth() {
        
    }
}

#Preview {
    let date = Calendar.current.date(byAdding: .year, value: 1, to: Date())
        
    AddMonthView(showAddMonth: .constant(true), accounts: Account.sampleData, date: Date())
        .modelContainer(SampleData.shared.modelContainer)
}

enter image description here

3
  • Maybe you get the duplicates issue because you have id: \.self in the ForEach, try to remove altogether or replace it with id: \.id (I am assuming Account is a SwiftData model) Commented Oct 3 at 17:09
  • @JoakimDanielson changing it to id: \.id and completely removing it yields the same duplicate result. Interestingly though the more I test this it seems like sometimes random values will get the default I set in the init and when I modify those they don't duplicate Commented Oct 3 at 17:15
  • Self isn't a valid id keypath Commented Oct 3 at 23:43

1 Answer 1

2

I have not tested your code but here's a few things I can see:

First problem

Okay first of all, I try to avoid when initializing the state properties like you are doing, with _accountBalances = State(initialValue: tempAccountBalances). Instead of this, I would pass the important data (accounts: [Account], date: Date) into the initializer, like you are doing, and initialize the accountBalances as an empty dictionary. Then in an .onAppear, or even .onChange(of:<accounts or dates>, initial), use the account and date to form the accountBalances. From my experience, the .onChange modifier is the best to use because it will refresh the accountBalances state variable, as soon as the view loads, as well as updates it when either of the account or dates change, which accountBalances is dependent on.

The pseudocode for this would look like this (I.e. it might contain a few syntax errors or incorrect labels, it's just from memory):

struct AddMonthView: View {
    ...
    @Binding var showAddMonth: Bool
    let accounts: [Account]
    let date: Date
    // Make this private so it cannot be passed in from another view
    // You are already initializing this as an empty dictionary
    @State private var accountBalances = [Account : Double]()
    
    // Don't need an init.
    
    var body: some View {
        NavigationStack {
            Form {
                ...
            }
            .navigationTitle ...
            .toolbar {
                ...
            }
            .onChange(of: (accounts, date), initial: true, action: updateBalances)
        }
    }
    
    ...

    private func updateBalances() {
        var tempAccountBalances = [Account: Double]()
        for account in accounts {
            tempAccountBalances[account] = 13 //defaulting to any number should work
        }
         
        accountBalances = tempAccountBalances
    }
}

Alternative approach

From the looks of it, you are needing this process of loading a accountBalances property from data passed in, just so you have something to bind the text fields to. Then, I'm guessing you need to synchronize the modified accountBalances with accounts in saveMonth to persist changes made by the text fields outside the view.

One other approach you can do is using a custom binding. I won't explain the exact syntax here, but I will refer you to a Swiftful Thinking video on this on YouTube -- it is very helpful and explains the syntax you'll see below, and more. But just know that with this method, you can bind the text field directly to Account.

struct AddMonthView: View {
    @Environment(\.modelContext) private var modelContext
    
    @Binding var showAddMonth: Bool
    @Binding var accounts: [Account]
    let date: Date
    
    var body: some View {
        NavigationStack {
            Form {
                List {
                    ForEach(Array(accounts.enumerated()), id: \.offset) { index, account in
                        HStack {
                            Text(accounts[index].name)
                                .frame(maxWidth: .infinity, alignment: .leading)
                            
                            TextField(value: Binding(get: { accounts[index].balance }, set: { new in accounts[index].balance = new }, format: .currency(code: Locale.current.currency?.identifier ?? "USD"))) {
                                Text("Balance")
                            }
                            .keyboardType(.decimalPad)
                            .submitLabel(.done)
                        }
                    }
                }
            }
            ....
        }
    }
    
    ...
}

#Preview {
    let date = Calendar.current.date(byAdding: .year, value: 1, to: Date())
        
    AddMonthView(showAddMonth: .constant(true), accounts: Account.sampleData, date: Date())
        .modelContainer(SampleData.shared.modelContainer)
}

I don't know of any reason your text field rows are duplicated, but with both these things I tested the code out and it seemed to run fine. Here is the code for your reference:


import SwiftUI
import SwiftData

struct Account {
    let name: String
    var balance: Double
    
    static var sampleData: [Self] {
        [
            .init(name: "Test 1", balance: 0),
            .init(name: "Test 2", balance: 0),
            .init(name: "Test 3", balance: 0),
            .init(name: "Test 4", balance: 0),
            .init(name: "Test 5", balance: 0),
            .init(name: "Test 6", balance: 0),
            .init(name: "Test 7", balance: 0),
            .init(name: "Test 8", balance: 0),
            .init(name: "Test 9", balance: 0),
        ]
    }
}

struct AddMonthView: View {
//    @Environment(\.modelContext) private var modelContext
    
    @Binding var showAddMonth: Bool
    @Binding var accounts: [Account]
    let date: Date
    
    var body: some View {
        NavigationStack {
            Form {
                List {
                    ForEach(Array(accounts.enumerated()), id: \.offset) {
                        index,
                        account in
                        HStack {
                            Text(accounts[index].name)
                                .frame(maxWidth: .infinity, alignment: .leading)
                            
                            TextField(
                                value: Binding(
                                    get: {
                                        accounts[index].balance
                                    },
                                    set: {
                                        new in accounts[index].balance = new
                                    }),
                                format: .currency(
                                    code: Locale.current.currency?.identifier ?? "USD"
                                )
                            ) {
                                Text("Balance")
                            }
                            .keyboardType(.decimalPad)
                            .submitLabel(.done)
                        }
                    }
                }
            }
        }
    }
    
}

#Preview {
    @Previewable @State var a = Account.sampleData
    let date = Calendar.current.date(byAdding: .year, value: 1, to: Date())
    
    AddMonthView(showAddMonth: .constant(true), accounts: $a, date: Date())
//        .modelContainer(SampleData.shared.modelContainer)
}
Sign up to request clarification or add additional context in comments.

2 Comments

I'll give this a try, I would add a balance property to my Account object but the way my data model is set up is I have a MonthSummary object that represents each month you add your balances, an Account object that represents the individual financial accounts you have, then a MonthlyBalance object that has a relationship field to both Account and MonthSummary and MonthlyBalance is where the balance number is stored
Using the .onAppear() worked!

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.