iosswiftswiftuiswiftdata

SwiftUI not observing SwiftData changes


I have an app with the following model:

@Model class TaskList {
    @Attribute(.unique)
    var name: String
    
    // Relationships
    var parentList: TaskList?
    
    @Relationship(deleteRule: .cascade, inverse: \TaskList.parentList)
    var taskLists: [TaskList]?
        
    init(name: String, parentTaskList: TaskList? = nil) {
        self.name = name
        self.parentList = parentTaskList
        self.taskLists = []
    }
}

If I run the following test, I get the expected results - Parent has it's taskLists array updated to include the Child list created. I don't explicitly add the child to the parent array - the parentList relationship property on the child causes SwiftData to automatically perform the append into the parent array:

    @Test("TaskList with children with independent saves are in the database")
    func test_savingRootTaskIndependentOfChildren_SavesAllTaskLists() async throws {
        let modelContext = TestHelperUtility.createModelContext(useInMemory: false)
        let parentList = TaskList(name: "Parent")
        
        modelContext.insert(parentList)
        try modelContext.save()
        
        let childList = TaskList(name: "Child")
        childList.parentList = parentList
        modelContext.insert(childList)
        try modelContext.save()
        
        let fetchedResults = try modelContext.fetch(FetchDescriptor<TaskList>())
        let fetchedParent = fetchedResults.first(where: { $0.name == "Parent"})
        let fetchedChild = fetchedResults.first(where: { $0.name == "Child" })
        #expect(fetchedResults.count == 2)
        #expect(fetchedParent?.taskLists.count == 1)
        #expect(fetchedChild?.parentList?.name == "Parent")
        #expect(fetchedChild?.parentList?.taskLists.count == 1)
    }

I have a subsequent test that deletes the child and shows the parent array being updated accordingly. With this context in mind, I'm not seeing these relationship updates being observed within SwiftUI. This is an app that reproduces the issue. In this example, I am trying to move "Finance" from under the "Work" parent and into the "Home" list.

enter image description here

To start, the following code is a working example where the behavior does what I expect - it moves the list from one parent to another without any issue. This is done using the native OutlineGroup in SwiftUI.

ContentView

struct ContentView: View {
    @Query(sort: \TaskList.name) var taskLists: [TaskList]
    @State private var selectedList: TaskList?
    
    var body: some View {
        NavigationStack {
            List {
                ForEach(taskLists.filter({$0.parentList == nil})) { list in
                    OutlineGroup(list, children: \.taskLists) { list in
                        Text(list.name)
                            .onTapGesture {
                                selectedList = list
                            }
                    }
                }
            }
            .sheet(item: $selectedList, onDismiss: {
                selectedList = nil
            }) { list in
                TaskListEditorScreen(existingList: list)
            }
        }
    }
}

TaskListEditorScreen

struct TaskListEditorScreen: View {
    @Environment(\.dismiss) private var dismiss
    @Environment(\.modelContext) private var modelContext
    
    @State private var viewModel: TaskListEditorViewModel
    @Bindable var list: TaskList
    
    init(existingList: TaskList) {
        list = existingList
        viewModel = TaskListEditorViewModel(taskList: existingList)
    }
    
    var body: some View {
        NavigationView {
            TaskListFormView(viewModel: viewModel)
                .toolbar {
                    ToolbarItem {
                        Button("Cancel") {
                            dismiss()
                        }
                    }
                    
                    ToolbarItem {
                        Button("Save") {
                            list.name = viewModel.name
                            list.parentList = viewModel.parentTaskList
                            try! modelContext.save()
                            
                            dismiss()
                        }
                    }
                }
        }
    }
}

TaskListFormView

struct TaskListFormView: View {
    @Bindable var viewModel: TaskListEditorViewModel
    
    var body: some View {
        VStack {
            Form {
                TextField("Name", text: $viewModel.name)
                
                NavigationLink {
                    TaskListPickerScreen(viewModel: self.viewModel)
                } label: {
                    Text(self.viewModel.parentTaskList?.name ?? "Parent List")
                }
            }
        }
    }
}

TaskListPickerScreen

struct TaskListPickerScreen: View {
    @Environment(\.dismiss) private var dismiss
    @Query(filter: #Predicate { $0.parentList == nil }, sort: \TaskList.name)
    private var taskLists: [TaskList]
    
    @Bindable var viewModel: TaskListEditorViewModel
    
    var body: some View {
        List {
            ForEach(taskLists) { list in
                OutlineGroup(list, children: \.taskLists) { child in
                    getRowForChild(child)
                }
            }
        }
        .toolbar {
            ToolbarItem {
                Button("Clear Parent") {
                    viewModel.parentTaskList = nil
                    dismiss()
                }
            }
        }
    }
    
    @ViewBuilder func getRowForChild(_ list: TaskList) -> some View {
        HStack {
            Text(list.name)
        }
        .onTapGesture {
            if list.name == viewModel.name {
                return
            }
            
            self.viewModel.parentTaskList = list
            dismiss()
        }
    }
}

TaskListEditorViewModel

@Observable class TaskListEditorViewModel {
    var name: String
    var parentTaskList: TaskList?
    
    init(taskList: TaskList) {
        name = taskList.name
        parentTaskList = taskList.parentList
    }
}

You can setup the following container and seed it with test data to verify that the result is the lists can move between parents and SwiftUI updates it accordingly.

#Preview {
    ContentView()
        .modelContext(DataContainer.preview.dataContainer.mainContext)
}

@MainActor class DataContainer {
    let schemaModels = Schema([ TaskList.self ])
    let dataConfiguration: ModelConfiguration
    
    static let shared = DataContainer()
    static let preview = DataContainer(memoryDB: true)
    
    init(memoryDB: Bool = false) {
        dataConfiguration = ModelConfiguration(isStoredInMemoryOnly: memoryDB)
    }
    
    lazy var dataContainer: ModelContainer = {
        do {
            let container = try ModelContainer(for: schemaModels)
            seedData(context: container.mainContext)
            return container
        } catch {
            fatalError("\(error.localizedDescription)")
        }
    }()
    
    func seedData(context: ModelContext) {
        let lists = try! context.fetch(FetchDescriptor<TaskList>())
        if lists.count == 0 {
            Task { @MainActor in
                SampleData.taskLists.filter({ $0.parentList == nil }).forEach {
                    context.insert($0)
                }
                
                try! context.save()
            }
        }
    }
}

struct SampleData {
    static let taskLists: [TaskList] = {
        let home = TaskList(name: "Home")
        let work = TaskList(name: "Work")
        let remodeling = TaskList(name: "Remodeling", parentTaskList: home)
        let kidsBedroom = TaskList(name: "Kids Room", parentTaskList: remodeling)
        let livingRoom = TaskList(name: "Living Room", parentTaskList: remodeling)
        let management = TaskList(name: "Management", parentTaskList: work)
        let finance = TaskList(name: "Finance", parentTaskList: work)
                
        return [home, work, remodeling, kidsBedroom, livingRoom, management, finance]
    }()
}

However, I need to customize the layout and interaction of each row, including how the indicators are handled. To facilitate this, I replaced the use of OutlineGroup with my own custom views. The following three views make up that component - allowing for parent/child nesting.

Note at face value it may seem like these don't do much else over OutlineGroup. In my real app these are more complicated. It is streamlined for the reproducible example.

struct TaskListRowContentView: View {
    @Environment(\.modelContext) private var modelContext
    @Bindable var taskList: TaskList
    @State var isShowingEditor: Bool = false
    
    init(taskList: TaskList) {
        self.taskList = taskList
    }
    
    var body: some View {
        HStack {
            Text(taskList.name)
        }
        .contextMenu {
            Button("Edit") {
                isShowingEditor = true
            }
        }
        .sheet(isPresented: $isShowingEditor) {
            TaskListEditorScreen(existingList: taskList)
        }
    }
}

struct TaskListRowParentView: View {
    @Bindable var taskList: TaskList
    @State private var isExpanded: Bool = true
    
    var children: [TaskList] {
        taskList.taskLists!.sorted(by: { $0.name < $1.name })
    }
    
    var body: some View {
        DisclosureGroup(isExpanded: $isExpanded) {
            ForEach(children) { child in
                if child.taskLists!.isEmpty {
                    TaskListRowContentView(taskList: child)
                } else {
                    TaskListRowParentView(taskList: child)
                }
            }
        } label: {
            TaskListRowContentView(taskList: self.taskList)
        }
    }
}

struct TaskListRowView: View {
    @Bindable var taskList: TaskList
    
    var body: some View {
        if taskList.taskLists!.isEmpty {
            TaskListRowContentView(
                taskList: taskList)
        } else {
            TaskListRowParentView(taskList: taskList)
        }
    }
}

With these views defined, I update my ContentView to use them instead of the OutlineGroup.

List {
    ForEach(taskLists.filter({$0.parentList == nil})) { list in
        TaskListRowView(taskList: list)
    }
}

With this change in place, I start to experience my issue within SwiftUI. I will move the Finance list out of the Work list via the editor and into the Home parent. During debugging, I can verify that the modelContext.save() call I am doing immediately causes both of the parent lists to update. The work.taskLists is reduced by 1 and the home.tasksLists array as increased by 1 as expected. I can kill the app and relaunch and I see the finance list as a child of the Home list. However, I don't see this reflect in real-time. I have to kill the app to see the changes.

If I alter my save code so that it manually updates the parent array - the issue goes away and it works as expected.

ToolbarItem {
    Button("Save") {
        list.name = viewModel.name
        list.parentList = viewModel.parentTaskList
        
        // Manually add to "Home"
        if let newParent = viewModel.parentTaskList {
            newParent.taskLists?.append(list)
        }
        
        try! modelContext.save()
        
        dismiss()
    }
}

This has me confused. When I debug this, I can see that viewModel.parentTaskList.taskLists.count equals 2 (Remodeling, Finance). I can print the contents of the array and both the Remodeling and Finance models are in there as expected. However, the UI doesn't work unless I explicitly call newParent.taskLists?.append(list). The list already exists in the array and yet I must do this in order for SwiftUI to update it's binding.

Why does my explicit append call solve for this? I don't understand how the array was mutated by SwiftData and the observing Views did not get notified of the change. My original approach (not manually updating the arrays) works fine in every unit/integration test I run but I can't get SwiftUI to observe the array changes.

If someone could explain why this is the case and whether or not I'll be required to manually update the arrays going forward I would appreciate it.

I have the full source code available as a Gist for easier copy/paste - it matches the contents of the post.

Edit One other thing that causes confusion for me is that the Finance list already exists prior to my manually appending it to the parent list array. So, despite my appending it anyway SwiftData is smart enough to not duplicate it. With that being the case, I don't know if it's just replacing what's in there, or saying "nope" and not adding it. If it's not adding it, then what is notifying the observing Views that the data changed?


Solution

  • Indeed, you shouldn't have to append to the array for the changes to be detected, especially if the list already exists in the array.

    If you have to do that, it's likely that the approach used for passing, editing and observing changes to objects is not "proper".

    The first thing that intrigued me was the presence of the taskLists property, which seemed redundant given the parentList property. The child lists of a list would be all lists that have that list set as their parentList.

    If you have a parentList property AND a taskLists array property, it means you have to manage changes to both every time you update a list's parent. That simply sounds like overkill.

    Regarding the taskLists property, you mentioned in the comments:

    Otherwise, I'd have to query for all lists and build the hierarchy myself from a flat list. It works as intended with SwiftData, so that's not an issue. The issue is that SwiftData mutates the array and SwiftUI doesn't know.

    From the looks of it, it doesn't quite work as intended.

    It would be much simpler to architect everything such that in order to change a list's parent you only need to update the parentList property - and have the UI react accordingly. This would reduce complexity and simplify troubleshooting precisely in the kind of scenario you're describing.

    In my experience, when there's a need to observe changes to a SwiftData array property, it's very likely additional consideration and configuration will be required depending on the model, especially if it involves multiple/nested levels of objects and arrays.

    Because you're using SwiftData models and @Observable class, you can pass around objects without necessarily requiring bindings, since any changes to the object's properties will be observed automatically. As such, I think it's more flexible and intuitive to pass parameters to views instead of relying on a @Bindable in every view.

    Regarding querying for all lists, you're already doing so. Actually, you're doing it twice. The "forward" navigation is not really needed when it can be done with a recursive view.

    After spending some time attempting to pinpoint the reason your code doesn't react to changes (which could be any of the above points), I ended up making too many changes to keep track of.

    Below you find the code with my approach for your use case, which I commented as much as possible for clarity:

    
    import SwiftUI
    import SwiftData
    import Observation
    
    //SwiftData model
    @Model class TaskList {
        @Attribute(.unique)
        var name: String
        
        // Define the parent relationship but don't create a reverse relationship
        @Relationship(inverse: nil) var parentList: TaskList?
        
        init(name: String, parentList: TaskList? = nil) {
            self.name = name
            self.parentList = parentList
        }
        
    }
    
    //Model extension
    extension TaskList {
        
        //Function to insert sample list data
        static func insertSampleData(context: ModelContext) {
            
            //Define sample lists (without relationship)
            let home = TaskList(name: "Home")
            let work = TaskList(name: "Work")
            let remodeling = TaskList(name: "Remodeling" )
            let kidsBedroom = TaskList(name: "Kids Room")
            let livingRoom = TaskList(name: "Living Room")
            let management = TaskList(name: "Management")
            let finance = TaskList(name: "Finance")
            
            let sampleLists = [home, work, remodeling, kidsBedroom, livingRoom, management, finance]
            
            // Insert all sample lists
            sampleLists.forEach { context.insert($0) }
            
            // Set up parent-child relationships
            remodeling.parentList = home
            kidsBedroom.parentList = remodeling
            livingRoom.parentList = remodeling
            management.parentList = work
            finance.parentList = work
            
            // Save all changes in one go
            try? context.save()
        }
        
        //Function to delete a list individually or with children
        func delete(includeChildren: Bool = false, in context: ModelContext) {
            // Find all child TaskLists
            let children = self.children(in: context)
            
            for child in children {
                if includeChildren {
                    // Recursively delete each child
                    child.delete(includeChildren: true, in: context)
                }
                else {
                    //Set child's parent to nil
                    child.parentList = nil
                }
            }
            
            // Delete the current list
            context.delete(self)
        }
        
        //Function to get a list's children
        func children(in context: ModelContext) -> [TaskList] {
            let parentID = self.id
            
            // Create a FetchDescriptor with a predicate to filter by the parent list
            let fetchDescriptor = FetchDescriptor<TaskList>(
                predicate: #Predicate<TaskList> { list in
                    list.parentList?.persistentModelID == parentID
                }
            )
            
            do {
                // Use the FetchDescriptor to perform the fetch
                return try context.fetch(fetchDescriptor)
            } catch {
                print("Failed to fetch children for \(self.name): \(error)")
                return []
            }
        }
    }
    
    //Main view
    struct TaskListContentView: View {
        
        //Environment values
        @Environment(\.dismiss) var dismiss
        @Environment(\.modelContext) private var modelContext
        
        //Bindings
        @Bindable var listObserver: ListEditObserver = ListEditObserver.shared
        
        //Body
        var body: some View {
            NavigationStack {
                
                TaskListOutline()
                    .toolbar {
                        ToolbarItem(placement: .topBarLeading) {
                            Button {
                                DataContainer.preview.resetData(context: DataContainer.preview.dataContainer.mainContext)
                            } label: {
                                //Icon label
                                HStack {
                                    Text("Reset")
                                    Image(systemName: "arrow.trianglehead.2.counterclockwise.rotate.90")
                                        .imageScale(.small)
                                }
                                .padding(.horizontal)
                            }
                        }
                    }
            }
            .sheet(item: $listObserver.editList, onDismiss: {
                listObserver.selectedList = nil
            }) { list in
                NavigationStack {
                    TaskListFormView(list: list)
                }
            }
        }
    }
    
    //List tree view
    struct TaskListOutline: View {
        
        //Queries
        @Query(sort: \TaskList.name) private var taskLists: [TaskList]
        
        //Environment values
        @Environment(\.dismiss) var dismiss
        @Environment(\.modelContext) private var modelContext
        
        //Bindings
        @Bindable var listObserver = ListEditObserver.shared
        
        //State values
        @State private var showDeleteAlert: Bool = false
        
        //Body
        var body: some View {
            
            List {
                ForEach(taskLists.filter({$0.parentList == nil}), id: \.self) { list in
                    TaskListRowView(list: list, taskLists: taskLists)
                }
            }
            
            //Dismiss when a list is selected
            .onChange(of: ListEditObserver.shared.selectedList) { _, list in
                if list != nil {
                    dismiss()
                }
            }
            
            //Observe deleteList and show delete confirmation when it changes
            .onChange(of: listObserver.deleteList){ _, list in
                if list != nil {
                    showDeleteAlert = true
                }
            }
            
            //Delete confirmation alert
            .alert(
                "Confirm deletion",
                isPresented: $showDeleteAlert,
                presenting: listObserver.deleteList
            ) { list in
                Button("Delete \(list.name) only", role: .destructive) {
                    list.delete(in: modelContext)
                    try? modelContext.save()
                }
                Button("Delete all", role: .destructive) {
                    list.delete(includeChildren: true, in: modelContext)
                    try? modelContext.save()
                }
                Button("Cancel", role: .cancel) {
                    // Reset the list to be deleted in the singleton
                    listObserver.deleteList = nil
                }
            } message: { list in
                Text("Do you want to delete all lists in \(list.name)?")
            }
        }
    }
    
    //List editor
    struct TaskListFormView: View {
    
        //Parameters
        let list: TaskList
        
        //Environment values
        @Environment(\.dismiss) private var dismiss
        @Environment(\.modelContext) private var modelContext
        
        //State values
        @State private var selectedList: TaskList?
        @State private var listName: String = ""
        @State private var parentList: TaskList?
        
        //Bindings
        @Bindable var listObserver: ListEditObserver = ListEditObserver.shared
        
        //Body
        var body: some View {
            
            VStack {
                Form {
                    
                    //Field to edit the list name
                    TextField("Name", text: $listName)
                    
                    //Link to select a parent list
                    NavigationLink{
                        TaskListOutline()
                            .environment(\.enableListEdit, false)
                    } label: {
                        HStack {
                            Text("Parent list:")
                                .foregroundStyle(.secondary)
                            Text(parentList?.name ?? "None")
                        }
                    }
                    
                    Section {
                        //Button to clear the parent and dismiss
                        Button {
                            list.parentList = nil
                            try? modelContext.save()
                            dismiss()
                        } label: {
                            Text("Clear parent")
                        }
                        
                        //Disable button if list has no parent
                        .disabled(list.parentList == nil)
                    }
                }
            }
            .onAppear {
                
                //Set initial state values
                listName = list.name
                parentList = listObserver.selectedList ?? list.parentList
            }
            .toolbar {
                ToolbarItem(placement: .topBarLeading) {
                    Button("Cancel") {
                        dismiss()
                    }
                }
                
                ToolbarItem {
                    Button("Save") {
                        list.name = listName
                        list.parentList = parentList
                        
                        try? modelContext.save()
                        listObserver.selectedList = nil
                        
                        dismiss()
                    }
                }
            }
        }
    }
    
    //List Row
    struct TaskListRowView: View {
        
        //Parameters
        let list: TaskList
        let taskLists: [TaskList]
        
        //Environment values
        @Environment(\.dismiss) var dismiss
        
        //Helper function to check if the list has any children
        private func hasChildren(_ list: TaskList) -> Bool {
            return taskLists.contains { $0.parentList == list }
        }
        
        //State values
        @State private var isExpanded: Bool = true
        
        //Body
        var body: some View {
            
            //Show lists that are not the one being edited (so it can't be selected as its own parent)
            if list != ListEditObserver.shared.editList {
                
                if hasChildren(list) {
                    
                    //Show a disclosure group if the list has children
                    DisclosureGroup(isExpanded: $isExpanded) {
                        
                        // Display sub-TaskLists by filtering
                        ForEach(taskLists.filter{ $0.parentList == list }) { childList in
                            TaskListRowView(list: childList, taskLists: taskLists)
                        }
                        
                    } label: {
                        TaskListNameLabel(list: list)
                    }
                    
                } else {
                    
                    //Show just the list name without an arrow if no children
                    TaskListNameLabel(list: list)
                }
            }
        }
    }
    
    //List name label
    struct TaskListNameLabel: View {
        
        //Parameters
        let list: TaskList
        
        @Environment(\.enableListEdit) var enableListEdit
        
        //Body
        var body: some View {
            
            Button {
                ListEditObserver.shared.selectedList = list
            } label: {
                Text(list.name)
            }
            .buttonStyle(PlainButtonStyle()) // Keeps the appearance like a plain label
            .contextMenu {
                if enableListEdit {
                    
                    //Edit button
                    Button {
                        ListEditObserver.shared.editList = list
                    } label: {
                        Label("Edit", systemImage: "gear")
                    }
                    
                    //Promote button
                    Button {
                        //Set the list's parent to the parent of its current parent
                        list.parentList = list.parentList?.parentList
                    } label: {
                        Label("Promote", systemImage: "arrowshape.up.circle")
                    }
                    
                    //Set root list button
                    Button {
                        list.parentList = nil
                    } label: {
                        Label("Set as root list", systemImage: "list.bullet.below.rectangle")
                    }
                    
                    //Delete button
                    Button(role: .destructive) {
                        ListEditObserver.shared.deleteList = list
                    } label: {
                        Label("Delete", systemImage: "xmark")
                    }
                }
            }
        }
    }
    
    
    //Observable singleton
    @Observable
    class ListEditObserver {
        var selectedList: TaskList?
        var editList: TaskList?
        var deleteList: TaskList?
    
        static let shared = ListEditObserver()
        
        private init() {}
    }
    
    //Data container
    @MainActor class DataContainer {
        let schemaModels = Schema([ TaskList.self ])
        let dataConfiguration: ModelConfiguration
        
        static let preview = DataContainer(memoryDB: false)
        
        init(memoryDB: Bool = false) {
            dataConfiguration = ModelConfiguration(isStoredInMemoryOnly: memoryDB)
        }
        
        lazy var dataContainer: ModelContainer = {
            do {
                let container = try ModelContainer(for: schemaModels)
                // seedData(context: container.mainContext)
                return container
            } catch {
                fatalError("\(error.localizedDescription)")
            }
        }()
        
        func resetData(context: ModelContext) {
            do {
                // Fetch all TaskList entries
                let lists = try context.fetch(FetchDescriptor<TaskList>())
                
                // Delete each entry
                for list in lists {
                    context.delete(list)
                }
                
                // Save changes to persist deletions
                try context.save()
                
                // Re-insert sample data
                withAnimation {
                    TaskList.insertSampleData(context: context)
                }
                
                try context.save()
                
            } catch {
                print("Failed to save: \(error.localizedDescription)")
            }
            
        }
    }
    
    //Environment keys
    struct EnableListEditKey: EnvironmentKey {
        static let defaultValue: Bool = true
    }
    
    extension EnvironmentValues {
        var enableListEdit: Bool {
            get { self[EnableListEditKey.self] }
            set { self[EnableListEditKey.self] = newValue }
        }
    }
    
    //App
    @main
    struct TaskListTestApp: App {
        
        var body: some Scene {
            WindowGroup {
                TaskListContentView()
                    .modelContext(DataContainer.preview.dataContainer.mainContext)
            }
        }
    }
    
    //Previews
    #Preview {
        TaskListContentView()
            .modelContext(DataContainer.preview.dataContainer.mainContext)
    }
    
    

    Give it a try and let me know if I can address any specific points in more details.

    UPDATE:

    I updated the previously provided code to include the following changes:

    1. Added context menu Delete button and associated delete logic
    2. As part of the delete logic, there's a new deleteList property added to the singleton and a couple of functions in the model extension (delete() and children()).
    3. Restructured sample data and revised reset/insert logic for more reliable resetting
    4. Added a couple of context buttons to promote list and set as root to illustrate the simple operation when using only parentList.