Alejandro Ciniglio

Observable Object hierarchies in SwiftUI

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 ObservableObjects. 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!