I work on an iOS app using SwiftUI and SwiftData. I added a computed property to one of my models - Parent - that uses relationship - array of Child models - data and I started getting strange problems. Let me start with models:
@Model
final class Parent {
var name: String
@Relationship(deleteRule: .cascade, inverse: \Child.parent)
var children: [Child]? = []
var streak: Int {
// Yes, I know that's not optimal solution for such counter ;)
guard let children = children?.sorted(using: SortDescriptor(\.date, order: .reverse)) else { return 0 }
var date = Date.now
let calendar = Calendar.current
for (index, child) in children.enumerated() {
if !calendar.isDate(child.date, inSameDayAs: date) {
return index
}
date = calendar.date(byAdding: .day, value: -1, to: date) ?? .now
}
return children.count
}
init(name: String) {
self.name = name
}
}
@Model
final class Child {
var date: Date
@Relationship(deleteRule: .nullify)
var parent: Parent?
init(date: Date, parent: Parent) {
self.date = date
self.parent = parent
}
}
At first everything works as expected. The problem arises once I try to remove one of child from the parent instance. I remove the value from context and save changes without any problems, at least not ones that can be caught by do { } catch
. But instead of refreshing UI I get an signal SIGABRT
somewhere inside SwiftData internals that points to the line where I'm trying (inside View
body
) get a child from a Query
:
struct LastSevenDaysButtons: View {
@Environment(\.modelContext)
private var modelContext
@Query
private var children: [Child]
private let dates: [Date]
private let parent: Parent
init(for parent: Parent) {
self.parent = parent
var lastSevenDays = [Date]()
let calendar = Calendar.current
let firstDate = calendar.date(byAdding: .day, value: -6, to: calendar.startOfDay(for: .now)) ?? .now
var date = firstDate
while date <= .now {
lastSevenDays.append(date)
date = calendar.date(byAdding: .day, value: 1, to: date) ?? .now
}
dates = lastSevenDays
let parentId = parent.persistentModelID
_children = Query(
filter: #Predicate {
$0.parent?.persistentModelID == parentId && $0.date >= firstDate
},
sort: [SortDescriptor(\Child.date, order: .reverse)],
animation: .default
)
}
var body: some View {
VStack {
HStack(alignment: .top) {
ForEach(dates, id: \.self) { date in
// Here is the last point on stack from my code that I see
let child = children.first { $0.date == date }
Button {
if let child {
modelContext.delete(child)
} else {
modelContext.insert(Child(date: date, parent: parent))
}
do {
try modelContext.save()
} catch {
print("Can't save changes for \(parent.name) on \(date.formatted(date: .abbreviated, time: .omitted)): \(error.localizedDescription)")
}
} label: {
Text("\(date.formatted(date: .abbreviated, time: .omitted))")
.foregroundStyle(child == nil ? .red : .blue)
}
}
}
}
}
}
The LastSevenDaysButtons
View
is kind of deep in a View
hierarchy:
RootView
-> ParentList
-> ParentListItem
-> LastSevenDaysButtons
However once I move insides of ParentList
to RootView
application works just fine, although I see and warning: === AttributeGraph: cycle detected through attribute 6912 ===
.
What could be that I do wrong in here? I believe it must me something I'm missing here, but after 2 days of debug, trial and errors, I can't think clearly anymore.
I prepared minimal repro you can try yourself:
import SwiftData
import SwiftUI
@Model
final class Parent {
var name: String
@Relationship(deleteRule: .cascade, inverse: \Child.parent)
var children: [Child]? = []
var streak: Int {
guard let children = children?.sorted(using: SortDescriptor(\.date, order: .reverse)) else { return 0 }
var date = Date.now
let calendar = Calendar.current
for (index, child) in children.enumerated() {
if !calendar.isDate(child.date, inSameDayAs: date) {
return index
}
date = calendar.date(byAdding: .day, value: -1, to: date) ?? .now
}
return children.count
}
init(name: String) {
self.name = name
}
}
extension Parent {
static func createExamples(in context: ModelContext) {
let calendar = Calendar.current
for i in 0 ..< 10 {
let parent = Parent(name: "Parent \(i)")
var date = calendar.startOfDay(for: .now)
for _ in 0 ..< 10 {
context.insert(Child(date: date, parent: parent))
date = calendar.date(byAdding: .day, value: -1, to: date) ?? .now
}
context.insert(parent)
}
do {
try context.save()
} catch {
fatalError("Couldn't create examples: \(error.localizedDescription)")
}
}
}
@Model
final class Child {
var date: Date
@Relationship(deleteRule: .nullify)
var parent: Parent?
init(date: Date, parent: Parent) {
self.date = date
self.parent = parent
}
}
class ModelContainerPrivider {
var modelContainer: ModelContainer
@MainActor
var modelContex: ModelContext {
modelContainer.mainContext
}
init(isStoredInMemoryOnly: Bool = true) {
let configuration = ModelConfiguration(isStoredInMemoryOnly: isStoredInMemoryOnly)
let schema = Schema([Parent.self, Child.self])
do {
modelContainer = try ModelContainer(for: schema, configurations: [configuration])
} catch {
fatalError("Couldn't create model container: \(error.localizedDescription)")
}
}
}
struct ContentView: View {
var body: some View {
NavigationStack {
ParentList()
}
}
}
struct ParentList: View {
@Query(sort: [SortDescriptor(\Parent.name)], animation: .default)
private var parents: [Parent]
var body: some View {
List {
ForEach(parents) { parent in
ParentListItem(for: parent)
}
}
}
}
struct ParentListItem: View {
@Bindable
private var parent: Parent
init(for parent: Parent) {
self.parent = parent
}
var body: some View {
VStack(alignment: .leading) {
NavigationLink(value: parent) {
Text(parent.name)
Text("Streak: \(parent.streak)")
}
LastSevenDaysButtons(for: parent)
}
.buttonStyle(.plain)
.id(parent.persistentModelID)
}
}
struct LastSevenDaysButtons: View {
@Environment(\.modelContext)
private var modelContext
@Query
private var children: [Child]
private let dates: [Date]
private let parent: Parent
init(for parent: Parent) {
self.parent = parent
var lastSevenDays = [Date]()
let calendar = Calendar.current
let firstDate = calendar.date(byAdding: .day, value: -6, to: calendar.startOfDay(for: .now)) ?? .now
var date = firstDate
while date <= .now {
lastSevenDays.append(date)
date = calendar.date(byAdding: .day, value: 1, to: date) ?? .now
}
dates = lastSevenDays
let parentId = parent.persistentModelID
_children = Query(
filter: #Predicate {
$0.parent?.persistentModelID == parentId && $0.date >= firstDate
},
sort: [SortDescriptor(\Child.date, order: .reverse)],
animation: .default
)
}
var body: some View {
VStack {
HStack(alignment: .top) {
ForEach(dates, id: \.self) { date in
let child = children.first { $0.date == date }
Button {
if let child {
modelContext.delete(child)
} else {
modelContext.insert(Child(date: date, parent: parent))
}
do {
try modelContext.save()
} catch {
print("Can't save changes for \(parent.name) on \(date.formatted(date: .abbreviated, time: .omitted)): \(error.localizedDescription)")
}
} label: {
Text("\(date.formatted(date: .abbreviated, time: .omitted))")
.foregroundStyle(child == nil ? .red : .blue)
}
}
}
}
}
}
@main
struct PlaygroundApp: App {
@State
private var provider = ModelContainerPrivider(isStoredInMemoryOnly: true)
init() {
Parent.createExamples(in: provider.modelContex)
}
var body: some Scene {
WindowGroup {
ContentView()
}
.modelContainer(provider.modelContainer)
}
}
#Preview {
let provider = ModelContainerPrivider()
Parent.createExamples(in: provider.modelContex)
return ContentView()
.modelContainer(provider.modelContainer)
}