Home > Software design >  SwiftUI: @StateObject init multiple times
SwiftUI: @StateObject init multiple times

Time:12-14

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...

CodePudding user response:

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)")
        }
    }
}
  • Related