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.
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.
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)
}
}
}
}
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()
}
}
}
}
}
}
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")
}
}
}
}
}
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()
}
}
}
@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?
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.
I updated the previously provided code to include the following changes:
deleteList
property added to the singleton and a couple of functions in the model extension (delete()
and children()
).parentList
.