Issue #917

Interesting SwiftUI Q&A during WWDC21

@StateObject or ObservedObject?

Q: Let’s say I have a purely SwiftUI flow. I have a ListView with a @StateObject var listFetcher, that makes requests for a list of items. Tapping on an item in the list navigates to a DetailView, which has an ObservableObject property detailFetcher, that makes requests for details on the item. What’s the best way to structure DetailView and which property wrapper would we use for detailFetcher in DetailView?

Have an initializer like init(itemID: Int), and use @StateObject? This would require us to eventually update the detailFetcher property with something like detailFetcher.itemID = itemID in the body’s onAppear. Pass in the detailFetcher into the initializer like init(detailFetcher: ObservableObject) and make the property @ObservableObject? If this is preferred, where would this detailFetcher live if not in SwiftUI?

A: In general, use @StateObject when the view in question owns the associated object, i.e. the object will be created when the view is created, and should be destroyed when the view is removed.

In contrast, use @ObservedObject when the view needs to reference an object that is owned by another view or something else. So the view is dependent on the object, but their lifetimes are not tied together.

For example, you could have a main screen that uses @StateObject to initialize your app’s model, and then pass that object off to detail screens using @ObservedObject. Also check out the Demystify SwiftUI talk to learn more about this!

ObservedObject not used

Q: When an observedObject is passed into a view, does SwiftUI differentiate between views that actually use it (the object is used in the body) and ‘intermediate’ views (which just pass that object to a child? )? Or are all views just invalidated?

A: Yes, there is a difference. If you don’t use any of the ObservableObject property wrappers (@StateObject, @ObservedObject) the view would not observe and update the instance. So you you just need to pass an ObservableObject through some intermediary view just make it a regular property on the view but make sure to use the property wrapper if you ever read any of the value in the view, otherwise your view will no be consistent with your data.

Also, @EnvironmetObject is a great tool when you have an ObservableObject that you want to pass down multiple levels of your view hierarchy without having to manually do it every step of the way.

frame vs Spacer

Q: In most cases, the layout behavior with Spacer can be replaced with .frame(maxWidth:alignment:)(or height) seamlessly. Since Spacer is an actual View that is arranged within the view hierarchy, using Spacer will consume more memory and cpu resources. And Demystify SwiftUI also says “modifier is cheap”. So should I use .frame instead of Spacer as much as possible?

A: While Spacer is a view, it doesn’t end up displaying anything of its own so it is plenty lightweight. Using .frame can have other behavior introduced to the way the view gets laid out beyond just changing its size. They both have their uses, so use them each where appropriate.

A: To add a little more onto this, even in cases where you will get almost entirely the same behavior between the two, the performance difference will be so minimal that I would strongly suggest prioritizing code readability over performance / memory use to make this decision.

If Spacer makes it more clear what layout you’re trying to specify, use that, and vice versa.

Get View size

Q: Is there any way to get GeometryReader size from another view? I want to replace “???” with the height of “Hello world!”.

A: Using a GeometryReader in a background of a view ensures the GeometryReader doesn’t grow to be larger than that containing view, but it makes it tricky to bubble its size out. You could do something like this:

struct ContentView: View {
    @State private var height = 100.0
    
    var body: some View {
        MyView().background {
            GeometryReader { proxy in
                Color.clear
                    .onAppear {
                        height = proxy.size.height
                    }
                    .onChange(of: proxy.size.height) {
                        height = $0
                    }
            }
        }
    

A: You must ensure you will not cause a continuous layout loop here, if your layout responds to height changing in a way that causes the GeometryReader to lay out again and cause height to get updated, you can get into a loop!

Q: Are NavigationLinks “lazy” in iOS 15

A: NavigationLinks do not fully resolve their destinations until they are triggered, though the value of the destination view is created when the NavigationLink is created. In general, we recommend avoiding as much work as possible when a view is initialized, which would avoid potential issues here. This is important for performance. Instead, have that work be triggered within the view’s body, such as using onAppear or the new task() modifier. SwiftUI may reinitialize views for any number of reasons, and this is a normal part of the update process. I’d recommend watching the Demystify SwiftUI talk, which helps explain some of these concepts in more detail.

Downside of single app state

Q: I have a class Store<State, Action>: ObservableObject that holds the whole app state. It lives as @StateObject in the root of the App lifecycle and passed via environment to all views. View send actions to the store and update as soon as store’s state updated. It allows me to keep the app consistent. Could you please mention any downsides of this approach from your prospective?

A: That approach makes every view in your app dependent on a single Observable object. Any change to a Published property forces every view that references the environment object to be updated.

StateObject directly

Q: When I’ve needed to inject data into a detail view, but still let the view have ownership of its StateObject, I’ve used the StateObject(wrappedValue:) initializer directly in my view initializer, for example:

public struct PlanDetailsView: View {

    @StateObject var model: PlanDetailsModel
    
    public init(plan: Plan) {
        self._model = StateObject(wrappedValue: PlanDetailsModel(plan: plan))
    }

    ...
}

Is this an acceptable use of the initializer? I know StateObject is only supposed to initialize at the start of the View’s lifetime, and not on subsequent instantiations of the View value, so I want to make sure I’m not forcing it to re-allocate new storage each time the View is re-instantiated.

A: Yes, this is an acceptable use of the initializer and your understanding is correct: that object will be create only at the beginning of the view lifetime and kept alive. The StateObject’s wrapped value is an autoclosure that is invoke only once at the beginning of the view lifetime. That also means that SwiftUI will capture the value of plan when is firstly created; something to keep in mind is that if you view identity doesn’t change but you pass a different plan SwiftUI will not notice that.

EnvironmentObject vs ObservedObject

Q: In general, to pass data around, would it better to have an EnvironmentObject that could be called within a view, or an ObservedObject that gets passed down (and/or injected) through child views?

A: Both have their uses, and it depends on the architecture you’re building. If you have one (or a few) large ObservableObjects that large parts of the view hierarchy need to see, I would generally recommend EnvironmentObject as SwiftUI can look at which of your views depend on the EnvironmentObject and only invalidate those when your ObservableObject changes (you can get this behavior with ObservedObject too, but it’s more cumbersome). Plus, views that don’t actually use the ObservableObject don’t get cluttered with code relating to it.

That said, if your model is, for example, an object graph that is largely not structured based on your view hierarchy, it may make more sense to use ObservedObject to grab pieces of that model out to use in your view.