rickclephas / KMP-ObservableViewModel

Library to use AndroidX/Kotlin ViewModels with SwiftUI
MIT License
569 stars 29 forks source link

Can't observe the same viewModel object in two different screens on iOS #57

Closed cameron-greene-telus closed 6 months ago

cameron-greene-telus commented 6 months ago

I'm using Koin DI to inject singleton ViewModels into my iOS app - I have the following LoginScreen defined:

struct LoginScreen: View {
    @ObservedViewModel var viewModel: LoginViewModel = Koin.instance.get()

    ...
}

I created a SplashScreen that also depends on the LoginViewModel, but this code fails with the error kotlin.IllegalStateException: KMMViewModel can't be wrapped more than once

struct SplashScreen: View {
    @ObservedViewModel var loginViewModel: LoginViewModel = Koin.instance.get()

    ...
}

If I make the ViewModel manually (so it's not the same object as the one in LoginScreen), it doesn't get the error when run, but loses the benefit of being a singleton between the two screens.

struct SplashScreen: View {
    @ObservedViewModel var loginViewModel: LoginViewModel = LoginViewModel(authRepository: Koin.instance.get())

    ...
}

There are a couple options here - I could pass the ViewModel from one screen to the next, but that tightly couples one view to the one before it, which I don't want. I could also create a SplashScreenViewModel and use that, but there are other places in my app that are going to need to share instances of the same ViewModel, so this would become an issue again very quickly.

This is not an issue on Android, you can inject a singleton ViewModel into as many screens as you want without getting this error.

Is this something that could be fixed at the library level, or is there another way to access singleton ViewModels in multiple screens?

rickclephas commented 6 months ago

Is this something that could be fixed at the library level, or is there another way to access singleton ViewModels in multiple screens?

Singleton ViewModels in itself are supported. However the issue/challenge is in storing a reference to this singleton. On iOS your KMMViewModel is being wrapped inside an ObservableViewModel. The @*ViewModel property wrappers transparently create or get this wrapper and keep a reference to it.

In cases like these where the ViewModel outlives the view you can use the observableViewModel(for:) function. It returns the ObservableViewModel wrapper which you can store anywhere you like. E.g. you could store this wrapper in a parent view as a regular property, or in your DI graph as a singleton.

Note: keep in mind that this wrapper keeps a strong reference to your KMMViewModel, so storing a reference to the wrapper inside the viewmodel would result in a reference cycle.

rickclephas commented 6 months ago

Another approach could be to provide these singleton viewmodels as @EnvironmentViewModels. That would be similar to storing the wrapper in a parent view, but would reduce the amount of boilerplate.

jollygreenegiant commented 6 months ago

@rickclephas thanks for the quick response! Android dev here trying to wrap my brain around the iOS side of things, so I appreciate the help.

Assuming I have a ContentView that creates the LoginViewModel object and passes it to SplashScreen and LoginScreen as an environment variable, what might a minimum example of each file look like?

Right now I have the following:

struct ContentView: View {
    var loginViewModel: LoginViewModel = Koin.instance.get()

    var body: some View {
          SplashScreen()
              .environmentObject(loginViewModel)
    }
}
struct SplashScreen: View {
    @EnvironmentViewModel var loginViewModel: LoginViewModel

    var body: some View {
        if (viewModel.state.isLoggedOut) {
            LoginScreen()
        }

        ...
    }
}
struct LoginScreen: View {
    @EnvironmentViewModel var viewModel: LoginViewModel
    ...
}

and the app crashes on launch with the error: SwiftUI/EnvironmentObject.swift:90: Fatal error: No ObservableObject of type ObservableViewModel<SharedLoginViewModel> found. A View.environmentObject(_:) for ObservableViewModel<SharedLoginViewModel> may be missing as an ancestor of this view.

What am I missing here?

rickclephas commented 6 months ago

Changing environmentObject(loginViewModel) to environmentViewModel(loginViewModel) should do the trick.

jollygreenegiant commented 6 months ago

That did it - thank you for your help!