Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

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

Tags:

ios

swift

swiftui

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())
like image 655
nde Avatar asked Apr 07 '20 05:04

nde


3 Answers

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 {
        // ...
    }
}
like image 167
Lebron Avatar answered Oct 26 '22 12:10

Lebron


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.

like image 42
Hannes Hertach Avatar answered Oct 26 '22 13:10

Hannes Hertach


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)
    }
}
like image 21
zgluis Avatar answered Oct 26 '22 11:10

zgluis