[SC]()

iOS. Apple. Indies. Plus Things.

On Apple's Guidance for StateObject Initialization

// Written by Jordan Morgan // Apr 20th, 2023 // Read it in about 3 minutes // RE: SwiftUI

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

I’ve written before on some of the techniques you might have to use for initialization in SwiftUI. For example, dependency injection with @State variables, or how to make a Struct play nice with ObservableObject. But, extend the conversation out to @StateObject and how it should be properly initialized with outside data? The discussion, opinions and techniques start to vary across the board.

Hopefully, that’s all old news now.

In an attempt to put an end to our StateObject woes in a more paradigmatic manner, Luca Bernardi let us know that Apple’s official documentation now tackles the matter. Specifically, there is now text detailing how to handle dependency injection with StateObject. This is exactly the kind of material we need from Apple, and it clears up a lot of confusion and advice I’ve been reading.

Private State

Let’s start with why this can be confusing. First, much like @State, it’s recommended that any @StateObject should be a private variable. Makes sense, as you’re basically indicating to SwiftUI that the containing view should only be interested in that object, and any other view that you consciously pass it down to as an ObservableObject. This is not unlike how some state could end up as a binding somewhere else.

let concatenatedThoughts = """

In fact, not only should a `StateObject` variable be private, it kinda has to be. If it weren't, and you invoked the memberwise intializer that Swift generates, it can have adverse consequences to SwiftUI's storage and state management.

"""

So, state object’s already don’t lend themselves naturally to dependency injection by their very nature.

But, more to our second point, there are times when they can’t be initialized directly. To that end, Apple’s example starts off with the “happy path” where direct initialization is no problem:

class DataModel: ObservableObject {
    @Published var name = "Some Name"
    @Published var isEnabled = false
}

struct MyView: View {
    @StateObject private var model = DataModel() // Create the state object.

    var body: some View {
        Text(model.name) // Updates when the data model changes.
        MySubView()
            .environmentObject(model)
    }
}

Using Outside Data with State Object

As they go on, though, things get a little more realistic. What if you wanted to initialize DataModel with a name value? This is where the confusion from the community came in. However, it turns out that using StateObject’s wrappedValue flavor of the initializer is certainly supported, and even recommended, as long as you are #AwareOfAFewThings.

Again, from Apple:

struct MyInitializableView: View {
    @StateObject private var model: DataModel

    init(name: String) {
        // SwiftUI ensures that the following initialization uses the
        // closure only once during the lifetime of the view, so
        // later changes to the view's name input have no effect.
        _model = StateObject(wrappedValue: { DataModel(name: name) }())
    }

    var body: some View {
        VStack {
            Text("Name: \(model.name)")
        }
    }
}

Which might look like this, higher up the view tree:

MyInitializableView(name: "Steph Curry")

Here’s where you need to be a little careful.

SwiftUI only will initialize a state object the first time you call it within its view. This makes perfect sense when you consider how SwiftUI is designed, because without stable storage across view updates, things would get out of whack, quickly.

But, what are the actual consequences of that?

Well, say you change the model.name value above, and MyInitializableView initializer kicks in. The autoclosure you’ve given the state object won’t be invoked after the first time, which means the actual data model’s name value doesn’t change.

If name was changed to Jordan, then the view would show Jordan but model.name is still going to be whatever it was set to when the autoclosure was run (i.e. ‘Steph Curry’). And, well, that’s probably not what you might’ve been expecting.

So, if there was data outside of that view depending on that mutation, it wouldn’t work.

All of this basically boils down to - dependency injection with state object initialization works great if parties outside of the view housing it are feeding it data that doesn’t change either. To make it more practical, in this example - perhaps if the name was immutable and came from a model like this:

struct ExamplePlayers {
	let steph: Player = .init(name: "Steph Curry", position: .guard, number: 30)
}

When you consider this, it makes perfect sense as to why Apple chose to show us a Text(name: \(model.name)) example, and not a TextEditor(text: $model.name) one.

Forcing the Issue

Of course, there is a way around this - though I don’t think it’s a particularly good one. If you did need the autoclosure to fire again, you could set the identity of the view to the value you’re interested in. Apple shows this technique as well:

MyInitializableView(name: name)
    .id(name) 

Since the state object isn’t recreated when view inputs are changed (and it certainly shouldn’t), but when the view’s identity changes, this route forces the initializer to run again. But again, I can’t really see this being a viable route for most projects. Among other things, it could mess up animations or trigger view renders that maybe you don’t expect down the view tree. So, it feels like a good time to reassess how you’re doing things if you’ve hit this point.

Until next time ✌️

···

Spot an issue, anything to add?

Reach Out.