SwiftUI prides itself on how simple and easy it is to spin up beautiful user experiences with its declarative syntax. The most common way of showing off is by skipping creating initializers in Views. When Swift structs can automatically parametrize your properties, it gives the impression that setting up an init is just wasted lines of code in a sea of beautiful SwiftUI.
As I check out more and more code, I’m noticing that this has given a false impression that initializers should be avoided at all costs. “Why duplicate what’s already done for free?”
The reasons I believe this has caught on are three-fold: first, SwiftUI is a great entry point for aspiring developers and you just may not know about init (and that’s ok). Second, we underestimate the NEEDS of our View and its properties at this stage of the View lifecycle. Lastly, it’s because there is some understandable confusion around initializers, property wrappers, and reactive objects.
In this article, I aim to explain what initializers are, why we get certain things for free, and what’s available to us. I’ll warn up front, there are some tricky bits to this. Also, I can’t imagine this will be exhaustive (despite the fact this will be the longest post to date). But it will absolutely highlight some of the more common paths and pitfalls I’ve seen.
Reviewing View Initialization and the Freebies
Let’s quickly go over what an initializer even is in swift and what its purpose is. Initializers are something that sets up a few things for whatever object you are trying to instantiate (create):
The initial state of your properties and therefore your object
The dependencies your object has in order to accomplish its goals
SwiftUI Views are built using structures (structs) instead of classes. A feature of structs are that they have Memberwise Initializers for free. These are generated by the compiler recognizing the properties and creating initializers with parameters defined by those properties.
An example would be having a View with some initial text that I want to pass into the view and display when it’s first shown. We can simply code this as:
struct SomeTextfulView: View {
var initialText: String
var body: some View {
Text(initialText)
}
}
If we were to define the initializer ourselves it could look like this:
struct SomeTextfulView: View {
var initialText: String
init(initialText: String) {
self.initialText = initialText
}
var body: some View {
Text(initialText)
}
}
Both examples can be instantiated with SomeTextView(initialText: “Hello World”)
.
Here’s what going on in both of the samples above. Our View has a String property without a default value. We have an init that takes in a String to be determined when an instance is declared. When the View is created, we pass that parameter value into the property. That way, when our View appears, the value is readily available.
Notice the phases we went through: declared, created, appears. The created/creation phase is where initializer magic happens.
Note: similarly, deinitializers also exist, but after our View has disappeared and is cleaning itself up.
Initializing States and Bindings of Values
The previous section discussed a basic property being set at initialization. In SwiftUI, we can also have wrapped properties, indicated with wrappers such as @State or @Binding. These also get counted with Memberwise Initializers, so we can simply modify our previous example to have our property became a State:
struct SomeTextfulView: View {
@State var initialText: String
var body: some View {
Text(initialText)
}
}
And, again, similar with a custom initializer
struct SomeTextfulView: View {
@State var initialText: String
init(initialText: String) {
self.initialText = initialText
}
var body: some View {
Text(initialText)
}
}
However, it is worth mentioning that wrapped properties do act a bit differently under the hood. To learn more, I’d encourage reading the proposal for property wrappers, but long story short there is a privately held storage of the value, itself. It’s that value that is wrapped by the logic the property wrapper is offering to provide. By default, this storage is represented by the name of the property with a ‘_’ prefix. So, another way of initializing the value would be:
init(initialText: String) {
self._initialText = .init(wrappedValue: initialText)
}
When declaring a wrapped property with a default value, SwiftUI automatically handles wrapping and setting the value for us. When using a custom initializer, specifically for @State or @Binding, we can just stick with the former syntax with the understanding that, again, SwiftUI is doing the heavy lifting under the hood. But as we will see, this idea of the storage behind a wrapped property becomes a bit more important.
Initializing StateObjects of ObservableObjects
Now we go a bit further into one of the more powerful options available for our experiences, but also one of the trickiest.
An ObservableObject is a Combine type that holds together Published properties alongside any other logic or properties that are associated with it. An oversimplified way of explaining is it’s a way to group together States and their supportive code, instead of declaring them individually in your View.
SwiftUI can then reference and bind to properties of these Observed Objects. To do so, there are two special property wrappers to assign to them: @ObservedObject and @StateObject. To understand the differences, I’m gonna point you to the wonderful Antoine van der Lee and this article where he goes over them in detail. For now, I want to dive into StateObject in particular.
Basic Use
Lets say we have a basic model to observe:
class MyModel: ObservableObject {
@Published var myText = "This is myText"
@Published var yourText = "This is yourText"
}
We can simply declare properties to hold an instance of our model (this time with a @StateObject wrapper) and set a default value like below:
struct SomeVeryTextfulView: View {
@StateObject var myModel = MyModel()
var body: some View {
Text(initialText)
}
}
However, if we are looking to pass in an instance of our model, we cannot safely assume this can be used alongside Memberwise Initializers. Worse, we cannot just call .init(wrappedValue)
in an initializer.
This is where things get tricky.
The Secret Behind StateObject
If you go to the Apple documentation for StateObject and go to this section, they encourage that you “call the object’s initializer explicitly from within its container’s initializer”. In other words, they prefer you do this:
init(initialText: String) {
self._myModel = .init(wrappedValue: MyModel(myText: initialText))
}
But they don’t clearly explain that initializing your ObservableObject outside the View and passing it in is discouraged. In other words, this is not a valid initializer:
// DO NOT DO THIS
init(myModel: MyModel) {
self._myModel = .init(wrappedValue: myModel)
}
There’s a reason for this and evidence to support it. In the same documentation, Apple explains that in the event a value changes and causes a redraw of the View, StateObject specifically guards against the ObservableObject from being recreated or accidentally lost. This way the values of state within that object remain, post-redraw.
StateObject does this by controlling the actual initialization of your ObservableObject. The docs state “SwiftUI only initializes a state object the first time you call its initializer in a given view. This ensures that the object provides stable storage even as the view’s inputs change.”
The evidence for this is in the actual .init(wrappedValue)
abstraction for StateObject:
@inlinable public init(wrappedValue thunk: @autoclosure @escaping () -> ObjectType)
Instead of init(wrappedValue value: Value)
like for State, StateObject takes a specially marked closure. This is how they can control ObservableObject being initialized only once: they literally do the initialization and keep tabs on it!
Pulling Back the Curtain
Let’s break down what is meant by “specially marked closure”. You’ll notice that thunk is a closure marked with @autoclosure and @escaping.
The @escaping simply informs the compiler that the closure has the potential to be stored elsewhere or perhaps even outlive the function it’s being passed into. This is probably for the benefit of whatever magic happens within SwiftUI, but if you’d like to learn more, check out this helpful article.
As for @autoclosure, we encounter something pretty neat though it is also the source of some deception. Normally, when passing in a closure we use the typical syntax of some logic wrapped inside the usual curly bracket block {…}. With @autoclosure, it automatically creates a closure based off an expression.
// What we would normally write
someFuncThatTakesAClosure(closure: { print("manual closure") })
// What @autoclosure allows
someFuncThatTakesAnAutoClosure(closure: print("auto closure"))
So when we do .init(wrappedValue: MyModel(myText: initialText))
, it’s a bit deceptive because it looks like (through order of operations) that MyModel is being initialized and the resulting Value passed as a parameter. But in fact it’s the very initializer, itself, that’s being collected inside an executable closure!
Proper Way of Handling StateObject in View Initializers
With all that explained, the burning question is “if I need to use a custom View initializer with a StateObject, what do I do?”.
Let’s say you can’t set a default value to your StateObject because there’s some dependency. You can pass in that dependency and then pass it into the “autoclosed” initializer just as we showed before:
init(initialText: String) {
self._myModel = .init(wrappedValue: MyModel(myText: initialText))
}
If you insist on passing in the object itself (perhaps you want to shield the View from a type that only the object should know about), then you can try this:
init(myModel: @autoclosure @escaping () -> MyModel) {
self._myModel = .init(wrappedValue: myModel)
}
This essentially raises up what StateObject expects in its own initializer, with the added caveat of the specific OberservableObject Type the View requires.
Note that this means you’ll need to pass in the initializer, not an initialized object:
// CAN'T DO THIS
var myModel = MyModel()
SomeVeryTextfulView(myModel: myModel)
// MUST DO THIS
SomeVeryTextfulView(myModel: MyModel())
Initializing with Observable (Observation Framework)
If you’re migrating to or adopting the more recent Observation framework, initialization is… different. On one hand, it becomes a bit simpler. On the other hand, it becomes a a bit more unusual.
There are three ways of handling an Observable in SwiftUI:
@State
- used to create and hold an instance, or the source of truth@Bindable
- used to reference an existing instance and properly bind to it (in other words, allow for children to receive ancestor updates as well as parents to receive descendent updates)No Wrapper - SwiftUI can reference an existing instance of an Observable and will automatically track changes to its properties (safely allows for children to receive ancestor updates only)
It’s important to note these uses as it strays from Combine’s definitions and terms. Most noticeably are the terms bind and track.
We’ll explore these three and their initializations. First, let’s migrate our model:
@Observable
class MyModel {
var myText = "This is myText"
var yourText = "This is yourText"
}
Observable as State
Starting with @State
, we can start off like this:
struct SomeVeryTextfulView: View {
@State var myModel = MyModel()
...
}
Yes, with Observable, we can simply use State. Yay! Now, remember, State is ideally for the View to “create and hold an instance”. But technically, we could set through a custom initializer like so:
init(myModel: MyModel) {
self.myModel = myModel
}
This works! We’re done here!
Right?
Right?!
Well, turns out, it’s not. No, it’s not because of fancy closures or anything like that. You can try what we did above (in fact, Xcode’s autocomplete sets it up for you that way). However, the catch is this: Apple discourages setting State with anything beyond the scope of a default value (let alone an already initialized object), as any perceived extra work could have unintended consequences.
In fact, their solution is you should consider using the task modifier. Yes, they suggest skipping the init to initialize your Observable State. Crazy, but here it is:
struct SomeVeryTextfulView: View {
@State var myModel: MyModel?
var body: some View {
Text(myModel!.myText)
.task {
myModel = MyModel()
}
}
}
Let’s pull back for a moment and put this in terms of the View lifecycle: at this point the order is initialization (init), task, and appearance (onAppear). Therefore, task is triggered before the View appears. But, it’s asynchronous, which means it’s triggered, but not guaranteed to be done by the time the View appears. This means the above sample could crash if myModel doesn’t get set in time. You’ll need to check if it’s nil and, if it is, display something else in the meantime (if even for a millisecond).
There are pros and cons to this, for sure. This could be acceptable in some scenarios, but the idea of being forced to rely on a task feels uncomfortable for others (some have gone so far as to wrap Observable in an ObservableObject to be able to go back to using StateObject like here and here).
All in all, you can try setting through the initializer, but if you begin to see wonkiness, you may need to consider your options.
Observable as Bindable or Non-wrapped Trackable
As noted earlier, there are two paths when working with already initialized Observables: bind and track. We can bind with an Observable by using the property wrapper @Bindable
and track by simply not placing any property wrapper.
struct SomeVeryTextfulView: View {
@Bindable var myBindedModel: MyModel
let myTrackedModel: MyModel
...
}
From all of my research and experimenting, I’m happy to say that these do have straightforward approaches in the initializer:
init(myBindedModel: MyModel, myTrackedModel: MyModel){
self.myBindedModel = myBindedModel
self.myTrackedModel = myTrackedModel
}
That’s a Wrap
Phew. If you made it to this point, congratulations. It is crazy to think something like initializers would be such a complex topic for something like SwiftUI. But as I mentioned earlier, this stage of the lifecycle often gets taken for granted until we have a complex need.
What I can say is that SwiftUI does give preference to default values and then Memberwise Initializers. My recommendation is, if you’re able to stick to these, use them. If not, then I hope this post has offered guidance to helping you build a solid SwiftUI experience.