Setting up the model and views
I’m starting to work on a new app, and the main piece I’m working on
is allowing a user to enter their data for later viewing. The data is
fairly structured, so this is essentially data modeling for CRUD
operations.
The part of the app I’m working on now deals with workouts and a
workout contains multiple exercises. Think “chest workout” includes
“bench press” and “push ups”.
My data model then looks like
struct Exercise {
let name: String
let workout: Workout
}
class Workout: ObservableObject {
@Published let name: String
@Published let exercises: [Exercise]
}
(Workout
can’t be a struct because they mutually refer to each
other; see here for other ways to solve this
problem)
Then into my main view I pass a binding to Workout
so that the user
can edit it as needed.
The problem
One of my views wants to attach some data to the exercises, but I
don’t want to put that into the Exercise
struct since it’s only for
UI purposes. This led me to build a view model (in the colloquial
sense, not necessarily the formal sense), that would encapsulate the
view specific information and the workout information.
class WorkoutEditorViewModel: ObservableObject {
@Published var workout: Workout
@Published var expanded: [Bool]
init(workout: Workout, defaultExpanded: Bool = false) {
self.workout = workout
self.expanded = workout.exercises.map { _ in defaultExpanded }
}
}
Then in my view, I can use this model to present editors for all exercises inline.
struct WorkoutEditor: View {
@ObservedObject var workout: WorkoutEditorViewModel
var body: some View {
ForEach(workout.workout.exercises.indexed(), id: \.1) { index, ex in
ExerciseDetails(expanded: self.$workout.expanded[index],
name: self.$workout.workout.exercises[index].name,
exercise: self.$workout.workout.exercises[index])
}
}
}
Here, I’m using an extension indexed()
to create a list of tuples
that I can use to render a view for each item in the list. The tuples
are needed because ForEach
doesn’t allow index iteration if you’re
going to be adding items to the list, and we need the index to get a
binding to a specific exercise from the view model.
This renders fine, but when editing an exercise, I would get a runtime
error Fatal error: Duplicate keys of type 'Exercise' were found in a Dictionary.
This error doesn’t have much google juice, and as you
can see, I haven’t actually used Exercise
in a dictionary.
Solution
It occured to me that the reason ForEach
requires an id:
argument
is possibly to put the backing data into a dictionary for faster
future lookup. This meant that my ForEach
was iterating over
exercises that were being changed from underneath it, which could only
happen if my view wasn’t being updated when the exercise was changed.
The real source of the problem is my nested ObservableObject
s. Even
though WorkoutEditorViewModel
used @Published
when declaring the
workout
property, this doesn’t automatically subscribe to the
objectWillChange
publisher of the Workout class.
Fortunately, with a little combine, we can do this manually. The
corrected view model looks like this:
class WorkoutEditorViewModel: ObservableObject {
@Published var workout: Workout
@Published var expanded: [Bool]
var anyCancellable: AnyCancellable? = nil
init(workout: Workout, defaultExpanded: Bool = false) {
self.workout = workout
self.expanded = workout.exercises.map { _ in defaultExpanded }
anyCancellable = workout.objectWillChange.sink { _ in
self.objectWillChange.send()
}
}
}
Now, any time the underlying workout is changed, the view model will
notify it’s subscribers (i.e. views) that it has changed as well. This
will trigger a view update, and prevent our dictionary keys from being
changed in place.
Hopefully this helps someone else running into this cryptic duplicate
keys error!