6

I'm trying to optimize my SwiftUI app. I have a strange behavior with a ViewModel stored as a @StateObject in its View. To understand the issue, I made a small project that reproduces it.

ContentView contains a button to open ChildView in a sheet. ChildView is stored as property as I don't want to recreate it every time the sheet is open by user (this works):

struct ContentView: View {
   
    @State private var displayingChildView = false
    private let childView = ChildView()
 
    var body: some View {
        Button(action: {
            displayingChildView.toggle()
        }, label: {
            Text("Display child view")
        })
        
        .sheet(isPresented: $displayingChildView, content: {
            childView // instead of: ChildView()
        })
    }
}

ChildView code:

struct ChildView: View {
    
    @StateObject private var viewModel = ViewModel()
    
    init() {
        print("init() of ChildView")
    }
    
    var body: some View {
        VStack {
            Button(action: {
                viewModel.add()
            }, label: {
                Text("Add 1 to count")
            })
            
            Text("Count: \(viewModel.count)")
        }
    }
}

And its ViewModel:

class ViewModel: ObservableObject {
    @Published private(set) var count = 0
    
    init() {
        print("init() of ViewModel")
    }
    
    func add() {
        count += 1
    }
}

Here is the issue:

The ViewModel's init is called every time user opens the sheet. Why?

As ViewModel is a @StateObject in ChildView and ChildView is only init once, I am expecting that ViewModel is also only init once.

I have read this article that says that :

Observed objects marked with the @StateObject property wrapper don’t get destroyed and re-instantiated at times their containing view struct redraws.

Or here:

Use @StateObject once for each observable object you use, in whichever part of your code is responsible for creating it.

So I understand that ViewModel should stay alive, especially as ChildView is not destroyed.

And what confuses me the most is that if I replace @StateObject with @ObservedObject it works as expected. But it is not recommended to store an @ObservedObject inside a View.

Can anyone explain why this behavior and how to fix it as expected (ViewModel init should be called once) ?

A possible solution:

I've found a possible solution to fix this behavior:

a. Move the declaration of ViewModel into ContentView:

@StateObject private var viewModel = ViewModel()

b. Change the declaration of ViewModel in ChildView to be an EnvironmentObject:

@EnvironmentObject private var viewModel: ViewModel

c. And inject it in childView:

childView
   .environmentObject(viewModel)

That means it's ContentView that is responsible to keep the ChildView's ViewModel alive. It works, but I find this solution quite ugly:

  • All future child Views of ChildView could get access to ViewModel through environment objects. But it's no sense as it should be only useful for its View.
  • I would prefer declare a ViewModel inside its View instead of inside its parent View.

And this solution still doesn't explain above questions about @StateObject that should stay alive...

1
  • Don't use view model objects, we don't need them, that is what the View struct and @State is for. Commented Dec 13, 2022 at 17:55

2 Answers 2

2

SwiftUI initializes the @State variables when a view is inserted into the view hierarchy. This is why your attempt to keep the state of the child view alive by assigning it to a var fails. Every time your sheet is presented, the child view is added to the view hierarchy and its state variables are initialized.

The correct way to do this is to pass the viewModel to the child view.

struct ContentView: View {
    @StateObject private var viewModel = ViewModel()
    @State private var displayingChildView = false
    
    var body: some View {
        Button(action: {
            displayingChildView.toggle()
        }, label: {
            Text("Display child view")
        })
        
        .sheet(isPresented: $displayingChildView, content: {
            ChildView(viewModel: viewModel)
        })
    }
}

struct ChildView: View {
    @ObservedObject var viewModel: ViewModel
    
    var body: some View {
        VStack {
            Button(action: {
                viewModel.add()
            }, label: {
                Text("Add 1 to count")
            })
            
            Text("Count: \(viewModel.count)")
        }
    }
}
Sign up to request clarification or add additional context in comments.

6 Comments

Thank you for your answer. Does that mean all ViewModels have to be stored (as @StateObject) by the parent of View they are the ViewModel? (Don't know if my question is understandable ^^).
And with your solution, the init of ChildView is called everytime count var is edited. It is not really optimized, no?
Think in terms of life cycle. The viewModel should be created and held in a permanent place. In this case, ContentView isn't going anywhere, so it works. Even better, pass it to ContentView. As far as ChildView() being created every time it is presented, that is not a concern. SwiftUI creates views all the time. It's very efficient and for SwiftUI to manage. Since your ChildView is no longer storing state, let it be recreated.
Ok, I got it. Thanks! And another question: if ViewModel doesn't need to store data when ChildView is closed and reopened, do I still need to create it in ContentView? Or is there another way to store it in ChildView directly (as a @ObservedObject for example)?
OK. So as a summary: that means the initial state I have explained in my question was the case where ViewModel is recreated each time ChildView appears and it is the right way to do it. On the other hand, if I want that ViewModel is not recreated each time, I have to store it in parent View and inject it to the child.
|
-2

In SwiftUI ObservableObject is for model data, for view data it's best to use the built-in @State and @Binding, e.g.

struct Counter {
    private(set) var count = 0
    
    init() {
        print("init() of Counter")
    }
    
    mutating func add() {
        count += 1
    }
}

struct ChildView: View {
    
    @State private var counter = Counter()
    
    init() {
        print("init() of ChildView")
    }
    
    var body: some View {
        VStack {
            Button(action: {
                counter.add()
            }, label: {
                Text("Add 1 to count")
            })
            
            Text("Count: \(counter.count)")
        }
    }
}

You can learn about the distinction between view data state and model data objects in Data Essentials in SwiftUI WWDC 2020 from 9:24

My name is Luca. My colleague, Curt, has just described how to use State and Binding to drive changes in your UI, and how these tools are a great way to quickly iterate on your view code.

But State is designed for transient UI state that is local to a view. In this section, I want to move your attention to designing your model and explain all the tools that SwiftUI provides to you.

Typically, in your app, you store and process data by using a data model that is separate from its UI.

This is when you reach a critical point where you need to manage the life cycle of your data, including persisting and syncing it, handle side-effects, and, more generally, integrate it with existing components. This is when you should use ObservableObject. First, let's take a look at how ObservableObject is defined.

8 Comments

Mmh ok. Do you have any articles that explain a bit more why we should not use objects anymore in SwiftUI, I am very interested? Anyway, your solution does not work as the count variable always go back to 0 each time I open the sheet. Event if its init is called once and I store ChildView as let property of ContentView. Strange.
I recommend Apples tutorials and wwdc videos. And I thought you wanted the sheet to have its own state. If not then move it up and pass binding down for write access or let for read only.
As you might think, my question's example was a simplified case. How do you do when your Counter struct is a lot more complex, as a ViewModel could be (lot of @Published properties and so on) ? They are all mutating and redraw the View each time?
SwiftUI doesn't draw anything it just inits View structs and calls body if the params are different from last time. This hierarchy is diffed from last time and the result is used to update UIView objects which then eventually draw as normal. You can improve performance of the diffing with small View structs.
I don't totally agree with that as some of my ViewModels are sometimes quite complex and have lot of code logics. Separate them in many structs seems complicated. But I will have a try and find some article about that approach.
|

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.