TEAM LICENSES: Save money and learn new skills through a Hacking with Swift+ team license >>

SOLVED: SwiftData child views not updating on insertions, but updating fine on deletions

Forums > SwiftUI

I've been trying to figure out SwiftData, and am being driven well nigh crazy. I've built a toy model to demonstrate:

  1. I have a parent view ('level 1') which displays 'level 1' objects. These have a relationship to an array of 'level 2' objects, which have a relationship to an array of 'level 3' objects.
  2. The level 1 view updates just fine when I insert and delete level 1 objects. If I then navigate to a level 2 view, and pass it a level 1 object, I do it without any property wrappers, as we're told that it's an Observable, and that change notification gets taken care of behind the scenes.
  3. Inside a level 2 view, if I delete an object, the view instantly updates - magic! But if I insert an object, nada, no view update.
  4. I've tried various things, like forcing a modelContext save, but it does nothing. If I log changes to view bodies, I can see that the view's dependencies are updating. If I remove the relationship, and then manually do level1.level2.append(new_level2), the view updates fine - but my understanding is that we shouldn't be managing relationships by hand like this?

In short, what's the correct way to pass Observable objects into child views so that the view receives observation updates when the relevant object properties' setters get called? The docs seem to suggest it should magically happen, and that no property wrappers are needed, but my experience is anything but. I've come across a number of similar examples on stack overflow, but no convincing solutions - the closest I've come is someone suggesting to use a separate ObservableObject in a view model, and then to copy changes to a published variable, which doesn't at all seem in the spirit of what Apple is getting at with this. Am I just missing something obvious?

Thanks in advance,

Dominic

--- CODE ---

Model:

import Foundation
import SwiftData

@Model
final class Layer1: Hashable {
    var name: String
    @Relationship(deleteRule: .cascade, inverse: \Layer2.layer1) var layer2: [Layer2] = []

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

@Model
final class Layer2 {
    var name: String
    var layer1: Layer1
    @Relationship(deleteRule: .cascade, inverse: \Layer3.layer2) var layer3: [Layer3] = []

    init(name: String, layer1: Layer1) {
        self.name = name
        self.layer1 = layer1
    }
}

@Model
final class Layer3 {
    var name: String
    var layer2: Layer2

    init(name: String, layer2: Layer2) {
        self.name = name
        self.layer2 = layer2
    }
}

Layer 1 view:

import SwiftUI
import SwiftData

struct Layer1View: View {
    @Query var layers: [Layer1]
    // I have already injected the model context at the app entry point
    @Environment(\.modelContext) var modelContext

    func addLayer() {
        let layer = Layer1(name: UUID().uuidString)
        modelContext.insert(layer)
    }

    func deleteLayer(_ indexSet: IndexSet) {
        for i in indexSet {
            let layer = layers[i]
            modelContext.delete(layer)
        }
    }

    var body: some View {
        let _ = Self._printChanges()
        List {
            ForEach(layers) { layer in
                                NavigationLink(destination: Layer2View(layer: layer)) {
                    Text(layer.name)
                }
            }
            .onDelete(perform: deleteLayer)
        }
        .toolbar {
            Button("Add L1") {
                addLayer()
            }
        }
    }
}

Layer 2 view:

import SwiftUI
import SwiftData

struct Layer2View: View {
    var layer: Layer1
    @Environment(\.modelContext) var modelContext

    func addLayer() {
        let layer2 = Layer2(name: UUID().uuidString, layer1: layer)
        modelContext.insert(layer2)
//        This works, but of course you can't do both.
//        layer.layer2.append(layer2)
    }

    func deleteLayer(_ indexSet: IndexSet) {
        for i in indexSet {
            let layer2 = layer.layer2[i]
//           Deletion causes view to update instantly:
            modelContext.delete(layer2)
        }
    }

    var body: some View {
        let _ = Self._printChanges()
        NavigationView {
            List {
                ForEach(layer.layer2) { layer in
                    Text(layer.name)
                }
                .onDelete(perform: deleteLayer)
            }
            .toolbar {
                Button("Add L2") {
                    addLayer()
                }
            }
            .navigationTitle("Layer 2")
        }
    }
}

2      

Hi @GerardBailey,

I'm afraid your answer reads just like something generated by GPT4 - apologies if it isn't, but there are some giveaway phrases that ring alarm bells for me (e.g. the "Here are some suggestions and insights that might help you..." boilerplate, which GPT prefixes to most such answers), and I have a pretty good depth of experience building on generative models.

Also, you've got a mishmash of things that are valid along with things that are clearly out of date or already mentioned in my post:

  1. Apple says we should NOT be using ObservableObject and should migrate to the @Observable macro instead, and should therefore no longer need the @Published property wrapper.
  2. Also, I already mentioned in my post about using an @ObservableObject at a higher level and passing it down, as a possible (but ugly) workaround.
  3. Also, the model context automatically infers relationships as required, so you only have to specify one model class to having them all accessible in your model context.

Does anyone else have any targeted advice on my specific problem?

Thanks,

Dominic

4      

I would suggest to follow this logic. When you pass your var layer: Layer1 to subviews, you can save and delete to db without issue. The issue arises when you need to update the view. @Query creates kind of "pipeline" and your items are updated once they are in context. So to make your views get those updates using @Query you can do as in below code. Maybe there are other options out there. But this one I spent some time doing myself. I have created more readable code, sorry those layer1 and layer2 got my brain spinning. I also added comments so I think it clear what is going on in the code. I tend to be perfectionist way too often so hope you don't mind that ))) But the data flow is the same as in your code.

UPDATE

My previous suggestion works, however there is redundant code in it, due to using @Query and custom inits for that part. What is actually needed for the model to work flawlessly is revision of relationships. One part MUST be optional, (it is commented in the code) and how we add child to children relationship and grandchild to grandchildren respectively. Also preview is updated to show proper information. So code is updated to show the latest info. The previous part using @Query I deleted as many lines would add to the post :)

NOW ABOUT THE REASON why your code did not work

Paul mentioned that this might be a bug in that article. https://www.hackingwithswift.com/quick-start/swiftdata/how-to-save-a-swiftdata-object

Namely:

If you use a non-optional on the other side (your case), you must specify the delete rule manually (you did it) and call save() (your code does not include that) when inserting the data, otherwise SwiftData won’t refresh the relationship (as in your case) until application is relaunched – even if you call save() at a later date, and even if you create and run a new FetchDescriptor from scratch.

1. The App File

import SwiftUI

@main
struct SwiftDataPassingToChildApp: App {
    var body: some Scene {
        WindowGroup {
            ParentView()
                .modelContainer(for: Parent.self)
        }
    }
}

2. Data Model File

import Foundation
import SwiftData

@Model
final class Parent: Hashable {
    var name: String
    @Relationship(deleteRule: .cascade, inverse: \Child.parent)
    var children: [Child] = []

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

@Model
final class Child: Hashable {
    var name: String
    // The 1 side needs to be optional or you get an
    // "Unsupported relationship" fatal error when running the app
    var parent: Parent?
    @Relationship(deleteRule: .cascade, inverse: \Grandchild.child)
    var grandChildren: [Grandchild] = []

    init(name: String, parent: Parent) {
        self.name = name
        self.parent = parent
    }
}

@Model
final class Grandchild: Hashable {
    var name: String
    // The 1 side needs to be optional or you get an
    // "Unsupported relationship" fatal error when running the app
    var child: Child?

    init(name: String, child: Child) {
        self.name = name
        self.child = child
    }
}

extension Parent {
    // Mock Data
    @MainActor
    static var preview: ModelContainer {
        let container = try! ModelContainer(for: Parent.self, configurations: ModelConfiguration(isStoredInMemoryOnly: true))

        let parent1 = Parent(name: "Parent One")
        let parent2 = Parent(name: "Parent Two")

        let child1 = Child(name: "Child One", parent: parent1)
        let child2 = Child(name: "Child Two", parent: parent2)

        let grandChild1 = Grandchild(name: "Grandchild One", child: child1)
        let grandChild2 = Grandchild(name: "Grancdhild Two", child: child2)
        container.mainContext.insert(grandChild1)
        container.mainContext.insert(grandChild2)

        return container
    }
}

3. Parent View File

import SwiftUI
import SwiftData

struct ParentView: View {
    // We create "pipeline" so whenever data changes in the model context
    // the view automatically gets updated, this is because all models are observable
    @Query private var parents: [Parent]
    @Environment(\.modelContext) var modelContext
    @State private var navigationPath = NavigationPath()

    var body: some View {
        NavigationStack(path: $navigationPath) {
            if parents.isEmpty {
                emptyView
            } else {
                parentViewSection
            }
        }
    }
}

// MARK: - Extensions

extension ParentView {

    // MARK: - View Sections

    private var emptyView: some View {
        ContentUnavailableView {
            Label("No Parent Items", systemImage: "person.fill")
        } description: {
            Text("You don't have any parent items yet.\n Add new items now!")
        } actions: {
            Button {
                addParent()
            } label: {
                Text("Add Parent Item")
            }
            .buttonStyle(.borderedProminent)
            .controlSize(.regular)
        }
    }

    private var parentViewSection: some View {
        List {
            ForEach(parents) { parent in
                NavigationLink(value: parent) {
                    Text(parent.name)
                }
            }
            .onDelete(perform: deleteParent)
        }
        .navigationDestination(for: Parent.self) { parent in
            ChildView(selectedParent: parent)
        }
        .toolbar {
            Button("Add Parent", systemImage: "person.fill") {
                addParent()
            }
        }
        .navigationTitle("Parent View")
    }

    // MARK: - Functions

    private func addParent() {
        let parent = Parent(name: UUID().uuidString)
        modelContext.insert(parent)
    }

    private func deleteParent(indexSet: IndexSet) {
        for index in indexSet {
            modelContext.delete(parents[index])
        }
    }
}

// MARK: - Preview

#Preview {
    NavigationStack {
        ParentView()
            .modelContainer(Parent.preview)
    }
}

4. Child View File

import SwiftUI
import SwiftData

struct ChildView: View {
    @Environment(\.modelContext) var modelContext
    let selectedParent: Parent

    var body: some View {
        if selectedParent.children.isEmpty {
            emptyView
        } else {
            childrenViewSection
        }
    }
}

// MARK: - Extensions

extension ChildView {
    // MARK: - View Sections

    private var emptyView: some View {
        ContentUnavailableView {
            Label("No Children Items", systemImage: "person.2.fill")
        } description: {
            Text("You don't have any children items yet.\n Add new items now!")
        } actions: {
            Button {
                addChild()
            } label: {
                Text("Add Child Item")
            }
            .buttonStyle(.borderedProminent)
            .controlSize(.regular)
        }
    }

    private var childrenViewSection: some View {
        List {
            ForEach(selectedParent.children) { child in
                NavigationLink(value: child) {
                    Text(child.name)
                }
            }
            .onDelete(perform: deleteChild)
        }
        .navigationDestination(for: Child.self) { child in
            GrandchildView(selectedChild: child)
        }
        .toolbar {
            Button("Add Child", systemImage: "person.2.fill") {
                addChild()
            }
        }
        .navigationTitle("Child View")
    }

    // MARK: - Functions

    private func addChild() {
        let child = Child(name: UUID().uuidString, parent: selectedParent)
        // We are adding child to parent like this
        selectedParent.children.append(child)
    }

    private func deleteChild(indexSet: IndexSet) {
        for index in indexSet {
            modelContext.delete(selectedParent.children[index])
        }
    }
}

// MARK: - Preview

#Preview {
    let context = Parent.preview.mainContext
    let parent = Parent(name: "Parent One")
    let child = Child(name: "Child One", parent: parent)
    let grandchild = Grandchild(name: "Grandchild One", child: child)
    context.insert(grandchild)

    return NavigationStack {
        ChildView(selectedParent: parent)
    }
}

5. Grandchild View File

import SwiftUI
import SwiftData

struct GrandchildView: View {
    @Environment(\.modelContext) var modelContext
    let selectedChild: Child

    var body: some View {
        if selectedChild.grandChildren.isEmpty {
            emptyView
        } else {
            grandchildrenViewSection
        }
    }
}

// MARK: - Extensions
extension GrandchildView {

    // MARK: - View Sections

    private var emptyView: some View {
        ContentUnavailableView {
            Label("No Grandchildren Items", systemImage: "person.3.fill")
        } description: {
            Text("You don't have any grandchildren items yet.\n Add new items now!")
        } actions: {
            Button {
                addGrandchild()
            } label: {
                Text("Add Grandchild Item")
            }
            .buttonStyle(.borderedProminent)
            .controlSize(.regular)
        }
    }

    private var grandchildrenViewSection: some View {
        List {
            ForEach(selectedChild.grandChildren) { grandchild in
                Text(grandchild.name)
            }
            .onDelete(perform: deleteGrandchild)
        }
        .toolbar {
            Button("Add Child", systemImage: "person.3.fill") {
                addGrandchild()
            }
        }
        .navigationTitle("Grandchildren View")
    }

    // MARK: - Functions

    private func addGrandchild() {
        let grandchild = Grandchild(name: UUID().uuidString, child: selectedChild)
        // We are adding grandchild to child like this
        selectedChild.grandChildren.append(grandchild)
    }

    private func deleteGrandchild(indexSet: IndexSet) {
        for index in indexSet {
            modelContext.delete(selectedChild.grandChildren[index])
        }
    }
}

// MARK: - Preview

#Preview {
    let context = Parent.preview.mainContext
    let parent = Parent(name: "Parent One")
    let child = Child(name: "Child One", parent: parent)
    let grandchild = Grandchild(name: "Grandchild One", child: child)
    context.insert(grandchild)
    return NavigationStack {
        GrandchildView(selectedChild: child)
    }
}

4      

Hi @ygeras,

I just wanted to say a massive thank you for this. It's incredibly helpful, and I've now got everything working as intended. It's really frustrating that the basic functionality Apple touts for SwiftData is so flaky, and it feels so wrong to have to insert new items into the model context in some places, but in others imperatively set a relationship object's properties to cause the change notification to be sent.

I've also discovered that in at least one example (a ScrollView), deletion of a grandchild works fine, but to get the view to update you have to first remove the object from the child.grandchildren array, which again feels like a bit of an antipattern.

I was about to dispair of SwiftData and revert to CoreData, but you've saved me. It still feels like a massive ergonomic improvement, event with workarounds required.

Any no worries at all about the precision - it's very helpful indeed, and definitely makes everything clearer.

Thanks again!

Dominic

4      

Hi @dbtl88!

You're more than welcome! Glad to hear that such approach helped to solve your challenge. Your kind words are really appreciated.

I've also discovered that in at least one example (a ScrollView), deletion of a grandchild works fine, but to get the view to update you have to first remove the object from the child.grandchildren array, which again feels like a bit of an antipattern.

Talking about my sample code, as I am using "empty view" and "list view" in children and grandchildren. One may notice that once the last item in array is removed, the view does not switch automatically to "empty view". So as you mentioned you have two options there:

  1. Remove the item as well from array
private func deleteGrandchild(indexSet: IndexSet) {
        for index in indexSet {
            modelContext.delete(selectedChild.grandChildren[index])
            selectedChild.grandChildren.remove(at: index) // <-- this line to be added
        }
    }
  1. Create @State var to "force" the view to refresh upon deletion of items.
struct GrandchildView: View {
    @Environment(\.modelContext) var modelContext
    let selectedChild: Child
    @State private var refreshList = false // <- Add this var

    var body: some View {
        if selectedChild.grandChildren.isEmpty {
            emptyView
        } else {
            grandchildrenViewSection
                .id(refreshList) // <- Add id
        }
    }
}

and upon deletion refresh the view

private func deleteGrandchild(indexSet: IndexSet) {
        for index in indexSet {
            modelContext.delete(selectedChild.grandChildren[index])
            refreshList.toggle() // <- Refreshes the view
        }
    }

2      

Hacking with Swift is sponsored by RevenueCat.

SPONSORED Take the pain out of configuring and testing your paywalls. RevenueCat's Paywalls allow you to remotely configure your entire paywall view without any code changes or app updates.

Learn more here

Sponsor Hacking with Swift and reach the world's largest Swift community!

Reply to this topic…

You need to create an account or log in to reply.

All interactions here are governed by our code of conduct.

 
Unknown user

You are not logged in

Log in or create account
 

Link copied to your pasteboard.