Search code examples
swiftswiftuimodelswiftdata

SwiftData model not updating


I have a problem with the SwiftData model not updating properly when changing some its child property's property. haha.. I know :)

Let me provide some code samples so you can understand my problem. I have the following models:

@Model
final class Exercise {
    @Attribute(.unique) var id: UUID
    @Attribute(.unique) var title: String
    
    @Relationship(deleteRule: .cascade, inverse: \WorkoutExercise.exercise)
    var exercises: [WorkoutExercise] = [WorkoutExercise]()
    
    init(id: UUID = .init(), title: String = "", exercises: [WorkoutExercise] = []) {
        self.id = id
        self.title = title
        self.exercises = exercises
    }
}
@Model
final class Workout {
    @Attribute(.unique) var id: UUID
    @Attribute(.unique) var title: String
    
    @Relationship(deleteRule: .cascade, inverse: \WorkoutExercise.workout)
    var exercises: [WorkoutExercise] = [WorkoutExercise]()
    
    init(id: UUID = .init(), title: String = "", exercises: [WorkoutExercise] = []) {
        self.id = id
        self.title = title
        self.exercises = exercises
    }
}

extension Workout {
    @Transient
    var subtitle: String {
        let exerciseTitles = self.exercises
            .compactMap { $0.title.lowercased() }
            .joined(separator: ", ")
        
        return exercises.isEmpty ? "No exercises" : exerciseTitles
    }
}
@Model
final class WorkoutExercise {
    var id: UUID
    var exercise: Exercise?
    var workout: Workout?
    var sets: [ExerciseSet] = [ExerciseSet]()
    
    init(id: UUID = .init(), exercise: Exercise, sets: [ExerciseSet] = []) {
        self.id = id
        self.exercise = exercise
        self.sets = sets
    }
}

extension WorkoutExercise {
    @Transient
    var title: String {
        exercise?.title ?? "Empty"
    }
}
struct ExerciseSet: Identifiable, Codable {
    var id: UUID
    var weight: Int
    var reps: Int
    var rest: Int
    
    init(id: UUID = .init(), weight: Int, reps: Int, rest: Int) {
        self.id = id
        self.weight = weight
        self.reps = reps
        self.rest = rest
    }
}

Long story short, I separated the Exercise from Workout by using WorkoutExercise so I can create multiple workouts using the same exercise (Exercise) while each of these exercises (WorkoutExercise) has its own implementation using its own sets (ExerciseSet). I want somehow to use then Exercise type to create some statistics based on its exercises (WorkoutExercise).

My problem is that when I want to edit an WorkoutExercise title, I am actually gonna edit its exercise: Exercise? title instead in order to update the title in all workouts workouts that includes the exercise I edited. But somehow this change is not updating the whole UI instantly, but only after I restart the app. As far as I can tell, the subtitle Workout property is not correctly updating.

Here is how I am displaying the workouts.

struct ContentView: View {
    @Environment(\.modelContext) private var modelContext
    
    @Query(sort: \Workout.title, order: .forward) private var workouts: [Workout]
        
    var body: some View {
        NavigationStack {
            List {
                ForEach(workouts) { workout in
                    Section {
                        WorkoutListItem(workout: workout)
                    }
                }
            }
            .navigationTitle("Workouts")
            .scrollDisabled(workouts.isEmpty)
            .listSectionSpacing(.compact)
            .navigationDestination(for: Workout.self) { workout in
                WorkoutDetailView(workout: workout)
            }
        }
    }
}
struct WorkoutListItem: View {
    @Environment(\.modelContext) private var modelContext
    
    private var workout: Workout
    
    init(workout: Workout) {
        self.workout = workout
    }
    
    var body: some View {
        NavigationLink(value: workout) {
            VStack(alignment: .leading) {
                Text(workout.title)
                    .font(.title3.bold())
                Divider()
                Text(workout.subtitle)
                    .font(.subheadline)
                    .foregroundStyle(.secondary)
                    .lineLimit(1)
                    .italic()
            }
        }
    }
}
struct WorkoutDetailView: View {
    @Environment(\.modelContext) private var modelContext
    @Environment(\.dismiss) private var dismiss
    
    private var workout: Workout
    
    init(workout: Workout) {
        self.workout = workout
    }
    
    var body: some View {
        List {
            Section {
                ForEach(workout.exercises) { exercise in
                    Text(exercise.title)
                }
            } header: {
                Text("Exercises")
            }
        }
        .navigationTitle(workout.title)
        .navigationDestination(for: WorkoutExercise.self) { exercise in
            EditWorkoutExerciseView(exercise: exercise)
        }
    }
}

This is how I edit the WorkoutExercise. The update

struct EditWorkoutExerciseView: View {
    @Environment(\.modelContext) private var modelContext
    @Environment(\.dismiss) private var dismiss
    
    var exercise: WorkoutExercise
    
    @State private var title: String = ""
        
    init(exercise: WorkoutExercise) {
        self.exercise = exercise
    }
    
    var body: some View {
        Form {
            Section {
                TextField(exercise.title.isEmpty ? "Enter title here..." : exercise.title, text: $title)
            } header: {
                Text("Exercise Title")
            } footer: {
                Text("This title will be updated in all workouts that include this exercise.")
            }
        }
        .navigationTitle("Edit Exercise")
        .toolbar {
            ToolbarItem(placement: .confirmationAction) {
                Button {
                    update()
                
                    dismiss()
                } label: {
                    Text("Save")
                }
            }
        }
    }

    private func update() {
        guard let exerciseToEdit = exercise.exercise else { return }
        
        if !title.isEmpty {
            exerciseToEdit.title = title
        }
                
        do {
            try modelContext.save()
        } catch {
            print("Error")
        }
    }
}

The problem is that the updated exercise title inside the WorkoutDetailView is displayed correctly, but the subtitle is not updating inside the WorkoutListItem, so in the ContentView list also.

Can anyone explain to me why does this happen?


Solution

  • As a solution for this problem I managed to do as below.

    Create an @Observable class that wraps the Workout type and use this class type in the view.

    @Observable
    class WorkoutModel {
        var workout: Workout
    
        init(workout: Workout) {
            self.workout = workout
        }
    }
    
    struct WorkoutListItem: View {
        @Environment(\.modelContext) private var modelContext
        
        private var workout: WorkoutModel
        
        init(workout: Workout) {
            self.workout = WorkoutModel(workout: workout)
        }
        
        var body: some View {
            NavigationLink(value: workout) {
                VStack(alignment: .leading) {
                    Text(workout.title)
                        .font(.title3.bold())
                    Divider()
                    Text(workout.subtitle)
                        .font(.subheadline)
                        .foregroundStyle(.secondary)
                        .lineLimit(1)
                        .italic()
                }
            }
        }
    }
    

    If any of you have any other better solutions or an improvement for this one, I am more than grateful to hear about them.