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
}
}
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.
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
}