[SC]()

iOS. Apple. Indies. Plus Things.

Dependency Injection with @State Variables

// Written by Jordan Morgan // Sep 14th, 2022 // Read it in about 3 minutes // RE: SwiftUI

This post is brought to you by Emerge Tools, the best way to build on mobile.

Value types permeate my code base these days. Having been around this whole iPhoneOS SDK gig for a bit, I’ve spent enough evenings hunting down a bug caused by reference types. And so it is, in my SwiftUI endeavors I’ve inevitably hit situations where I need to pass a struct around, essentially - dependency injection between views when I’m outside of the normal pattern of an environment object of some sort.

Previously, I figured the answer to this scenario was to use one of @State’s initializers - either providing an initial value or a wrapped one. Something like this:

struct PersonEditor: View {
    @State private var person: Person
    
    init(person: Person) {
        _person = State(initialValue: person)
    }
    
    var body: some View {
        Form {
            Section("Details") {
                TextField("Name", text: $person.name)
                Stepper("Age: \(person.age)", value: $person.age, in:1...100, step: 1) { changed in
                    
                }
            }
        }
    }
}

When discussing which of the two @State initializers I should go with among a few dev friends, their answer surprised me:

One is bad, and the other is worse.

Well, okay then! Before we look at the easy peezy alternative, the reasons why it’s not recommended to go the route I have above are:

  1. Those state initializers can lead to unusual bugs that are tricky to pin down.
  2. By definition, if the struct is a piece of data the view doesn’t own, then @State is the wrong choice.
  3. Generally speaking, if you find yourself needing to write a View’s initializer for something like this, you should try to figure out if there’s a way to model your data to avoid it. You’re kind of working against SwiftUI’s design at that point.

Leveraging Structs

Let’s revisit the example above. Let’s say to get to that view, we had a list showing before it - and tapping an item presented it:

struct DirectoryView: View {
    @EnvironmentObject var store: Store 
    @State private var people: [Person] = []
        
    var body: some View { 
        List(people) { person in
            NavigationLink(presenting: person.id) {
                PersonCellRow(idea)
            }
        }
        .navigationDestination(for: Person.ID.self) { personID in
            PersonEditor(people.person(for: personID))
        }
        .task {
            people = store.getPeople()
        }
    }
}

So, for the PersonEditor navigation destination, what we should do instead of initializing that @State variable is simply switch it out to something SwiftUI expects for these flows, like a @Binding:

struct PersonEditor: View {
    @Binding var person: Person
    
    var body: some View {
        Form {
            Section("Details") {
                TextField("Name", text: $person.name)
                Stepper("Age: \(person.age)", value: $person.age, in:1...100, step: 1) { changed in
                    
                }
            }
        }
    }
}

And done. I could end the post here if I needed to.

But TL;DR - I typically boxed @Binding properties into presentation contexts, or to modify one single property on another model. But when your model is a struct…then, a @Binding is perfect! With the setup above, any edits we make to the model gets reflected back in the other list.

However, we can extend this pattern to make some U.X. wins very easily. The idea is this: Still pass in the binding, but also have another state variable of the model and edit that. You simply assign to it from the original binding. This way, if you want the edits to save - you simply assign the edited model back to the binding. But, if you don’t - just toss it away and dismiss the view. The binding variable remains untouched:

struct PersonEditor: View {
    @Binding var person: Person
    @State private var editedPerson: Person = .empty
    @Environment(\.dismiss) private var dismiss
    
    var body: some View {
        Form {
            Section("Details") {
                TextField("Name", text: $editedPerson.name)
                Stepper("Age: \(editedPerson.age)", value: $editedPerson.age, in:1...100, step: 1) { changed in
                    
                }
            }
        }
        .toolbar {
            ToolbarItem(placement: .cancellationAction) {
                Button("Cancel") {
                    dismiss()
                }
            }
            ToolbarItem(placement: .confirmationAction) {
                Button("Save") {
                    person = editedPerson
                    dismiss()
                }
            }
        }
        .onAppear {
            editedPerson = person
        }
    }
}

I love this pattern. If you glance it over, maybe it’ll click for you as it did for me. To break down its advantages:

  1. If we don’t want to save the edits, we just leave the view. No harm done, since the binding (i.e. person here) remains untouched. Value types, yay!
  2. If we do want to save our edits, well - now that’s as simple as assigning back to the person from our editedPerson struct. Done. Since it’s a binding, the view before becomes updated as well without much fuss.
  3. Further, we can also apply easy U.X. wins, like disabling the Save button here if there were no edits made. That would just look like this: .disabled(editedPerson == Person.empty). I know that kind of thing isn’t exactly enabled because of this pattern, but it is a nice byproduct.

So, there you have it. I won’t come right out and proclaim this is the “right” way to wrangle dependency injection with @State in SwiftUI, but I will say it’s objectively better than creating your state within your initializer. That’s fairly easy to point at and say, “That’s probably wrong.”

If anything, it’s a great thought process and another poignant reminder to always ask your other developer buddies how they solve stuff - it’s a wonderful way to learn.

Until next time ✌️

···

Spot an issue, anything to add?

Reach Out.