View not updating when relationship array changes

I'm working on a SwiftUI app using SwiftData for persistence. I have a TaskModel class with a relationship to DailyProgress objects. When I update the progress, the view doesn't reflect the changes. Here's an example to demonstrate: (You can just copy everything)

import SwiftUI
import SwiftData

@main
struct SO_model_testApp: App {
    var sharedModelContainer: ModelContainer = {
        let schema = Schema([
            TaskModel.self,
        ])
        let modelConfiguration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false)

        do {
            return try ModelContainer(for: schema, configurations: [modelConfiguration])
        } catch {
            fatalError("Could not create ModelContainer: \(error)")
        }
    }()

    var body: some Scene {
        WindowGroup {
            ContentView()
        }
        .modelContainer(sharedModelContainer)
    }
}


@Model
class DailyProgress: Identifiable, CustomStringConvertible {
    var id = UUID()
    var date: Date
    var goal: Int
    var task: TaskModel?
    
    init(date: Date, goal: Int) {
        self.date = date
        self.goal = goal
    }
    
    var description: String {
        "\(date) - \(goal)"
    }
    
    var weekOfYear: Int {
        let calendar = Calendar.autoupdatingCurrent
        return calendar.component(.weekOfYear, from: date)
    }
    
    var yearForWeekOfYear: Int {
        let calendar = Calendar.autoupdatingCurrent
        return calendar.component(.yearForWeekOfYear, from: date)
    }
}

@Model
class TaskModel: Identifiable {
    @Attribute(.unique) var id = UUID().uuidString
    var name: String
    var goal: Int
    
    @Relationship(deleteRule: .cascade)
    var progress: [DailyProgress] = []
        
    init(name: String, goal: Int) {
        self.name = name
        self.goal = goal
    }
    
    func goalForDate(_ date: Date) -> Int {
        let calendar = Calendar.autoupdatingCurrent
        for dailyProgress in progress {
            if calendar.isDate(dailyProgress.date, inSameDayAs: date) {
                return dailyProgress.goal
            }
        }
        return 0
    }
    
    @MainActor
    func updateGoal(date: Date, value: Int) {
        let calendar = Calendar.autoupdatingCurrent
        let dateIndex = progress.firstIndex(where: { calendar.isDate($0.date, inSameDayAs: date) })
        
        if let index = dateIndex {
            progress[index].goal += value
            if progress[index].goal <= 0 {
                progress.remove(at: index)
            }
        } else if value > 0 {
            let newProgress = DailyProgress(date: date, goal: value)
            progress.append(newProgress)
        }
    }
}

struct ContentView: View {
    @Query private var tasks: [TaskModel]
    @State private var selectedTask: TaskModel?
    @State private var showingNewTaskSheet = false
    
    var body: some View {
        NavigationView {
            List(tasks) { task in
                TaskRow(task: task)
                    .onTapGesture {
                        selectedTask = task
                    }
            }
            .navigationTitle("Tasks")
            .toolbar {
                ToolbarItem(placement: .navigationBarTrailing) {
                    Button(action: {
                        showingNewTaskSheet = true
                    }) {
                        Image(systemName: "plus")
                    }
                }
            }
            .sheet(item: $selectedTask) {
                TaskDetailView(task: $0)
            }
            .sheet(isPresented: $showingNewTaskSheet) {
                NewTaskView(isPresented: $showingNewTaskSheet)
            }
        }
    }
}

struct TaskRow: View {
    let task: TaskModel
    
    var body: some View {
        HStack {
            Text(task.name)
            Spacer()
            Text("Goal: \(task.goal)")
                .foregroundColor(.secondary)
        }
    }
}

struct TaskDetailView: View {
    @Bindable var task: TaskModel
    @Environment(\.modelContext) private var modelContext
    @State private var today = Date()
    
    var todayTotal: Int {
        task.goalForDate(Date())
    }
    
    var body: some View {
        Form {
            Section(header: Text("Task Details")) {
                TextField("Task Name", text: $task.name)
                Stepper("Daily Goal: \(task.goal)", value: $task.goal, in: 1...100)
            }
            
            Section(header:                 Text("Total: \(todayTotal)")) {
                Text("Total: \(task.goalForDate(today))")
                Button(action: {
                    task.updateGoal(date: today, value: 1)
                }) {
                    Label("Log Progress", systemImage: "plus.circle")
                }
            }
            
//            Section(header: Text("All Progresses")) {
//                if task.progress.isEmpty {
//                    Text("No progress logged yet")
//                        .foregroundColor(.secondary)
//                } else {
//                    ForEach(task.progress.sorted(by: { $0.date > $1.date })) { progress in
//                        HStack {
//                            Text(progress.date, style: .date)
//                            Spacer()
//                            Text("Goal: \(progress.goal)")
//                        }
//                    }
//                }
//            }
        }
        .navigationTitle("Task Details")
    }
}

struct NewTaskView: View {
    @Environment(\.dismiss) private var dismiss

    @Environment(\.modelContext) private var modelContext

    @Binding var isPresented: Bool
    
    @State private var taskName = ""
    @State private var goal = 1
    
    var body: some View {
        NavigationView {
            Form {
                TextField("Task Name", text: $taskName)
                Stepper("Daily Goal: \(goal)", value: $goal, in: 1...100)
            }
            .navigationTitle("New Task")
            .navigationBarItems(
                leading: Button("Cancel") {
                    isPresented = false
                },
                trailing: Button("Save") {
                    modelContext.insert(TaskModel(name: taskName, goal: goal))
                    isPresented = false
                }
                .disabled(taskName.isEmpty)
            )
        }
    }
}

When I tap the "Log Progress" button, updateGoal is called, but the "Total" text doesn't update to reflect the new value.

If you uncomment commented code, then it works fine, because I think the section needs this progress data to show. But why doesn't it get updated when commented out? I still use daily progress implicitly in task.goalForDate(), so I expect it to be updated as well.

Upvotes: 1

Views: 482

Answers (2)

Joakim Danielson
Joakim Danielson

Reputation: 52043

The problem is that when you update a related object in your function this will not trigger an update in SwiftUI since the view engine doesn't get notified about the update.

This seems to be a bug that has been fixed in iOS 18, macOS 15 but as a workaround for older versions one way is to use a property wrapper like @State to get updates for the related object as well.

So first we change the function in the model to return the found object instead

func goalForDate(_ date: Date) -> DailyProgress? {
    let calendar = Calendar.autoupdatingCurrent
    return progress.first(where: { calendar.isDate($0.date, inSameDayAs: date) })
}

Then we add the property

@State private var currentProgress: DailyProgress?

and set it in onAppear

.onAppear {
    currentProgress = task.goalForDate(Date())
}

and of course read the property where needed

var todayTotal: Int {
    currentProgress?.goal ?? 0
}

//...

Text("Total: \(currentProgress?.goal ?? 0)")

Now the view will update whenever the object held by currentProgress is updated by the updateGoal function.

Upvotes: 1

malhal
malhal

Reputation: 30746

You're missing a @Query for the DailyProgress which match the required task. You can use a predicate for that, e.g. something like:

 @Query var progresses: [DailyProgress]

 init(task: TaskModel) {      
    let predicate = #Predicate<DailyProgress> {
$0.task == task
    }
    _progresses = Query(filter: predicate, sort: \ DailyProgress.date)
}

You can see this pattern in TripListItem.swift in Adopting SwiftData for a Core Data app.

Upvotes: 0

Related Questions