swiftswiftuiswiftdata

Content inside .sheet() being infinitely called


I have another project with almost identical setup without this problem, so I'm scratching my head as to why in this project, whenever a sheet is presented, the content code is being repeatedly called

struct TransactionsView: View {

    @Environment(\.modelContext) private var modelContext
    
    let sheet: Sheet
    
    @Query private var transactions: [Transaction]
    
    @State private var showCreate = false

    init(sheet: Sheet) {
        // init code to query for transactions matching Sheet ID
    }

    var body: some View {
        List {
            ForEach(transactions) { tx in
                // Standard row stuff
            }
            .onDelete(perform: deleteTxs)
        }
        .toolbar {
            ToolbarItem {
                Button(action: addTransaction) {
                    Label("Add", systemImage: "plus")
                }
            }
        }
        .sheet(isPresented: $showCreate) {
            TransactionCreateView(sheet: self.sheet)
            .presentationDetents([.medium, .large])
        }
    }
    
    private func addTransaction() {
        withAnimation {
            showCreate.toggle()
        }
    }
    
    private func deleteTxs(offsets: IndexSet) {
        withAnimation {
            for index in offsets {
                modelContext.delete(transactions[index])
            }
        }
    }
}

The sheet view

struct TransactionCreateView: View {
    
    @Environment(\.dismiss) var dismiss
    @Environment(\.modelContext) private var modelContext
    
    @State private var transaction: Transaction
    @State private var notesText: String = ""

    let sheet: Sheet
    
    init(sheet: Sheet) {
        self.sheet = sheet
        self.transaction = Transaction(sheet: self.sheet)
    }
    
    var body: some View {
        Form {
            Picker("Type", selection: $transaction.type) {
                ForEach(TransactionType.allCases, id: \.self) { type in
                    Text(type.rawValue.capitalized)
                        .tag(type)
                }
            }
            .pickerStyle(.segmented)
            
            TextField("Amount", value: $transaction.value, format: .number)

            TextField("Notes", text: $notesText, prompt: Text("What was this for?"))

            Button("Add") {
                withAnimation {
                    transaction.notes = notesText.isEmpty ? nil : notesText
                    modelContext.insert(transaction)
                }
                dismiss()
            }
        }
        .onAppear {
            notesText = transaction.notes ?? ""
        }
    }
}

Model files

@Model
final class Transaction {
    var timestamp: Date
    var value: Double
    var type: TransactionType
    var notes: String?
    
    @Relationship(inverse: \Sheet.transactions) var sheet: Sheet?
}

enum TransactionType: String, Codable, CaseIterable {
    case income
    case expense
}
@Model
final class Sheet {
    var timestamp: Date
    var title: String
    
    var transactions: [Transaction]
}

I have narrowed down the issue to TransactionCreateView(sheet: self.sheet) being called repeatedly (verified via breakpoint), but I'm not sure why. I'm guessing that somehow somewhere is causing something to be redrawn repeatedly. My other project also has .sheet() attached in this similar fashion yet doesn't experience this issue.


Solution

  • The reason that the init get's called in an infinite loop is that in the init of the view you creates a new Transaction object and sets the relationship property sheet. Now since the passed Sheet object is already persisted (inserted in the ModelContext) then SwiftData will automatically insert the new Transaction object into the ModelContext instance (because otherwise the data would be inconsistent if only one end of the relationship existed in the context).

    This insert of the new transaction will trigger the @Query in the parent view and a redraw in which the child view gets called again and you have an infinite loop.

    Furthermore it's worth noting that since the transaction is always inserted there is no way for the user to regret adding a transaction by pressing escape when the sheet is open.

    The simple solution is to not assign the sheet when creating the transaction and instead doing that in the action for the "Add" button.

    init(sheet: Sheet) {
        self.sheet = sheet
        transaction = Transaction(sheet: nil)
    }
    

    But maybe a better solution since you don't have so many properties is to instead create local @State properties and create and insert the Transaction in the "Add" button action

    @State private var type: TransactionType = .income
    @State private var value: Double = .zero
    @State private var notesText: String = ""
    let sheet: Sheet
    
    var body: some View {
        Form {
            Picker("Type", selection: $type) {
                ForEach(TransactionType.allCases, id: \.self) { type in
                    Text(type.rawValue.capitalized)
                        .tag(type)
                }
            }
            .pickerStyle(.segmented)
            
            TextField("Amount", value: $value, format: .number)
            TextField("Notes", text: $notesText, prompt: Text("What was this for?"))
            Button("Add") {
                withAnimation {
                    let transaction = Transaction(timestamp: .now,
                                                  value: value,
                                                  type: type,
                                                  notes: notesText.isEmpty ? nil : notesText,
                                                  sheet: self.sheet)
                    modelContext.insert(transaction)
                }
                dismiss()
            }
        }
    }