JillevdW
JillevdW

Reputation: 1127

State change in parent View creates new instance of child ViewModel

Problem

When a State variable in a parent View changes, SwiftUI will redraw child Views and consequently call their constructors again.

Consider the following View:

struct Parent: View {
    @ObservedObject var viewModel: ParentViewModel

    var body: some View {
        VStack {
            Child(viewModel: ChildViewModel())
        }
    }
}

Any state change in the Parent View (ex. a Published variable changing in its ViewModel) might cause the Child View to be redrawn, leading to its viewModel property to receive a new instance of ChildViewModel.

Expectation vs. reality

When a subview gets redrawn I expect it to still reflect the state it was previously in, however since constructor is called again with a new instance of the ViewModel, this is not the case.

What I've tried

Let me preface this by stressing that we use SwiftUI 1.0, so using the @StateObject property wrapper is not an option.

We've attempted to use a ViewModelProvider class that caches ViewModels of a certain type for a longer time, so that consecutive initializations of a certain ViewModel will return the same instance. This works perfectly if there can only be one instance of a ViewModel (and thus, View) at one time. However, once you add Views that can navigate to new instances of that same View (adding the requirement for multiple ViewModels of the same type to exist) this falls apart.

We have some additional ideas about making aforementioned pattern work, but I just can't shake the feeling there should be a better way to do this without jumping through hoops.

Code

Setting Example as the rootView of your SceneDelegate, combined with some furious tapping of the button, should trigger print statements that should show you that every now and then (not even predictably) a different instance of ChildViewModel is used.

struct Example: View {
    @State var selection = 0
    var body: some View {
        VStack {
            Button("Tapped \(selection) times") {
                self.selection += 1
            }
            
            ChildView(viewModel: ChildViewModel())
            ChildView(viewModel: ChildViewModel())
        }
    }
}

struct ChildView: View {
    @ObservedObject var viewModel: ChildViewModel
    var body: some View {
        Text("Child")
    }
}

class ChildViewModel: ObservableObject {
    init() {
        withUnsafePointer(to: self) {
            print($0)
        }
    }
}

Upvotes: 2

Views: 710

Answers (1)

pawello2222
pawello2222

Reputation: 54426

SwiftUI 2

Use a StateObject - it is only created once:

struct ExampleView: View {
    @State var selection = 0

    var body: some View {
        VStack {
            Button("Tapped \(selection) times") {
                self.selection += 1
            }
            
            ChildView() // don't pass the ViewModel
            ChildView()
        }
    }
}

struct ChildView: View {
    @StateObject var viewModel = ChildViewModel() // create here

    var body: some View {
        Text("Child")
    }
}

Also see:

SwiftUI 1

You may need to use an @EnvironmentObject instead.

In the SceneDelegate:

let viewModel = ChildViewModel()
ExampleView()
    .environmentObject(viewModel)

and in the ChildView:

struct ChildView: View {
    @EnvironmentObject var viewModel: ChildViewModel // automatically injected

    var body: some View {
        Text("Child")
    }
}

Alternatively, you can use a struct instead of a class:

struct ChildViewModel { ... }

struct ChildView: View {
    @State var viewModel = ChildViewModel() // mark as `@State`

    var body: some View {
        Text("Child")
    }
}

Upvotes: 1

Related Questions