UPGRADE YOUR SKILLS: Learn advanced Swift and SwiftUI on Hacking with Swift+! >>

SwiftData Deletion Conundrum

Forums > SwiftUI

Hi all,

I've successfully worked through the SwiftData challenges (regular and bonus) but am struggling a bit with view refreshing after deleting - it seems once you drift from displaying/deleting list items things become a bit more complex. I'm working through an evolved version of the SwiftData example project to stetch my SwiftData skills, stealing from some other things I'm developing.

My main issue is getting my views to react to deletions in the SwiftData query. When adding new items to SwiftData in the 'Edit' view, they show up immediately in the 'Display' view. However, when deleting, they delete from the list in 'Edit' but do not disappear from 'Display' until leaving the List and coming back to it. Is there something I'm missing in my SwiftData implementation, or is this just a current limitation of SwiftData? The examples I've found online are all List based, and I'm struggling to implement it in a case that's outside of that construct.

I feel like my issue is related to this topic on SwiftData deletions-that-aren't-actually-deleted, but am unsure how to resolve this within the query: https://www.hackingwithswift.com/quick-start/swiftdata/how-to-check-whether-a-swiftdata-model-object-has-been-deleted

Appreciate any thoughts you have!

First, my SwiftData classes: Lists

import Foundation
import SwiftData

@Model
class Lists {
    var listName: String
    var listDetails: String
    var listDateAdded: Date
    @Relationship(deleteRule: .cascade, inverse: \ListSeries.lists) var listSeries = [ListSeries]()

    init(listName: String = "", listDetails: String = "", listDateAdded: Date = .now) {
        self.listName = listName
        self.listDetails = listDetails
        self.listDateAdded = listDateAdded
    }
}

ListSeries

import Foundation
import SwiftData

@Model
class ListSeries {
    var seriesID: Int
    var seriesDateAdded: Date
    var seriesName: String
    var lists: Lists?

    init(seriesID: Int, seriesDateAdded: Date, seriesName: String) {
        self.seriesID = seriesID
        self.seriesDateAdded = seriesDateAdded
        self.seriesName = seriesName
    }
}

Then the views I'm working with: ContentView

import SwiftData
import SwiftUI

struct ContentView: View {
    @Environment(\.modelContext) var modelContext

    @State private var path = [Lists]()
    @State private var sortOrder = SortDescriptor(\Lists.listName)
    @State private var searchText = ""

    var body: some View {
        NavigationStack(path: $path) {
            ListView(sort: sortOrder, searchString: searchText)
                .navigationDestination(for: Lists.self, destination: DisplayListDetailView.init)
                .searchable(text: $searchText)
                .toolbar {
                    Button("Add List", systemImage: "plus", action: addList)

                    Menu("Sort", systemImage: "arrow.up.arrow.down") {
                        Picker("Sort", selection: $sortOrder) {
                                Text("Name")
                                    .tag(SortDescriptor(\Lists.listName))
                            }
                            .pickerStyle(.inline)
                    }
                }
        }
    }

    func addList() {
        let list = Lists()
        modelContext.insert(list)
        path = [list]
    }
}

#Preview {
    ContentView()
}

ListView

import SwiftData
import SwiftUI

struct ListView: View {
    @Environment(\.modelContext) var modelContext
    @Query(sort: [SortDescriptor(\Lists.listName, order: .reverse), SortDescriptor(\Lists.listName)]) var lists: [Lists]

    var body: some View {
        List {
            ForEach(lists) { list in
                NavigationLink(value: list) {
                    VStack(alignment: .leading) {
                        Text(list.listName)
                            .font(.headline)

                        Text(list.listDateAdded.formatted(.dateTime.day().month().year()))
                    }
                }
            }
            .onDelete(perform: deleteLists)
        }
        .preferredColorScheme(.dark)
    }

    init(sort: SortDescriptor<Lists>, searchString: String) {
        _lists = Query(filter: #Predicate {
            if searchString.isEmpty {
                return true
            } else {
                return $0.listName.localizedStandardContains(searchString)
            }
        }, sort: [sort])
    }

    func deleteLists(_ indexSet: IndexSet) {
        for index in indexSet {
            let list = lists[index]
            modelContext.delete(list)
        }
    }
}

#Preview {
    ListView(sort: SortDescriptor(\Lists.listName), searchString: "")
}

DisplayListView

import SwiftData
import SwiftUI

struct DisplayListView: View {
    @Environment(\.modelContext) var modelContext
    @Query(sort: [SortDescriptor(\Lists.listName, order: .reverse), SortDescriptor(\Lists.listName)]) var lists: [Lists]

    var body: some View {
        List {
            ForEach(lists) { list in
                NavigationLink(value: list) {
                    VStack(alignment: .leading) {
                        Text(list.listName)
                            .font(.headline)

                        Text(list.listDateAdded.formatted(.dateTime.day().month().year()))
                    }
                }
            }
        }
        .preferredColorScheme(.dark)
    }
}

DisplayListDetailView

import SwiftData
import SwiftUI

struct DisplayListDetailView: View {
    @Environment(\.modelContext) private var modelContext

    @Bindable var list: Lists
    @State private var newSeriesName = ""

    var sortedSeries: [ListSeries] {
        list.listSeries.sorted {
            $0.seriesName < $1.seriesName
        }
    }

    @State private var showingSearch = false
    @State private var showingEdit = false
    @State private var series = Series.example

    var body: some View {
        GeometryReader { geometry in
            let screenWidth = geometry.size.width * 0.22
            let screenHeight = screenWidth * 1.5

            ScrollView {
                VStack(alignment: .leading) {
                    Text("\(list.listName)").bold()
                        .padding(.horizontal)
                        .padding(.top, 4)
                    Text("\(list.listDetails)")
                        .opacity(0.8)
                        .padding(.horizontal)
                        .padding(.vertical, 4)
                    Rectangle()
                        .frame(height: 1)
                        .foregroundColor(.gray)
                        .opacity(0.4)

                    LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: 4), content: {
                        ForEach(sortedSeries, id: \.self) { series in
                            SeriesPoster(tvId: series.seriesID, screenWidth: screenWidth, screenHeight: screenHeight)
                        }
                    })
                    .padding(.horizontal)

                    Rectangle()
                        .frame(height: 1)
                        .foregroundColor(.gray)
                        .opacity(0.4)
                }
                .navigationTitle("List")
                .navigationBarTitleDisplayMode(.inline)
                .preferredColorScheme(.dark)
                .sheet(isPresented: $showingEdit) {
                    EditListDetailsView(list: list)
                }
            }
            .toolbar {
                Button() {
                    showingEdit.toggle()
                } label: {
                    Label("Edit List", systemImage: "square.and.pencil")
                }
            }
        }
    }
}

EditListDetailsView

import SwiftData
import SwiftUI

struct EditListDetailsView: View {
    @Environment(\.modelContext) private var modelContext

    @Bindable var list: Lists
    @State private var newSeriesName = ""

    var sortedSeries: [ListSeries] {
        list.listSeries.sorted {
            $0.seriesName < $1.seriesName
        }
    }

    @State private var showingSearch = false
    @State private var series = Series.example

    var body: some View {
        Form {
            TextField("List Name", text: $list.listName)
            TextField("Details", text: $list.listDetails, axis: .vertical)
            DatePicker("Date", selection: $list.listDateAdded)

            Section("Series in List") {
                ForEach(sortedSeries) { series in
                    HStack {
                        SeriesPoster(tvId: series.seriesID, screenWidth: 50, screenHeight: 75)
                        Text(series.seriesName)
                    }
                }
                .onDelete(perform: deleteSeries)
            }

            Section("Add a Series") {
                HStack {
                    Button("Add") {
                        showingSearch.toggle()
                    }
                }
            }
        }
        .navigationTitle("Edit List")
        .navigationBarTitleDisplayMode(.inline)
        .sheet(isPresented: $showingSearch) {
            ListSeriesSearch(list: list, isPresented: $showingSearch)
        }
    }

    func addSeries() {
        guard newSeriesName.isEmpty == false else { return }

        withAnimation {
            let series = ListSeries(seriesID: 1234, seriesDateAdded: Date.now, seriesName: newSeriesName)
            list.listSeries.append(series)
            newSeriesName = ""
        }
    }

    func deleteSeries(_ indexSet: IndexSet) {
        for index in indexSet {
            let series = sortedSeries[index]
            modelContext.delete(series)
        }
    }
}

#Preview {
    do {
        let config = ModelConfiguration(isStoredInMemoryOnly: true)
        let container = try ModelContainer(for: Lists.self, configurations: config)

        let example = Lists(listName: "Example Destination", listDetails: "Example details go here and will automatically expand vertically as they are edited.")
        return EditListDetailsView(list: example)
            .modelContainer(container)
    } catch {
        fatalError("Failed to create model container.")
    }
}

2      

Hi, In DisplayListDetailView make a Query for ListSeries

@Query(sort: \ListSeries.seriesName) private var series: [ListSeries]

with a Predicate in the init

init(list: Lists) {
    self.list = list
    let listName = list.listName
    let predicate = #Predicate<ListSeries> { listSeries in
        listSeries.lists?.listName == listName
    }
    _series = Query(filter: predicate, sort: \ListSeries.seriesName)
}

Then change your LazyVGrid's ForEach to series instead of sortedSeries

LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: 4), content: {
    ForEach(series, id: \.self) { series in
        SeriesPoster(tvId: series.seriesID, screenWidth: screenWidth, screenHeight: screenHeight)
    }
})
.padding(.horizontal)

Hopefully it helps.

2      

hi,

i've confronted this problem a couple of times, first with my comment on Paul's recent 8-video SwiftData series on Destinations and Sights, and then this past weekend with an app of my own that tests out basic uses of SwiftData.

the problem seems to involve a one-to-many relationship: when you delete the object at the "many" end of the relationship, the object at the "one" end seemingly does not update its relationship array.

in your case, you have a relationship of one Lists model to many ListSeries objects. deleting a ListSeries object doesn't seem to get reflected in the Lists model.

i think this is a SwiftData bug, but perhaps being more explicit might be what you need.

my suggestions would be:

(1) decorate the definition of ListSeries.lists as an explicit relationship with a .nullify deletion rule. this might be enough by itself.

@Relationship(deleteRule: .nullify) var lists: Lists?

(2) if (1) does not do it, then before you delete a ListSeries object, explicitly remove it from the Lists.listSeries array.

func deleteSeries(_ indexSet: IndexSet) {
  let seriesArray = sortedSeries // your sortedSeries is a computed property, so make a copy (just call it once)
  let deletions = indexSet.map({ seriesArray[$0] }) // pull out elements to delete by index
  for series in deletions {
    series.lists?.listSeries?.removeAll(where: { $0.persistentModelID == series.persistentModelID })
    modelContext.delete(series)
  }
}

one or the other of (1) or (2) have so far worked in my cases.

hope that helps,

DMG

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.