swift - Content inside .sheet() being infinitely called - Stack Overflow

时间: 2025-01-06 admin 业界

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.

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.

Share Improve this question asked 14 hours ago DovizuDovizu 4353 silver badges15 bronze badges 4
  • sheet should be ether @State or @Binding – Paulw11 Commented 13 hours ago
  • @Paulw11 I think you’re confusing the SwiftData model Sheet and the .sheet() – Dovizu Commented 13 hours ago
  • in TransactionCreateView have you tried using @Bindable var transaction: Transaction since class Transaction already conforms to the Observable protocols. – workingdog support Ukraine Commented 12 hours ago
  • @workingdogsupportUkraine just tried that and unfortunately that didn't change anything. – Dovizu Commented 10 hours ago
Add a comment  | 

1 Answer 1

Reset to default 0

The reason that the init get's called in an infinite loop is that in the init of the view you create a new Transaction object and set 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()
        }
    }
}