ios – SwiftUI not observing SwiftData adjustments

0
16
ios – SwiftUI not observing SwiftData adjustments


I’ve an app with the next mannequin:

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

If I run the next take a look at, I get the anticipated outcomes – Father or mother has it is taskLists array up to date to incorporate the Youngster listing created. I do not explicitly add the kid to the guardian array – the parentList relationship property on the kid causes SwiftData to robotically carry out the append into the guardian array:

    @Take a look at("TaskList with youngsters with unbiased saves are within the database")
    func test_savingRootTaskIndependentOfChildren_SavesAllTaskLists() async throws {
        let modelContext = TestHelperUtility.createModelContext(useInMemory: false)
        let parentList = TaskList(identify: "Father or mother")
        
        modelContext.insert(parentList)
        strive modelContext.save()
        
        let childList = TaskList(identify: "Youngster")
        childList.parentList = parentList
        modelContext.insert(childList)
        strive modelContext.save()
        
        let fetchedResults = strive modelContext.fetch(FetchDescriptor())
        let fetchedParent = fetchedResults.first(the place: { $0.identify == "Father or mother"})
        let fetchedChild = fetchedResults.first(the place: { $0.identify == "Youngster" })
        #anticipate(fetchedResults.rely == 2)
        #anticipate(fetchedParent?.taskLists.rely == 1)
        #anticipate(fetchedChild?.parentList?.identify == "Father or mother")
        #anticipate(fetchedChild?.parentList?.taskLists.rely == 1)
    }

I’ve a subsequent take a look at that deletes the kid and exhibits the guardian array being up to date accordingly. With this context in thoughts, I am not seeing these relationship updates being noticed inside SwiftUI. That is an app that reproduces the difficulty. On this instance, I’m making an attempt to maneuver “Finance” from underneath the “Work” guardian and into the “House” listing.

enter image description here

To begin, the next code is a working instance the place the habits does what I anticipate – it strikes the listing from one guardian to a different with none situation. That is carried out utilizing the native OutlineGroup in SwiftUI.

ContentView

struct ContentView: View {
    @Question(type: TaskList.identify) var taskLists: [TaskList]
    @State personal var selectedList: TaskList?
    
    var physique: some View {
        NavigationStack {
            Checklist {
                ForEach(taskLists.filter({$0.parentList == nil})) { listing in
                    OutlineGroup(listing, youngsters: .taskLists) { listing in
                        Textual content(listing.identify)
                            .onTapGesture {
                                selectedList = listing
                            }
                    }
                }
            }
            .sheet(merchandise: $selectedList, onDismiss: {
                selectedList = nil
            }) { listing in
                TaskListEditorScreen(existingList: listing)
            }
        }
    }
}

TaskListEditorScreen

struct TaskListEditorScreen: View {
    @Atmosphere(.dismiss) personal var dismiss
    @Atmosphere(.modelContext) personal var modelContext
    
    @State personal var viewModel: TaskListEditorViewModel
    @Bindable var listing: TaskList
    
    init(existingList: TaskList) {
        listing = existingList
        viewModel = TaskListEditorViewModel(taskList: existingList)
    }
    
    var physique: some View {
        NavigationView {
            TaskListFormView(viewModel: viewModel)
                .toolbar {
                    ToolbarItem {
                        Button("Cancel") {
                            dismiss()
                        }
                    }
                    
                    ToolbarItem {
                        Button("Save") {
                            listing.identify = viewModel.identify
                            listing.parentList = viewModel.parentTaskList
                            strive! modelContext.save()
                            
                            dismiss()
                        }
                    }
                }
        }
    }
}

TaskListFormView

struct TaskListFormView: View {
    @Bindable var viewModel: TaskListEditorViewModel
    
    var physique: some View {
        VStack {
            Type {
                TextField("Identify", textual content: $viewModel.identify)
                
                NavigationLink {
                    TaskListPickerScreen(viewModel: self.viewModel)
                } label: {
                    Textual content(self.viewModel.parentTaskList?.identify ?? "Father or mother Checklist")
                }
            }
        }
    }
}

TaskListPickerScreen

struct TaskListPickerScreen: View {
    @Atmosphere(.dismiss) personal var dismiss
    @Question(filter: #Predicate { $0.parentList == nil }, type: TaskList.identify)
    personal var taskLists: [TaskList]
    
    @Bindable var viewModel: TaskListEditorViewModel
    
    var physique: some View {
        Checklist {
            ForEach(taskLists) { listing in
                OutlineGroup(listing, youngsters: .taskLists) { baby in
                    getRowForChild(baby)
                }
            }
        }
        .toolbar {
            ToolbarItem {
                Button("Clear Father or mother") {
                    viewModel.parentTaskList = nil
                    dismiss()
                }
            }
        }
    }
    
    @ViewBuilder func getRowForChild(_ listing: TaskList) -> some View {
        HStack {
            Textual content(listing.identify)
        }
        .onTapGesture {
            if listing.identify == viewModel.identify {
                return
            }
            
            self.viewModel.parentTaskList = listing
            dismiss()
        }
    }
}

TaskListEditorViewModel

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

You may setup the next container and seed it with take a look at information to confirm that the result’s the lists can transfer between mother and father 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 = strive ModelContainer(for: schemaModels)
            seedData(context: container.mainContext)
            return container
        } catch {
            fatalError("(error.localizedDescription)")
        }
    }()
    
    func seedData(context: ModelContext) {
        let lists = strive! context.fetch(FetchDescriptor())
        if lists.rely == 0 {
            Activity { @MainActor in
                SampleData.taskLists.filter({ $0.parentList == nil }).forEach {
                    context.insert($0)
                }
                
                strive! context.save()
            }
        }
    }
}

struct SampleData {
    static let taskLists: [TaskList] = {
        let residence = TaskList(identify: "House")
        let work = TaskList(identify: "Work")
        let reworking = TaskList(identify: "Transforming", parentTaskList: residence)
        let kidsBedroom = TaskList(identify: "Youngsters Room", parentTaskList: reworking)
        let livingRoom = TaskList(identify: "Dwelling Room", parentTaskList: reworking)
        let administration = TaskList(identify: "Administration", parentTaskList: work)
        let finance = TaskList(identify: "Finance", parentTaskList: work)
                
        return [home, work, remodeling, kidsBedroom, livingRoom, management, finance]
    }()
}

Nevertheless, I must customise the structure and interplay of every row, together with how the indications are dealt with. To facilitate this, I changed using OutlineGroup with my very own customized views. The next three views make up that part – permitting for guardian/baby nesting.

Word at face worth it might seem to be these do not do a lot else over OutlineGroup. In my actual app these are extra sophisticated. It’s streamlined for the reproducible instance.

struct TaskListRowContentView: View {
    @Atmosphere(.modelContext) personal var modelContext
    @Bindable var taskList: TaskList
    @State var isShowingEditor: Bool = false
    
    init(taskList: TaskList) {
        self.taskList = taskList
    }
    
    var physique: some View {
        HStack {
            Textual content(taskList.identify)
        }
        .contextMenu {
            Button("Edit") {
                isShowingEditor = true
            }
        }
        .sheet(isPresented: $isShowingEditor) {
            TaskListEditorScreen(existingList: taskList)
        }
    }
}

struct TaskListRowParentView: View {
    @Bindable var taskList: TaskList
    @State personal var isExpanded: Bool = true
    
    var youngsters: [TaskList] {
        taskList.taskLists!.sorted(by: { $0.identify < $1.identify })
    }
    
    var physique: some View {
        DisclosureGroup(isExpanded: $isExpanded) {
            ForEach(youngsters) { baby in
                if baby.taskLists!.isEmpty {
                    TaskListRowContentView(taskList: baby)
                } else {
                    TaskListRowParentView(taskList: baby)
                }
            }
        } label: {
            TaskListRowContentView(taskList: self.taskList)
        }
    }
}

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

With these views outlined, I replace my ContentView to make use of them as a substitute of the OutlineGroup.

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

With this transformation in place, I begin to expertise my situation inside SwiftUI. I’ll transfer the Finance listing out of the Work listing by way of the editor and into the House guardian. Throughout debugging, I can confirm that the modelContext.save() name I’m doing instantly causes each of the guardian lists to replace. The work.taskLists is lowered by 1 and the residence.tasksLists array as elevated by 1 as anticipated. I can kill the app and relaunch and I see the finance listing as a toddler of the House listing. Nevertheless, I do not see this replicate in real-time. I’ve to kill the app to see the adjustments.

If I alter my save code in order that it manually updates the guardian array – the difficulty goes away and it really works as anticipated.

ToolbarItem {
    Button("Save") {
        listing.identify = viewModel.identify
        listing.parentList = viewModel.parentTaskList
        
        // Manually add to "House"
        if let newParent = viewModel.parentTaskList {
            newParent.taskLists?.append(listing)
        }
        
        strive! modelContext.save()
        
        dismiss()
    }
}

This has me confused. After I debug this, I can see that viewModel.parentTaskList.taskLists.rely equals 2 (Transforming, Finance). I can print the contents of the array and each the Transforming and Finance fashions are in there as anticipated. Nevertheless, the UI does not work until I explicitly name newParent.taskLists?.append(listing). The listing already exists within the array and but I have to do that to ensure that SwiftUI to replace it is binding.

Why does my specific append name clear up for this? I do not perceive how the array was mutated by SwiftData and the observing Views didn’t get notified of the change. My unique method (not manually updating the arrays) works effective in each unit/integration take a look at I run however I can not get SwiftUI to watch the array adjustments.

If somebody might clarify why that is the case and whether or not or not I will be required to manually replace the arrays going ahead I’d admire it.

I’ve the total supply code obtainable as a Gist for simpler copy/paste – it matches the contents of the publish.

Edit
One different factor that causes confusion for me is that the Finance listing already exists previous to my manually appending it to the guardian listing array. So, regardless of my appending it anyway SwiftData is sensible sufficient to not duplicate it. With that being the case, I do not know if it is simply changing what’s in there, or saying “nope” and never including it. If it is not including it, then what’s notifying the observing Views that the information modified?

LEAVE A REPLY

Please enter your comment!
Please enter your name here