148

I would like to know if there is currently (at the time of asking, the first Xcode 12.0 Beta) a way to initialize a @StateObject with a parameter coming from an initializer.

To be more specific, this snippet of code works fine:

struct MyView: View {
  @StateObject var myObject = MyObject(id: 1)
}

But this does not:

struct MyView: View {
  @StateObject var myObject: MyObject

  init(id: Int) {
    self.myObject = MyObject(id: id)
  }
}

From what I understand the role of @StateObject is to make the view the owner of the object. The current workaround I use is to pass the already initialized MyObject instance like this:

struct MyView: View {
  @ObservedObject var myObject: MyObject

  init(myObject: MyObject) {
    self.myObject = myObject
  }
}

But now, as far as I understand, the view that created the object owns it, while this view does not.

Thanks.

Emilio Schepis
  • 1,677
  • 2
  • 9
  • 11

12 Answers12

138

Here is a demo of solution. Tested with Xcode 12+.

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)")
    }
}

From Apple (for all those like @user832):

confirmation

Asperi
  • 228,894
  • 20
  • 464
  • 690
  • Thank you very much. This works well. It, however, feels like a workaround. Do you think there is a reason why a standard initialization does not work? Am I not supposed to do it or is it just a limitation of this property wrapper? – Emilio Schepis Jun 29 '20 at 12:00
  • 1
    This is originally a peculiarity of State property wrapper. StateObject is just the same. – Asperi Jun 29 '20 at 12:06
  • 37
    In the documentation for @StateObject it says "You don’t call this initializer directly". – user832 Aug 20 '20 at 05:49
  • 4
    init() is called every time the View is rebuilt, yes. However State and StateObject will only initialise their objects the first time. – Brett Jan 28 '21 at 05:30
  • 1
    Yes, it is contradictory post. For someone it works for other it does not, 2-to-1 by votes... so if you look for *generic* copy-paste solution - this is not that case - try mindfully. – Asperi Feb 01 '21 at 12:35
  • btw, isn't it enough to use the autogenerated constructor if you *do not* make the `@StateObject` private: `@StateObject var object: MyObject` – Jan Feb 12 '21 at 14:21
  • 21
    This came up during the SwiftUI digital lounge of WWDC21 and was endorsed by the SwiftUI team in that context. SwiftUI-Lab.com's amazing summary of the digital lounge recorded this here: https://www.swiftui-lab.com/random-lessons#data-10 Of course it depends on you and your project whether this kind of soft endorsement makes you sufficiently comfortable in following this approach. – Alex Fringes Jul 28 '21 at 22:28
  • 19
    Huge caveat to not miss here: wrappedValue is a closure! If you do something like create your view model on one line, then pass it into wrappedValue on another, you'll silently break StateObject and get a new instance of your view model every update. That definitely caught me off guard – Selali Adobor Jan 30 '22 at 23:30
  • It will not work properly, on next view update object will not update – Maxime Ashurov Jun 29 '22 at 15:55
  • 1
    `wrappedValue` is marked `@autoclosure` so an object isn't init every time (as long as its done inside the param and not before. – malhal Oct 28 '22 at 14:38
54

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
            }
    }
}
Mark
  • 16,906
  • 20
  • 84
  • 117
  • 37
    What if SampleObject requires a parameter on initialisation? Also .onAppear is a bit of a mess in SwiftUI 2.0, it can get called multiple times. – Brett Oct 09 '20 at 07:37
  • 6
    I need to find where I read this, but I think @Asperi's answer is correct, as the views init may get called multiple times, but the StateObject doesn't get overwritten, it only gets set the first time. – Brett Nov 06 '20 at 01:26
  • @Brett, did you ever find documentation that Asperi's answer is supported? – user3847320 Jan 13 '21 at 19:57
  • @user3847320 To be honest I haven't looked again. But I am confident he is correct as this is also how a State var can be used and initialised. – Brett Jan 28 '21 at 05:21
  • 3
    @Brett Thanks. I have tried it and do see the correct behavior as well. The view is recreated, however the StateObject is not overwritten. I just hope that will not change in a future version of SwiftUI. – user3847320 Jan 29 '21 at 15:35
  • 6
    The `onAppear` solution works for a simple use case but doesn't if the `SampleObject` is more complex and needs parameters (such as injected services) in its .init. @Brett please share the Apple docs if using the custom init is the correct way. That would solve a lot of workarounds – Jan Feb 12 '21 at 14:03
  • This is not the page where I initially read about the behaviour of StateObject, however it is an Apple doc and it explicitly says: "SwiftUI creates a new instance of the object only once for each instance of the structure that declares the object" https://developer.apple.com/documentation/swiftui/stateobject – Brett Feb 13 '21 at 03:55
  • 11
    This question was asked in the WWDC21 lounges and the answer seems to contradict the warning the docs: "[the object passed to StateObject.init(wrappedValue:)] will be created only at the beginning of the view lifetime and kept alive. The StateObject ’s wrapped value is an autoclosure that is invoked only once at the beginning of the view lifetime. That also means that SwiftUI will capture the value of the plan when is firstly created; [...] if you view identity doesn’t change but you pass a different [object] SwiftUI will not notice that." – halleygen Aug 19 '21 at 00:24
  • The other alternate is if you can have the model as a `Struct` then you can simply use `State` and initialise it with `State(initialValue:)`, could be handy when you want a binding (like `Picker`) and could just `Int` (Int is a struct too) – user1046037 Nov 13 '21 at 02:24
  • 3
    If you are using the approach above then how would you inject a Core Data viewContext into the view model. – Mary Doe Nov 25 '21 at 00:38
46

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.

Andrew Bogaevskyi
  • 2,251
  • 21
  • 25
  • Even thought this solution works, it create a problem when you are swiping back because it reinstantiate the view, thus the view model. The problem was solved when I replaced NavigationView with NavigationStack. BUT NavigationStack is only available in iOS16.0 – Christos Chadjikyriacou Oct 05 '22 at 10:39
  • What if my root view is ``@main`` as entry point? it conforms to ``app`` therefore I dont have the ``body`` to apply the init – David.C Apr 04 '23 at 22:07
  • @David.C for the app root probably just `@StateObject var viewModel = AppRootViewModel()` without any `init`s and closures. – Andrew Bogaevskyi Apr 05 '23 at 12:35
6

I guess I found a workaround for being able to control the instantiation of a view model wrapped with @StateObject. If you don't make the view model private on the view you can use the synthesized memberwise init, and there you'll be able to control the instantiation of it without problem. In case you need a public way to instantiate your view, you can create a factory method that receives your view model dependencies and uses the internal synthesized init.

import SwiftUI

class MyViewModel: ObservableObject {
    @Published var message: String

    init(message: String) {
        self.message = message
    }
}

struct MyView: View {
    @StateObject var viewModel: MyViewModel

    var body: some View {
        Text(viewModel.message)
    }
}

public func myViewFactory(message: String) -> some View {
    MyView(viewModel: .init(message: message))
}
cicerocamargo
  • 1,046
  • 9
  • 9
4

Like @Mark pointed out, you should not handle @StateObject anywhere during initialization. That is because the @StateObject gets initialized after the View.init() and slightly before/after the body gets called.

I tried a lot of different approaches on how to pass data from one view to another and came up with a solution that fits for simple and complex views / view models.

Version

Apple Swift version 5.3.1 (swiftlang-1200.0.41 clang-1200.0.32.8)

This solution works with iOS 14.0 upwards, because you need the .onChange() view modifier. The example is written in Swift Playgrounds. If you need an onChange like modifier for lower versions, you should write your own modifier.

Main View

The main view has a @StateObject viewModel handling all of the views logic, like the button tap and the "data" (testingID: String) -> Check the ViewModel

struct TestMainView: View {
    
    @StateObject var viewModel: ViewModel = .init()
    
    var body: some View {
        VStack {
            Button(action: { self.viewModel.didTapButton() }) {
                Text("TAP")
            }
            Spacer()
            SubView(text: $viewModel.testingID)
        }.frame(width: 300, height: 400)
    }
    
}

Main View Model (ViewModel)

The viewModel publishes a testID: String?. This testID can be any kind of object (e.g. configuration object a.s.o, you name it), for this example it is just a string also needed in the sub view.

final class ViewModel: ObservableObject {
    
    @Published var testingID: String?
    
    func didTapButton() {
        self.testingID = UUID().uuidString
    }
    
}

So by tapping the button, our ViewModel will update the testID. We also want this testID in our SubView and if it changes, we also want our SubView to recognize and handle these changes. Through the ViewModel @Published var testingID we are able to publish changes to our view. Now let's take a look at our SubView and SubViewModel.

SubView

So the SubView has its own @StateObject to handle its own logic. It is completely separated from other views and ViewModels. In this example the SubView only presents the testID from its MainView. But remember, it can be any kind of object like presets and configurations for a database request.

struct SubView: View {
    
    @StateObject var viewModel: SubviewModel = .init()
    
    @Binding var test: String?
    init(text: Binding<String?>) {
        self._test = text
    }
    
    var body: some View {
        Text(self.viewModel.subViewText ?? "no text")
            .onChange(of: self.test) { (text) in
                self.viewModel.updateText(text: text)
            }
            .onAppear(perform: { self.viewModel.updateText(text: test) })
    }
}

To "connect" our testingID published by our MainViewModel we initialize our SubView with a @Binding. So now we have the same testingID in our SubView. But we don't want to use it in the view directly, instead we need to pass the data into our SubViewModel, remember our SubViewModel is a @StateObject to handle all the logic. And we can't pass the value into our @StateObject during view initialization, like I wrote in the beginning. Also if the data (testingID: String) changes in our MainViewModel, our SubViewModel should recognize and handle these changes.

Therefore we are using two ViewModifiers.

onChange

.onChange(of: self.test) { (text) in
                self.viewModel.updateText(text: text)
            }

The onChange modifier subscribes to changes in our @Binding property. So if it changes, these changes get passed to our SubViewModel. Note that your property needs to be Equatable. If you pass a more complex object, like a Struct, make sure to implement this protocol in your Struct.

onAppear

We need onAppear to handle the "first initial data" because onChange doesn't fire the first time your view gets initialized. It is only for changes.

.onAppear(perform: { self.viewModel.updateText(text: test) })

Ok and here is the SubViewModel, nothing more to explain to this one I guess.

class SubviewModel: ObservableObject {
    
    @Published var subViewText: String?
    
    func updateText(text: String?) {
        self.subViewText = text
    }
}

Now your data is in sync between your MainViewModel and SubViewModel and this approach works for large views with many subviews and subviews of these subviews and so on. It also keeps your views and corresponding viewModels enclosed with high reusability.

Working Example

Playground on GitHub: https://github.com/luca251117/PassingDataBetweenViewModels

Additional Notes

Why I use onAppear and onChange instead of only onReceive: It appears that replacing these two modifiers with onReceive leads to a continuous data stream firing the SubViewModel updateText multiple times. If you need to stream data for presentation, it could be fine but if you want to handle network calls for example, this can lead to problems. That's why I prefer the "two modifier approach".

Personal Note: Please don't modify the stateObject outside the corresponding view's scope. Even if it is somehow possible, it is not what its meant for.

Luca
  • 221
  • 2
  • 11
3

@cicerocamargo's answer is a great suggestion. I had the same struggle in my app trying to figure out how to inject dependencies in my @StateObject view model and came up with the same answer after much testing. This way the view model will only be instantiated exactly once in all situations.

class MyViewModel: ObservableObject {
   @Published var dependency: Any

   init(injectedDependency: Any) {
       self.dependency = injectedDependency
   }
}

struct  MyView: View {
    @StateObject var viewModel: MyViewModel
    
    var body: some View {
       // ...
    } 
}

struct MyCallingView: View {
    var body: some View {
        NavigationLink("Go to MyView",
            destination: MyView(viewModel: MyViewModel(injectedDependency: dependencyValue)))
    }
}

The only thing to keep in mind with this, is that the view model's instantiation should be inline with the view's instantiation. If we change our calling view code to this:

struct MyCallingView: View {
    var body: some View {
        let viewModel = MyViewModel(injectedDependency: dependencyValue)
        NavigationLink("Go to MyView",
            destination: MyView(viewModel: viewModel))
    }
}

then the compiler won't be able to optimize this code and the viewModel will be instantiated every time MyCallingView gets invalidated and needs to redraw. On the upside, even if it is instantiated every time, only the original instance will be used.

mike
  • 31
  • 2
  • 1
    This breaks down if MyView needs a custom init method. – Morgz Nov 17 '21 at 11:01
  • 1
    @Morgz - Well, not quite. First, does the view really need a custom initializer? Most of the times it's actually the ViewModel that needs the custom initializer, and whatever you are thinking to inject inside the View should most likely be injected to the ViewModel. If something really needs injection directly into the View, use the wrapping trick (this will preserve functionality, only the initial ViewModel instance will be used, but more than one ViewModel instance will indeed be created) ``` init(injected: Any, viewModel: MyViewModel) { _viewModel = StateObject(wrappedValue: viewModel)} ``` – mike Nov 18 '21 at 13:29
1

I don't really have a good solution for @StateObjects at the moment, but I was trying to use them in the @main App as the initialisation point for @EnvironmentObjects. My solution was not to use them. I am putting this answer here for people who are trying to do the same thing as me.

I struggled with this for quite a while before coming up with the following:

These two let declarations are at the file level

private let keychainManager = KeychainManager(service: "com.serious.Auth0Playground")
private let authenticatedUser = AuthenticatedUser(keychainManager: keychainManager)

@main
struct Auth0PlaygroundApp: App {

    var body: some Scene {
    
        WindowGroup {
            ContentView()
                .environmentObject(authenticatedUser)
        }
    }
}

This is the only way I have found to initialise an environmentObject with a parameter. I cannot create an authenticatedUser object without a keychainManager and I am not about to change the architecture of my whole App to make all my injected objects not take a parameter.

Brett
  • 1,647
  • 16
  • 34
1

Really good answers.

Now, I found that in some cases, getting @StateObject right can be tricky, like handling network requests needed to retrieve information lazily, as the user navigates the UI.

Here's a pattern I like to use, especially when a screen (or hierarchy of screens) should present data lazily due to its associated retrieval cost.

It goes like this:

  • the main screen holds the model(s) for the child screen(s).
  • each model keeps track of its display state and whether it has already loaded the info. This helps avoid repeating costly ops, like network calls.
  • the child screen relies on the model and checks the display state to show a loading view or present the final information/error.

Here's the screen breakdown:

enter image description here

In a hurry? Here's the project:

https://github.com/tciuro/StateObjectDemo

Main screen (ContentView):

import SwiftUI

struct ContentView: View {
    @StateObject private var aboutModel = AboutModel()
    
    var body: some View {
        NavigationView {
            List {
                Section {
                    NavigationLink(destination: AboutView(aboutModel: aboutModel)) {
                        Text("About...")
                    }
                } footer: {
                    Text("The 'About' info should be loaded once, no matter how many times it's visited.")
                }
                
                Section  {
                    Button {
                        aboutModel.displayMode = .loading
                    } label: {
                        Text("Reset Model")
                    }
                } footer: {
                    Text("Reset the model as if it had never been loaded before.")
                }
            }
            .listStyle(InsetGroupedListStyle())
        }
    }
}

Supporting datatypes:

enum ViewDisplayState {
    case loading
    case readyToLoad
    case error
}

enum MyError: Error, CustomStringConvertible {
    case loadingError
    
    var description: String {
        switch self {
            case .loadingError:
                return "about info failed to load... don't ask."
        }
    }
}

About Screen (AboutView):

import SwiftUI

struct AboutView: View {
    @ObservedObject var aboutModel: AboutModel
    
    var body: some View {
        Group {
            switch aboutModel.displayMode {
                case .loading:
                    VStack {
                        Text("Loading about info...")
                    }
                case .readyToLoad:
                    Text("About: \(aboutModel.info ?? "<about info should not be nil!>")")
                case .error:
                    Text("Error: \(aboutModel.error?.description ?? "<error hould not be nil!>")")
            }
        }
        .onAppear() {
            aboutModel.loadAboutInfo()
        }
    }
}

The AboutView model:

import SwiftUI

final class AboutModel: ObservableObject {
    private(set) var info: String?
    private(set) var error: MyError?
    
    @Published var displayMode: ViewDisplayState = .loading
    
    func loadAboutInfo() {
        /**
        If we have loaded the about info already, we're set.
        */
        
        if displayMode == .readyToLoad {
            return
        }
        
        /**
        Load the info (e.g. network call)
        */
        
        loadAbout() { result in
            /**
            Make sure we assign the 'displayMode' in the main queue
            (otherwise you'll see an Xcode warning about this.)
            */
            
            DispatchQueue.main.async {
                switch result {
                    case let .success(someAboutInfo):
                        self.info = someAboutInfo
                        self.displayMode = .readyToLoad
                    case let .failure(someError):
                        self.info = nil
                        self.error = someError
                        self.displayMode = .error
                }
            }
        }
    }
    
    /**
    Dummy function; for illustration purposes. It's just a placeholder function
    that demonstrates what the real app would do.
    */
    
    private func loadAbout(completion: @escaping (Result<String, MyError>) -> Void) {
        /**
        Gather the info somehow and return it.
        Wait a couple secs to make it feel a bit more 'real'...
        */
        
        DispatchQueue.global().asyncAfter(deadline: .now() + 2) {
            if Bool.random() {
                completion(.success("the info is ready"))
            } else {
                completion(.failure(MyError.loadingError))
            }
        }
    }
}

In short, I found that for this lazy loading pattern, placing the @StateObject in the main screen instead of the child screen avoids potentially unnecessary code re-executions.

In addition, using ViewDisplayState allows me to control whether a loading view should be shown or not, solving the common UI flickering issue that occurs when the data is already cached locally making the UI loading view not worth presenting.

Of course, this is not a silver bullet. But depending on your workflow it might be useful.

If you want to see this project in action and tinkle with it, feel free to download it here. Cheers!

titusmagnus
  • 2,014
  • 3
  • 23
  • 23
1

Based on Andrew Bogaevskyi answer, I created an extension to StateObject with a init that emulates a closure and avoids creating a new instance of StateObject every time.

extension StateObject {
    @inlinable init(_ value: @escaping () -> ObjectType) where ObjectType : ObservableObject {
        self.init(wrappedValue: value())
    }
}

Here is the testing code

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 = StateObject {
            ContentViewModel()
        }
        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()
            
            // 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() }
        }
    }
}

extension StateObject {
    @inlinable init(_ value: @escaping () -> ObjectType) where ObjectType : ObservableObject {
        self.init(wrappedValue: value())
    }
}

And the output

ContentView init
ViewModel init
ContentView init
ContentView init
ContentView init
ContentView init
ContentView init
Bruno Coelho
  • 926
  • 13
  • 25
0

I frequently visit this page whenever my views don't behave as they should. What I'm realising is I need to adapt my thinking from UIKit where I'd liberally use ViewModels to encapsulate a views state. I had much more confidence over the initialisation and teardown of these objects alongside my views. Using StateObject for a ViewModel with injected state is a bit of a black box and confusing. I think the answers on this post attest to this.

What I'm working towards now is a model proposed here https://nalexn.github.io/clean-architecture-swiftui/

I'll still use StateObject for strictly view properties but whenever I find myself wanting to inject state into the object I will consider it a possible code smell. For example a UserView having a UserViewModel(id: 1). I've tried both the injected viewModel and the init in the view with the _state = ... approaches and while they may work at first I've encountered bugs down the line.

The clean architecture linked above promotes a separate AppState which will pass its data onto views via bindings. Seems a bit Manager/Singleton ish but at least the management of my state is more obvious.

Morgz
  • 574
  • 6
  • 15
0

Asperi's answer is great, but it seems not very perfect because of what document's said. I discovered following method, but I don't know if it is valid.

class Object: ObservableObject {
    let id: String
    init(id: String) {
        self.id = id
    }
}

struct ParentView: View {
    @State var obj: Object?
    var body: some View {
        if let obj = obj {
            ChildView().environmentObject(obj)
        } else {
            Button("Tap") {
                self.obj = Object(id: "id")
            }
        }
    }
}

struct ChildView: View {
    @EnvironmentObject var customObject: Object
    var body: some View {
        Text(customObject.id)
    }
}
Dan Lee
  • 101
  • 1
  • 5
0

My solution is the following.

I put the id to trigger the refresh of the state in the parent that injects the ViewModel to the child view like that.

Why the id? because if I don't put it it does not trigger the refresh of the content of the view model.

if let vm = globalState.selected {
    ChildView(state: ChildViewModel(vm: vm))
       .id(vm.id)
}

struct ChildView: View {
  @StateObject var vm: ChildViewModel
  ...
}

I don't know the implications of that but it's working in my case because I require the behaviour that I descrived I hope to have helped

Fiser
  • 25
  • 8
  • I'd recommend against this approach. What this does is change the identifier of the view, thus if the view had any animations and such, like changing the size, the SwiftUI system wouldn't see it as a change to the existing view, but that view being replaced by an entirely different view. Just some food for thought. – Mark A. Donohoe May 07 '23 at 08:11