SwiftUI MVVM: child view model re-initialized when parent view updated - ios

I'm attempting to use MVVM in a SwiftUI app, however it appears that view models for child views (e.g. ones in a NavigationLink) are re-initialized whenever an ObservableObject that's observed by both the parent and child is updated. This causes the child's local state to be reset, network data to be reloaded, etc.
I'm guessing it's because this causes parent's body to be reevaluated, which contains a constructor to SubView's view model, but I haven't been able to find an alternative that lets me create view models that don't live beyond the life of the view. I need to be able to pass data to the child view model from the parent.
Here's a very simplified playground of what we're trying to accomplish, where incrementing EnvCounter.counter resets SubView.counter.
import SwiftUI
import PlaygroundSupport
class EnvCounter: ObservableObject {
#Published var counter = 0
}
struct ContentView: View {
#ObservedObject var envCounter = EnvCounter()
var body: some View {
VStack {
Text("Parent view")
Button(action: { self.envCounter.counter += 1 }) {
Text("EnvCounter is at \(self.envCounter.counter)")
}
.padding(.bottom, 40)
SubView(viewModel: .init())
}
.environmentObject(envCounter)
}
}
struct SubView: View {
class ViewModel: ObservableObject {
#Published var counter = 0
}
#EnvironmentObject var envCounter: EnvCounter
#ObservedObject var viewModel: ViewModel
var body: some View {
VStack {
Text("Sub view")
Button(action: { self.viewModel.counter += 1 }) {
Text("SubView counter is at \(self.viewModel.counter)")
}
Button(action: { self.envCounter.counter += 1 }) {
Text("EnvCounter is at \(self.envCounter.counter)")
}
}
}
}
PlaygroundPage.current.setLiveView(ContentView())

A new property wrapper is added to SwiftUI in Xcode 12, #StateObject. You should be able to fix it by simply changing #ObservedObject for #StateObject as follows.
struct SubView: View {
class ViewModel: ObservableObject {
#Published var counter = 0
}
#EnvironmentObject var envCounter: EnvCounter
#StateObject var viewModel: ViewModel // change on this line
var body: some View {
// ...
}
}

To solve this problem I created a custom helper class called ViewModelProvider.
The provider takes a hash for your view, and a method that builds the ViewModel. It then either returns the ViewModel, or builds it if its the first time that it received that hash.
As long as you make sure the hash stays the same as long as you want the same ViewModel, this solves the problem.
class ViewModelProvider {
private static var viewModelStore = [String:Any]()
static func makeViewModel<VM>(forHash hash: String, usingBuilder builder: () -> VM) -> VM {
if let vm = viewModelStore[hash] as? VM {
return vm
} else {
let vm = builder()
viewModelStore[hash] = vm
return vm
}
}
}
Then in your View, you can use the ViewModel:
Struct MyView: View {
#ObservedObject var viewModel: MyViewModel
public init(thisParameterDoesntChangeVM: String, thisParameterChangesVM: String) {
self.viewModel = ViewModelProvider.makeViewModel(forHash: thisParameterChangesVM) {
MOFOnboardingFlowViewModel(
pages: pages,
baseStyleConfig: style,
buttonConfig: buttonConfig,
onFinish: onFinish
)
}
}
}
In this example, there are two parameters. Only thisParameterChangesVM is used in the hash. This means that even if thisParameterDoesntChangeVM changes and the View is rebuilt, the view model stays the same.

I was having the same problem, your guesses are right, SwiftUI computes all your parent body every time its state changes. The solution is moving the child ViewModel init to the parent's ViewModel, this is the code from your example:
class EnvCounter: ObservableObject {
#Published var counter = 0
#Published var subViewViewModel = SubView.ViewModel.init()
}
struct CounterView: View {
#ObservedObject var envCounter = EnvCounter()
var body: some View {
VStack {
Text("Parent view")
Button(action: { self.envCounter.counter += 1 }) {
Text("EnvCounter is at \(self.envCounter.counter)")
}
.padding(.bottom, 40)
SubView(viewModel: envCounter.subViewViewModel)
}
.environmentObject(envCounter)
}
}

Related

How can I prevent a View model from being recreated on multiple views

DISCLAIMER: I'm a newbie in swift
I'm trying to set an MVVM app in such a way that multiple screens can access a single View Model but for some reason, everytime I navigate away from the home page, the ViewModel get re-created.
The ViewModel is set up this way:
extension ContentView {
//view model
class MyViewModel: ObservableObject {
let sdk: mySdk
#Published var allProducts = [ProductItem]()
#Published var itemsArray = [Item]() //This gets updated with content later on
...
init(sdk: mySdk) {
self.sdk = sdk
self.loadProds(forceReload: false)
...
func loadProds(forceReload: Bool){
sdk.getProducts(forceReload: forceReload) { products, error in
if let products = products {
self.allProducts = products
} else {
self.products = .error (error?.localizedDescription ?? "error")
print(error?.localizedDescription)
}
}
...
//itemsArray gets values appended to it as follows:
itemsArray.append(Item(productUid: key, quantity: Int32(value)))
}
}
}
}
The rest of the code is set up like:
struct ContentView: View { // Home Screen content
#ObservedObject var viewmodel: MyViewModel
var body: some View {
...
}
}
The SecondView that should get updated based on the state of the itemsArray is set up like so:
struct SecondView: View {
#ObservedObject var viewModel: ContentView.MyViewModel //I have also tried using #StateObject
init(sdk: mySdk) {
_viewModel = ObservedObject(wrappedValue: ContentView.MyViewModel(sdk: sdk))
}
var body: some View {
ScrollView {
LazyVStack {
Text("Items array count is \(viewModel.itemsArray.count)")
Text("All prods array count is \(viewModel.allProducts.count)")
if viewModel.itemsArray.isEmpty{
Text ("Items array is empty")
}
else {
Text ("Items array is not empty")
...
}
}
}
}
}
The Main View that holds the custom TabView and handles Navigation is set up like this:
struct MainView: View {
let sdk = mySdk(dbFactory: DbFactory())
#State private var selectedIndex = 0
let icons = [
"house",
"cart.fill",
"list.dash"
]
var body: some View{
VStack {
//Content
ZStack {
switch selectedIndex {
case 0:
NavigationView {
ContentView(viewmodel: .init(sdk: sdk))
.navigationBarTitle("Home")
}
case 1:
NavigationView {
SecondView(sdk: sdk)
.navigationBarTitle("Cart")
}
...
...
}
}
}
}
}
Everytime I navigate away from the ContentView screen, any updated content of the viewmodel gets reset. For example, on navigating the SecondView screen itemsArray.count shows 0 but allProducts Array shows the correct value as it was preloaded.
The entire content of ContentView gets recreated on navigating back as well.
I would love to have the data in the ViewModel persist on multiple views unless explicitly asked to refresh.
How can I go about doing that please? I can't seem to figure out where I'm doing something wrong.
Any help will be appreciated.
Your call to ContentView calls .init on your view model, so every time SwiftUI's rendering system needs to redraw itself, you'll get a new instance of the view model created. Similarly, the init() method on SecondView also calls the init method, in its ContentView.MyViewModel(sdk: sdk) form.
A better approach would be to create a single instance further up the hierarchy, and store it as a #StateObject so that SwiftUI knows to respond to changes to its published properties. Using #StateObject once also shows which view "owns" the object; that instance will stick around for as long as that view is in the hierarchy.
In your case, I'd create your view model in MainView – which probably means the view model definition shouldn't be namespaced within ContentView. Assuming you change the namespacing, you'd have something like
struct MainView: View {
#StateObject private var viewModel: ViewModel
init() {
let sdk = mySdk(dbFactory: DbFactory())
let viewModel = ViewModel(sdk: sdk)
_viewModel = StateObject(wrappedValue: viewModel)
}
var body: some View{
VStack {
//Content
ZStack {
switch selectedIndex {
case 0:
NavigationView {
ContentView(viewModel: viewModel)
.navigationBarTitle("Home")
}
case 1:
NavigationView {
SecondView(viewModel: viewModel)
.navigationBarTitle("Cart")
}
...
...
}
}
}
}
}
struct ContentView: View {
#ObservedObject var viewModel: ViewModel
var body: some View {
// etc
}
}
struct SecondView: View {
#ObservedObject var viewModel: ViewModel
var body: some View {
// etc
}
}
One of the key things is that ObservedObject is designed to watch for changes on an object that a view itself doesn't own, so you should never be creating objects and assigning them directly to an #ObservedObject property. Instead they should receive references to objects owned by a view higher up, such as those that have been declared with a #StateObject.
First of all, let sdk = mySdk(dbFactory: DbFactory()) should be #StateObject var sdk = mySdk(dbFactory: DbFactory()).
To continue, SecondView & ContentView should have the same ViewModel, hence they should be like this:
ContentView(viewmodel: sdk)
SecondView(sdk: sdk)
Also use #StateObject instead of #ObservedObject

MVVM Passing data from view to another view's viewModel

I'm new to MVVM and i am trying to pass a location data from my ContenView to DetailsView's viewModel which is DetailsViewViewModel.
My Opening View -> ContentView (My data is here)
Second View -> DetailsView
Data must be reach -> DetailsViewViewModel
Here is my sheet in ContentView
.sheet(item: $viewModel.selectedPlace) { place in
DetailsView(location: place) { newLocation in
viewModel.updateLocation(location: newLocation)
}
I know i'm trying to send my data to details view and it's wrong. It was like that before i convert the architecture to the MVVM and this is the only place that i couldn't convert.
Also here is my DetailsViewViewModel
extension DetailsView {
#MainActor class DetailsViewViewModel: ObservableObject {
enum LoadingState {
case loading, loaded, failed
}
var location: Location
#Published var name: String
#Published var description: String
#Published var loadingState = LoadingState.loading
#Published var pages = [Page]()
init() {
self.location = // ??? how should i initialize?
self.name = location.name
self.description = location.description
}
What is the proper way to this. Using another views data in another views viewmodel.
Let me try to put in an example that uses the convenience of #EnvironmentObject:
Your view model is a class that conforms to ObservableObject, so you can have those nice variables #Published that change the state of the views.
Your main view - or also your App - must "own" the view model, meaning it needs to create the one and only instance of your view model that will be used by all views.
You pass the view model from one view to another using #StateObject and #ObservableObject, but in this example I prefer to use another approach. Make your main view inject the instance of your view model in the environment, so all other views will read from that. The main view uses .environmentObject(viewModel) to do that.
The other views read the view model from the environment by calling #EnvironmentObject. They create a variable specifying only the type - there can only be one instance per type in the environment.
This is the way with which all view will read from the same model. See below a functioning example:
Step 1:
class MyViewModel: ObservableObject {
#Published private(set) var color: Color = .blue
#Published var showSheet = false
func changeColorTo(_ color: Color) {
self.color = color
}
}
Steps 2 and 3:
struct Example: View {
#StateObject private var viewModel = MyViewModel() // Here is the step (2)
var body: some View {
OneView()
.environmentObject(viewModel) // Here is the step (3)
}
}
Step 4 in two different views:
struct OneView: View {
#EnvironmentObject var viewModel: MyViewModel // Here is step (4)
var body: some View {
VStack {
Text("Current color")
.padding()
.background(viewModel.color)
Button {
if viewModel.color == .blue {
viewModel.changeColorTo(.yellow)
} else {
viewModel.changeColorTo(.blue)
}
} label: {
Text("Change color")
}
Button {
viewModel.showSheet.toggle()
} label: {
Text("Now, show a sheet")
}
.padding()
}
.sheet(isPresented: $viewModel.showSheet) {
DetailView()
}
}
}
struct DetailView: View {
#EnvironmentObject var viewModel: MyViewModel // Here is step (4)
var body: some View {
VStack {
Text("The sheet is showing")
.padding()
Button {
viewModel.showSheet.toggle()
} label: {
Text("Now, stop showing the sheet")
}
}
}
}
since location data is your business layer data, you need a use-case to provide it to both view models, and to optimize it caching the response is the way to go.
-ViewModel is responsible to hold the latest view states and data
-The domain layer is responsible to handle business logic
-The data layer (networking, cache, persistence, or in-memory) is responsible for providing the most efficient data storage/retrieval solutions
So, if you are okay with these defenitions and think of writing test for these view models you know that it is not right to inject data from another ViewModel because you would not test that view model on making sure it passes the data to the next viewModel and it is not its responsibility, but you write many tests for you data layer to make sure service calls and caching systems are working properly.
#StateObject var viewModel = ViewModel()
struct ParentView: View {
var body: some View {
Button(action: {
}, label: {
Text("btn")
})
.sheet(item: $viewModel.selectedPlace) { place in
DetailView(name: place.name,
location: place.location,
description: place.description)
}
}
}
struct DetailView: View {
var name: String
var location: String
var description: String
var body: some View {
VStack {
Text(name)
Text(location)
Text(description)
}
}
}
You need to initialise DetailsViewModel from ContentView sheet when you are adding the DetailsView like below:
ContentView
struct ContentView: View {
#StateObject var vm = ViewModel()
var body: some View {
Text("Hello, world!")
.sheet(item: $vm.selectedPlace,
onDismiss: didDismiss) {newLocation in
//Here Initialise the DetailViewModel with a location
DetailsView(detailsVM: DetailsViewModel(location: newLocation))
}
}
func didDismiss(){
}
}
DetailsView:
struct DetailsView: View {
#StateObject var detailsVM : DetailsViewModel
var body: some View {
Text("This is the DetailesView")
}
}
DetailsViewModel:
class DetailsViewModel:ObservableObject{
#Published var location:Location
init(location:Location){
self.location = location
}
}

Initializing #StateObject with parameters [duplicate]

This question already has answers here:
Initialize #StateObject with a parameter in SwiftUI
(12 answers)
Closed 1 year ago.
Suppose I have views like this:
struct Parent: View {
var data: Int
var body: some View {
Child(state: ChildState(data: data))
}
}
struct Child: View {
#StateObject var state: ChildState
var body: {
...
}
}
class ChildState: ObservableObject {
#Published var property: Int
...
init(data: Int) {
// ... heavy lifting
}
}
My understanding is that the init method for Child should not do any heavy computation, since it could be called over and over, by SwiftUI. So heavy lifting should be done in the ChildState class, which is marked with the #StateObject decorator, to ensure it persists through render cycles. The question is, if Parent.data changes, how do I propagate that down so that ChildState knows to update as well?
That is to say, I DO want a new instance of ChildState, or an update to ChildState if and only if Parent.data changes. Otherwise, ChildState should not change.
You need to make the parent state observable too. I'd do it like this:
class ParentState: ObservableObject {
#Published var parentData = 0
}
class ChildState: ObservableObject {
#Published var childData = 0
}
struct Parent: View {
#StateObject var parentState = ParentState()
#StateObject var childState = ChildState()
var body: some View {
ZStack {
Color.black
VStack {
Text("Parent state \(parentState.parentData) -- click me")
.onTapGesture {
parentState.parentData += 1
}
Child(parentState: parentState, childState: childState)
}
}
}
}
struct Child: View {
#ObservedObject var parentState: ParentState
#ObservedObject var childState: ChildState
var body: some View{
Text("Child state \(childState.childData) parentState \(parentState.parentData) -- click me")
.onTapGesture {
childState.childData += 1
}
}
}
struct ContentView: View {
var body: some View {
Parent()
}
}
Tested with Xcode 13.1
Of course, there's nothing wrong with passing the child state object to the initializer of the child view, the way you've shown it. It will work fine still, I just wanted to make the example as clear as possible.

Computed Property from Child's ViewModel does not update #ObservedObject Parent's ViewModel

I have a parent view and a child view, each with their own viewModels. The parent view injects the child view's viewModel.
The parent view does not correctly react to the changes on the child's computed property isFormInvalid (the child view does).
#Published cannot be added to a computed property, and other questions/answers I've seen around that area have not focused on having separate viewModels as this question does. I want separate viewModels to increase testability, since the child view could become quite a complex form.
Here is a file to minimally reproduce the issue:
import SwiftUI
extension ParentView {
final class ViewModel: ObservableObject {
#ObservedObject var childViewViewModel: ChildView.ViewModel
init(childViewViewModel: ChildView.ViewModel = ChildView.ViewModel()) {
self.childViewViewModel = childViewViewModel
}
}
}
struct ParentView: View {
#ObservedObject private var viewModel: ViewModel
init(viewModel: ViewModel = ViewModel()) {
self.viewModel = viewModel
}
var body: some View {
ChildView(viewModel: viewModel.childViewViewModel)
.navigationBarTitle("Form", displayMode: .inline)
.toolbar {
ToolbarItem(placement: .confirmationAction) {
addButton
}
}
}
private var addButton: some View {
Button {
print("======")
print(viewModel.childViewViewModel.$name)
} label: {
Text("ParentIsValid?")
}
.disabled(viewModel.childViewViewModel.isFormInvalid) // FIXME: doesn't work, but the actual fields work in terms of two way updating
}
}
struct ParentView_Previews: PreviewProvider {
static var previews: some View {
let childVm = ChildView.ViewModel()
let vm = ParentView.ViewModel(childViewViewModel: childVm)
NavigationView {
ParentView(viewModel: vm)
}
}
}
// MARK: child view
extension ChildView {
final class ViewModel: ObservableObject {
// MARK: - public properties
#Published var name = ""
var isFormInvalid: Bool {
print("isFormInvalid")
return name.isEmpty
}
}
}
struct ChildView: View {
#ObservedObject private var viewModel: ViewModel
init(viewModel: ViewModel = ViewModel()) {
self.viewModel = viewModel
}
var body: some View {
Form {
Section(header: Text("Name")) {
nameTextField
}
Button {} label: {
Text("ChildIsValid?: \(String(!viewModel.isFormInvalid))")
}
.disabled(viewModel.isFormInvalid)
}
}
private var nameTextField: some View {
TextField("Add name", text: $viewModel.name)
.autocapitalization(.words)
}
}
struct ChildView_Previews: PreviewProvider {
static var previews: some View {
let vm = ChildView.ViewModel()
ChildView(viewModel: vm).preferredColorScheme(.light)
}
}
Thank you!
Computed properties do not trigger any updates. It is the changed to #Publised property that triggers an update, when that happens the computed property is reevaluated. This works as expected which you can see in your ChildView. The problem you are facing is that ObservableObjects are not really designed for chaining (updated to child does not trigger update to the parent. You can workaround the fact by republishing an update from the child: you have subscribe to child's objectWillChange and each time it emits manually trigger objectWillChange on the parent.
extension ParentView {
final class ViewModel: ObservableObject {
#ObservedObject var childViewViewModel: ChildView.ViewModel
private var cancellables = Set<AnyCancellable>()
init(childViewViewModel: ChildView.ViewModel = ChildView.ViewModel()) {
self.childViewViewModel = childViewViewModel
childViewViewModel
.objectWillChange
.receive(on: RunLoop.main)
.sink { [weak self] _ in
self?.objectWillChange.send()
}
.store(in: &cancellables)
}
}
}

SwiftUI Dependency Injection

I have a SwiftUI app, its a tab based app
struct Tab_View: View {
var body: some View {
TabView{
Main1_View().tabItem {
Text("Blah 1")
Image("TabBar1")
}
Main2_View().tabItem {
Text("Blah 2")
Image("TabBar2")
}
}
}
}
Each view has its own view controller
struct Main1_View: View {
#ObservedObject var viewModel: Main1_ViewModel = Main1_ViewModel()
var body: some View {
VStack(spacing:0){
<<< VIEW CODE >>>
}
}
}
ViewModel Example
class Main1_ViewModel: ObservableObject {
#ObservableObject var settings: GameSettings
func Randomise(){
dataSource = settings.selectedFramework;
}
}
The class GameSettings is used by multiple viewmodels, is an ObservableObject, same instance of the class everywhere.
My background is C# and using CastleWindsor for dependency injection.
My Question: Is there a SwiftUI equivalent to pass around an instance of GameSettings?
Because of your requirement to use an ObservableObject (GameSettings) inside another ObservableObject (the view model) and use dependency injection, things are going to be a little bit convoluted.
In order to get a dependency-injected ObservableObject to a View, the normal solution is to use #EnvironmentObject. But, then you'll have to pass the object from the view to its view model. In my example, I've done that in onAppear. The side effect is that the object is an optional property on the view model (you could potentially solve this by setting a dummy initial value).
Because nested ObservableObjects don't work out-of-the box with #Published types (which work with value types, not reference types), you'll want to make sure that you use objectWillChange to pass along any changes from GameSettings to the parent view model, which I've done using Combine.
The DispatchQueue.main.asyncAfter part is there just to show that the view does in fact update when the value inside GameSettings is changed.
(Note, I've also changed your type names to use the Swift conventions of camel case)
import SwiftUI
import Combine
struct ContentView: View {
#StateObject private var settings = GameSettings()
var body: some View {
TabView {
Main1View().tabItem {
Text("Blah 1")
Image("TabBar1")
}
Main2View().tabItem {
Text("Blah 2")
Image("TabBar2")
}
}.environmentObject(settings)
}
}
struct Main1View: View {
#EnvironmentObject var settings: GameSettings
#StateObject var viewModel: Main1ViewModel = Main1ViewModel()
var body: some View {
VStack(spacing:0){
Text("Game settings: \(viewModel.settings?.myValue ?? "no value")")
}.onAppear {
viewModel.settings = settings
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
settings.myValue = "changed"
}
}
}
}
class GameSettings : ObservableObject {
#Published var myValue : String = "Test"
}
class Main1ViewModel: ObservableObject {
private var cancellable : AnyCancellable?
var settings: GameSettings? {
didSet {
print("Running init on Main1ViewModel")
self.objectWillChange.send()
cancellable = settings?.objectWillChange.sink(receiveValue: { _ in
print("Sending...")
self.objectWillChange.send()
})
}
}
}
struct Main2View : View {
var body: some View {
Text("Hello, world!")
}
}
Dependency injection, DI is the practice of providing an object with the other objects it depends on rather than creating them internally.
My Opinion
For SwiftUI you can use #EnvironmentObject. #EnvironmentObject and the View Model Factory both provide a clean solution to this.
Check this tutorial
https://mokacoding.com/blog/swiftui-dependency-injection/

Resources