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

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

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 {
        // ...
    }
}

Tags:

Ios

Swift

Swiftui