Ryan Kanno
Ryan Kanno

Reputation: 115

SwiftUI @Observable class ViewModel gets initialized twice when setting an enum variable, but not class ViewModel: ObservableObject

Having problems with my ViewModel being initialized twice when using the new Observation framework for my @Observable class ViewModel, when setting an enum variable. Tried changing it back to the older class ViewModel: ObservableObject and everything worked as expected. Does anyone have an explanation?

This example doesn't work. The ViewModel.init() and AuthManager.init() get called twice when the ViewModel.loginStatus enum is set when using the @Observable macro on the ViewModel:

import SwiftUI

struct ContentView: View {
   @State private var vm = ViewModel()

   var body: some View {
      Button {
         vm.login()
      } label: {
         Text("Login")
      }
      switch vm.loginStatus {
      case .unknown, .loggedOut:
         Text("Login Screen")
      case .loggedIn:
         Text("Home Screen")
      }
   }
}

-----------------------------------------------

import Foundation
import Observation

@Observable class ViewModel {
   private let auth = AuthManager()

   enum LoginStatus {
      case unknown, loggedIn, loggedOut
   }
   var loginStatus = .unknown

   init() {
      print(#function)
      auth.delegate = self
   }
   
   func login() {
      auth.login()
   }
}

extension ViewModel: AuthManagerDelegate {
   func authStateDidChange(isLoggedIn: Bool) {
      logginStatus = isLoggedIn ? .loggedIn : .loggedOut
   }
}

-----------------------------------------------

protocol AuthManagerDelegate: AnyObject {
   func authStateDidChange(isLoggedIn: Bool)
}

class AuthManager {
   weak var delegate: AuthManagerDelegate?
   private let auth = Dependency()

   init() {
      print(#function)
   }

   var user: User? {
      didSet {
         delegate?.authStateDidChange(isLoggedIn: user != nil)
      }
   }

   func login() {
      auth.signIn { [weak self] result in
         guard let self else { return }
         user = result.user
      }
   }
}

However, when converting this back to the old way before using the @Observable macro, the ViewModel.init() and AuthManager.init() only get called once as expected:

struct ContentView: View {
   @StateObject private var vm = ViewModel()

   var body: some View {
      ...same as above...
   }
}

-----------------------------------------------

class ViewModel: ObservableObject {
   private let auth = AuthManager()

   enum LoginStatus {
      case unknown, loggedIn, loggedOut
   }
   @Published var loginStatus = .unknown

   ini() {
      print(#function)
      auth.delegate = self
   }

   func login() {
      auth.login()
   }
}

extension ViewModel: AuthManagerDelegate {
   func authStateDidChange(isLoggedIn: Bool) {
      logginStatus = isLoggedIn ? .loggedIn : .loggedOut
   }
}

-----------------------------------------------

protocol AuthManagerDelegate: AnyObject {
   ...same as above...
}

class AuthManager {
   ...same as above...
}

Upvotes: 1

Views: 246

Answers (1)

rob mayoff
rob mayoff

Reputation: 386018

It is an unfortunate accident of history (I believe) that the State initializer has this signature:

init(wrappedValue value: Value)

while the StateObject initializer has this signature:

init(wrappedValue thunk: @autoclosure @escaping () -> ObjectType)

Notice that StateObject takes an @autoclosure, while State does not.

So each time your program creates an instance of your old-style (StateObject-based) ContentView, it initializes the StateObject-wrapped vm property with a closure that calls the ViewModel initializer. SwiftUI only then calls that closure one time, the first time the ContentView appears in your view hierarchy. On subsequent updates, it reuses the already-created ViewModel instead of calling the closure to create another.

But each time your program creates an instance of your new-style (Observation-based) ContentView, it initializes the State-wrapped vm property with a newly created ViewModel. Then, on updates, SwiftUI discards the new ViewModel in favor of the old one.

Upvotes: 3

Related Questions