Initialize @StateObject with a parameter in SwiftUI
Short Answer
The StateObject
has the next init: init(wrappedValue thunk: @autoclosure @escaping () -> ObjectType)
. This means that the StateObject
will create an instance of the object at the right time - before running body
for the first time. But it doesn't mean that you must declare that instance in one line in a View like @StateObject var viewModel = ContentViewModel()
.
The solution I found is to pass a closure as well and allow StateObject
to create an instance on an object. This solution works well. For more details read the Long Answer below.
class ContentViewModel: ObservableObject {}
struct ContentView: View {
@StateObject private var viewModel: ContentViewModel
init(viewModel: @autoclosure @escaping () -> ContentViewModel) {
_viewModel = StateObject(wrappedValue: viewModel())
}
}
struct RootView: View {
var body: some View {
ContentView(viewModel: ContentViewModel())
}
}
No matter how many times RootView
will create its body
, the instance of ContentViewModel
will be only one.
In this way, you are able to initialize @StateObject
view model which has a parameter.
Long Answer
@StateObject
The @StateObject
creates an instance of value just before running body
for the first time (Data Essentials in SwiftUI). And it keeps this one instance of the value during all view lifetime. You can create an instance of a view somewhere outside of a body
and you will see that init
of ContentViewModel
will not be called. See onAppear
in the example below:
struct ContentView: View {
@StateObject private var viewModel = ContentViewModel()
}
struct RootView: View {
var body: some View {
VStack(spacing: 20) {
//...
}
.onAppear {
// Instances of ContentViewModel will not be initialized
_ = ContentView()
_ = ContentView()
_ = ContentView()
// The next line of code
// will create an instance of ContentViewModel.
// Buy don't call body on your own in projects :)
_ = ContentView().view
}
}
}
Therefore it's important to delegate creating an instance to StateObject
.
Why should not use StateObject(wrappedValue:) with instance
Let's consider an example when we create an instance of StateObject
with _viewModel = StateObject(wrappedValue: viewModel)
by passing a viewModel
instance. When the root view will trigger an additional call of the body
, then the new instance on viewModel
will be created. If your view is an entire screen view, that will probably work fine. Despite this fact better not to use this solution. Because you're never sure when and how the parent view redrawing its children.
final class ContentViewModel: ObservableObject {
@Published var text = "Hello @StateObject"
init() { print("ViewModel init") }
deinit { print("ViewModel deinit") }
}
struct ContentView: View {
@StateObject private var viewModel: ContentViewModel
init(viewModel: ContentViewModel) {
_viewModel = StateObject(wrappedValue: viewModel)
print("ContentView init")
}
var body: some View { Text(viewModel.text) }
}
struct RootView: View {
@State var isOn = false
var body: some View {
VStack(spacing: 20) {
ContentView(viewModel: ContentViewModel())
// This code is change the hierarchy of the root view.
// Therefore all child views are created completely,
// including 'ContentView'
if isOn { Text("is on") }
Button("Trigger") { isOn.toggle() }
}
}
}
I tapped "Trigger" button 3 times and this is the output in the Xcode console:
ViewModel init ContentView init ViewModel init ContentView init ViewModel init ContentView init ViewModel deinit ViewModel init ContentView init ViewModel deinit
As you can see, the instance of the ContentViewModel
was created many times. That's because when a root view hierarchy is changed then everything in its body
is created from scratch, including ContentViewModel
. No matter that you set it to @StateObject
in the child view. The matter that you call init
in the root view the same amount of times as how the root view made an update of the body
.
Using closure
As far as the StateObject
use closure in the init - init(wrappedValue thunk: @autoclosure @escaping () -> ObjectType)
we can use this and pass the closure as well. Code exactly the same with previous section (ContentViewModel
and RootView
) but the only difference is using closure as init parameter to the ContentView
:
struct ContentView: View {
@StateObject private var viewModel: ContentViewModel
init(viewModel: @autoclosure @escaping () -> ContentViewModel) {
_viewModel = StateObject(wrappedValue: viewModel())
print("ContentView init")
}
var body: some View { Text(viewModel.text) }
}
After "Trigger" button was tapped 3 times - the output is next:
ContentView init ViewModel init ContentView init ContentView init ContentView init
You can see that only one instance of ContentViewModel
has been created. Also the ContentViewModel
was created after ContentView
.
Btw, the easiest way to do the same is to have the property as internal/public and remove init:
struct ContentView: View {
@StateObject var viewModel: ContentViewModel
}
The result is the same. But the viewModel
can not be private property in this case.
Here is a demo of solution. Tested with Xcode 12b.
class MyObject: ObservableObject {
@Published var id: Int
init(id: Int) {
self.id = id
}
}
struct MyView: View {
@StateObject private var object: MyObject
init(id: Int = 1) {
_object = StateObject(wrappedValue: MyObject(id: id))
}
var body: some View {
Text("Test: \(object.id)")
}
}
The answer given by @Asperi should be avoided Apple says so in their documentation for StateObject.
You don’t call this initializer directly. Instead, declare a property with the @StateObject attribute in a View, App, or Scene, and provide an initial value.
Apple tries to optimize a lot under the hood, don't fight the system.
Just create an ObservableObject
with a Published
value for the parameter you wanted to use in the first place. Then use the .onAppear()
to set it's value and SwiftUI will do the rest.
Code:
class SampleObject: ObservableObject {
@Published var id: Int = 0
}
struct MainView: View {
@StateObject private var sampleObject = SampleObject()
var body: some View {
Text("Identifier: \(sampleObject.id)")
.onAppear() {
sampleObject.id = 9000
}
}
}