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?
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.