swiftuiswiftdataswift-data-modelcontext

SwiftData auto inserts array into modelContext


Definitely one of the stranger quirks of SwiftData I've come across.

I have a ScriptView that shows Line entities related to a Production, and a TextEnterScriptView that’s presented in a sheet to input text.

I’m noticing that every time I type in the TextEditor within TextEnterScriptView, a new Line shows up in ScriptView — even though I haven’t explicitly inserted it into the modelContext.

I'm quite confused because even though I’m only assigning a new Line to a local @State array in TextEnterScriptView, every keystroke in the TextEditor causes a duplicate Line to appear in ScriptView.

In other words, Why is SwiftData creating new Line entities every time I type in the TextEditor, even though I’m only assigning to a local @State array and not explicitly inserting them into the modelContext?

Here is my minimal reproducible example:

import SwiftData
import SwiftUI

@main
struct testApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
                .modelContainer(for: Line.self, isAutosaveEnabled: false)
        }
    }
}

struct ContentView: View {
    @Environment(\.modelContext) var modelContext
    @Query(sort: \Production.title) var productions: [Production]
    var body: some View {
        NavigationStack {
            List(productions) { production in
                NavigationLink(value: production) {
                    Text(production.title)
                }
            }
            .navigationDestination(for: Production.self) { production in
                ScriptView(production: production)
            }
            .toolbar {
                Button("Add", systemImage: "plus") {
                    let production = Production(title: "Test \(productions.count + 1)")
                    modelContext.insert(production)
                    do {
                        try modelContext.save()
                    } catch {
                        print(error)
                    }
                }
            }
            .navigationTitle("Productions")
        }
    }
}

struct ScriptView: View {
    @Query private var lines: [Line]
    let production: Production
    @State private var isShowingSheet: Bool = false
    var body: some View {
        List {
            ForEach(lines) { line in
                Text(line.content)
            }
        }
        .toolbar {
            Button("Show Sheet") {
                isShowingSheet.toggle()
            }
        }
        .sheet(isPresented: $isShowingSheet) {
            TextEnterScriptView(production: production)
        }
    }
}

struct TextEnterScriptView: View {
    @Environment(\.dismiss) var dismiss
    @State private var text = ""
    @State private var lines: [Line] = []
    let production: Production
    var body: some View {
        NavigationStack {
            TextEditor(text: $text)
                .onChange(of: text, initial: false) {
                    lines = [Line(content: "test line", production: production)]
                }
                .toolbar {
                    Button("Done") {
                        dismiss()
                    }
                }
        }
    }
}

@Model
class Production {
    @Attribute(.unique) var title: String

    @Relationship(deleteRule: .cascade, inverse: \Line.production)
    var lines: [Line] = []

    init(title: String) {
        self.title = title
    }
}

@Model
class Line {
    var content: String
    var production: Production?

    init(content: String, production: Production?) {
        self.content = content
        self.production = production
    }
}

Solution

  • Yes, this can be confusing when you first face it. When you create a state that holds an object/instance of a SwiftData @Model that has relationships, it automatically gets added to the context.

    In this case, Line has a relationship with Production so it gets automatically inserted into context, since a Production cannot have a relationship or reference anything NOT in the context.

    To avoid this, you must work with objects not in state, similar to how you did it to create a new Production with default title:

    //This won't get added to context automatically, since it's not assigned to a State
    let production = Production(title: "Test \(productions.count + 1)") 
    

    In your case, the solution is simple because you don't actually need a state that holds an array of lines [Line]. You actually just need a string state (see revised TextEnterScriptView in the full code below).

    But in cases where you work with more complex models with many properties, it can be cumbersome to create a state for each property. In that case, you can create an intermediate struct that has a property of the respective type. See how it's done in the ProductionEditor view of the full code below.

    So create a helper struct:

    //Helper struct to create a Production object without it being added to the context automatically
    private struct DraftProduction {
        var draft: Production = Production(title: "")
    }
    

    Now you can assign that struct to a state:

    @State private var draftProduction = DraftProduction()
    

    Then you can reference or create bindings to properties of the actual Production object via the intermediate struct state instance, without it being automatically inserted into context (since DraftProduction has no relationships with other models that are in the context):

    //Binding to the Production's title property
    TextField("Enter production name", text: $draftProduction.draft.title) 
    

    To address your TextEnterScriptView view, a state to hold [Line] is not needed, because you most likely do not want to add a new line for every character you type in the text editor (which is what would happen based on the logic of the .onChange ).

    So if a production has arrays of lines, the input view should add a single Line , meaning you just need a simple String state, which gets appended as a Line to the production's lines property as part of the view's save function:

    private func save() {
        let newLine = Line(content: text, production: production)
        production.lines.append(newLine) 
    }
    

    Note that a context insert is not needed here, since as soon as you append to the production object that is already in context, the Line will have to be added to the context in order for the Production object to reference it.


    Here's the full updated code:

    import SwiftData
    import SwiftUI
    
    //Main view
    struct ScriptContentView: View {
        
        //Queries
        @Query(sort: \Production.title) var productions: [Production]
        
        //Environment values
        @Environment(\.modelContext) var modelContext
        
        //State values
        @State private var showProductionEditorSheet = false
        
        //Body
        var body: some View {
            NavigationStack {
                List(productions) { production in
                    NavigationLink(value: production) {
                        Text(production.title)
                    }
                }
                .contentMargins(.vertical, 20)
                
                //Overlay for empty productions content
                .overlay {
                    if productions.isEmpty {
                        ContentUnavailableView {
                            Label("No productions", systemImage: "movieclapper")
                        } description: {
                            Text("Add productions by tapping the + button.")
                        } actions: {
                            addProductionButton
                        }
                    }
                }
                .navigationDestination(for: Production.self) { production in
                    ScriptView(production: production)
                }
                .navigationTitle("Productions")
                .toolbar {
                    
                    //Add production button
                    ToolbarItem {
                        addProductionButton
                    }
                    
                    //Reset context
                    ToolbarItem(placement: .topBarLeading) {
                        Button {
                            withAnimation {
                                try? modelContext.delete(model: Production.self)
                                try? modelContext.save()
                            }
                        } label: {
                            Label("Reset", systemImage: "arrow.trianglehead.counterclockwise")
                        }
                    }
                }
                .sheet(isPresented: $showProductionEditorSheet) {
                    ProductionEditor(production: nil)
                        .presentationDetents([.medium])
                }
            }
        }
        
        //Helper view so it can be referenced in multiple places
        private var addProductionButton: some View {
            Menu {
                Button("Default title", systemImage: "plus") {
                    let production = Production(title: "Production \(productions.count + 1)")
                    withAnimation {
                        modelContext.insert(production)
                    }
                    do {
                        try modelContext.save()
                    } catch {
                        print(error)
                    }
                }
                
                Button("Custom title", systemImage: "plus") {
                    showProductionEditorSheet.toggle()
                }
            } label : {
                Label("Add production", systemImage: "plus")
            }
        }
    }
    
    struct ScriptView: View {
        // @Query private var lines: [Line]
        
        //Parameters
        let production: Production
        
        //State values
        @State private var isShowingSheet: Bool = false
        
        //Body
        var body: some View {
            List {
                if !production.lines.isEmpty {
                    Section("Lines") {
                        ForEach(production.lines) { line in
                            Text(line.content)
                        }
                    }
                }
            }
            .listRowSpacing(10)
            
            //Overlay for empty production lines content
            .overlay {
                if production.lines.isEmpty {
                    ContentUnavailableView {
                        Label("Empty script", systemImage: "text.document")
                    } description: {
                        Text("Add lines by tapping the Add Line button.")
                    }
                }
            }
            .navigationTitle(production.title)
            .toolbarTitleDisplayMode(.inline)
            .toolbar {
                Button("Add line") {
                    isShowingSheet.toggle()
                }
            }
            .sheet(isPresented: $isShowingSheet) {
                TextEnterScriptView(production: production)
                    .presentationDetents([.medium, .large])
            }
        }
    }
    
    struct ProductionEditor: View {
        
        //Parameters
        let production: Production?
        
        //Queries
        
        //Environment values
        @Environment(\.dismiss) var dismiss
        @Environment(\.modelContext) var modelContext
        
        //State values
        @State private var draftProduction = DraftProduction()
        
        //Computed properties
        private var editorTitle: String {
            production == nil ? "Add production" : "Edit production"
        }
        
        //Body
        var body: some View {
            
            NavigationStack {
                Form {
                    Section("Name") {
                        TextField("Enter production name", text: $draftProduction.draft.title) // <- Binding to the draft Production's title property
                    }
                }
                .navigationTitle(editorTitle)
                .toolbarTitleDisplayMode(.inline)
                .toolbar {
                    
                    //Add button
                    ToolbarItem(placement: .primaryAction) {
                        Button {
                            withAnimation {
                                save()
                                dismiss()
                            }
                        } label: {
                            Text("Add")
                        }
                        .disabled(draftProduction.draft.title.isEmpty)
                    }
                    
                    //Cancel button
                    ToolbarItem(placement: .topBarLeading) {
                        Button {
                            dismiss()
                        } label: {
                            Text("Cancel")
                        }
                    }
                }
            }
        }
        
        //Helper struct to create a Production object without it being added to the context automatically
        private struct DraftProduction {
            var draft: Production = Production(title: "")
        }
        
        //Save function
        private func save() {
            if production == nil { // Adding a production
                modelContext.insert(draftProduction.draft) // <- Insert the draft Production object
            }
            else { //Editing a production
                //....
            }
            
            try? modelContext.save()
        }
    }
    
    struct TextEnterScriptView: View {
        
        //Parameters
        let production: Production
        
        //Environment values
        @Environment(\.dismiss) var dismiss
        
        //State values
        @State private var text = ""
        
        //Body
        var body: some View {
            NavigationStack {
                Form {
                    Section("Line content") {
                        TextEditor(text: $text)
                            .overlay(alignment: .topLeading) {
                                //Show placeholder text if no text entered
                                if text.isEmpty {
                                    Text("Add line content...")
                                        .foregroundStyle(.tertiary)
                                        .padding(.top, 8)
                                        .padding(.leading, 3)
                                }
                            }
                    }
                }
                .toolbar {
                    
                    //Add button
                    ToolbarItem(placement: .primaryAction) {
                        Button("Add") {
                            save()
                            dismiss()
                        }
                        .disabled(text.isEmpty) //Disable Add button if text is empty
                    }
                    
                    //Cancel button
                    ToolbarItem(placement: .topBarLeading) {
                        Button {
                            dismiss()
                        } label: {
                            Text("Cancel")
                        }
                    }
                }
            }
        }
        
        //Save function
        private func save() {
            let newLine = Line(content: text, production: production) // <- the new line object can reference a production that is in context, because the line itself is not in the context
            
            production.lines.append(newLine) //Appending to production, which is already in context, will automatically insert the new line into context, since production cannot have a relationship or reference anything NOT in the context
        }
        
    }
    
    //Models
    @Model
    class Production {
        @Attribute(.unique) var title: String
    
        @Relationship(deleteRule: .cascade, inverse: \Line.production)
        var lines: [Line] = []
    
        init(title: String) {
            self.title = title
        }
    }
    
    @Model
    class Line {
        var content: String
        var production: Production?
    
        init(content: String, production: Production?) {
            self.content = content
            self.production = production
        }
    }
    
    //Preview
    #Preview {
        ScriptContentView()
            .modelContainer(for: [Line.self, Production.self], inMemory: true) // <- change inMemory to false if you want to persist changes
    }
    

    enter image description here