InsertKoinIO / koin

Koin - a pragmatic lightweight dependency injection framework for Kotlin & Kotlin Multiplatform
https://insert-koin.io
Apache License 2.0
8.98k stars 710 forks source link

Can't send result from one view model to another using back stack entry and SavedStateHandle #1935

Open babew opened 1 month ago

babew commented 1 month ago

Describe the bug I'm triyng to pass result data from one screen to previous screens view model with SavedStateHandle but savedStateHandle.getStateFlow isn't triggered.

Koin module and version: koin-core:4.0.0-RC1 koin-compose:4.0.0-RC1 koin-compose-viewmodel:4.0.0-RC1

Snippet or Sample project to help reproduce

@Composable
internal fun App(context: Context) {
    KoinApplication(application = {
        modules(appModule(context))
    }) {
        content()
    }
}

fun appModule(context: Context) = module {
    viewModelOf(::RegisterViewModel)
    viewModelOf(::ChooseCountryViewModel)

    single { RegisterCommand(get()) }
    single { GetCountriesCommand(get()) }
}

@Composable
internal fun SplashNav(navigateToMain: () -> Unit) {
    val navController = rememberNavController()

    NavHost(
        startDestination    = SplashNavigation.Splash.route,
        navController       = navController,
        modifier            = Modifier.fillMaxSize()
    ) {
        composable(route = SplashNavigation.Splash.route) {
            val viewModel: SplashViewModel = koinViewModel()
            SplashScreen(
                state           = viewModel.state.value,
                navigateToMain  = navigateToMain,
                navigateToLogin = {
                    navController.popBackStack()
                    navController.navigate(SplashNavigation.Login.route)
                }
            )
        }
        composable(route = SplashNavigation.Login.route) {
            val viewModel: LoginViewModel = koinViewModel()
            LoginScreen(
                navigateToMain      = navigateToMain,
                state               = viewModel.state.value,
                events              = viewModel::onTriggerEvent,
                navigateToRegister  = { navController.navigate(SplashNavigation.Register.route) }
            )
        }
        composable(route = SplashNavigation.Register.route) { backStackEntry ->
            println("TEST12345 Register ${navController.currentBackStackEntry?.savedStateHandle?.get<Int>(PHONE_CODE)}")
            val viewModel: RegisterViewModel = koinViewModel()
            RegisterScreen(
                state                   = viewModel.state.value,
                events                  = viewModel::onTriggerEvent,
                actions                 = viewModel.actions,
                navigateToMain          = navigateToMain,
                navigateToChooseCountry = { navController.navigate(SplashNavigation.ChooseCountry.route) }
            )
        }
        composable(route = SplashNavigation.ChooseCountry.route) {
            val viewModel: ChooseCountryViewModel = koinViewModel()
            ChooseCountryScreen(
                state               = viewModel.state.value,
                events              = viewModel::onTriggerEvent,
                onPhoneCodeChosen   = { phoneCode ->
                    println("TEST12345 onPhoneCodeChosen $phoneCode ${navController.previousBackStackEntry} ${navController.previousBackStackEntry?.savedStateHandle}")
                    navController.previousBackStackEntry?.savedStateHandle?.set(PHONE_CODE, phoneCode)
                    navController.popBackStack()
                                      },
                closeScreen         = navController::popBackStack
            )
        }
    }

}

class RegisterViewModel(
    private val registerCommand     : RegisterCommand,
    private val savedStateHandle    : SavedStateHandle
): ViewModel() {

    init {
        println("TEST12345 init $savedStateHandle")
        savedStateHandle.getStateFlow(key = PHONE_CODE, initialValue = "").onEach { phoneCode ->
            println("TEST12345 phoneCode $phoneCode")
            state.value = state.value.copy(phoneCode = phoneCode)
        }.launchIn(viewModelScope)
    }

}

And the printed log is:

TEST12345 Register null
TEST12345 init androidx.lifecycle.SavedStateHandle@f3a6d39
TEST12345 phoneCode 
TEST12345 Register null
TEST12345 Register null
TEST12345 Register null
TEST12345 Register null
TEST12345 onPhoneCodeChosen 244 NavBackStackEntry(d696f916-7c8f-4815-b888-812b745dd815) destination=Destination(0x1e049550) route=Register androidx.lifecycle.SavedStateHandle@685023a
TEST12345 Register 244
TEST12345 Register 244
TEST12345 Register 244

Conclusions: Passing data in SavedStateHandle works as expected because navController.currentBackStackEntry?.savedStateHandle?.get(PHONE_CODE)} starts returning the right result but when I try to use SavedStateHandle in ViewModel injected by Koin, the getStateFlow isn't triggered after navController.previousBackStackEntry?.savedStateHandle?.set(PHONE_CODE, phoneCode).

I can see that the instances of SavedStateHandle from Composable function and ViewModel are different: androidx.lifecycle.SavedStateHandle@f3a6d39 in ViewModel androidx.lifecycle.SavedStateHandle@685023a in the Composable function

mschwerz-bitrip commented 1 week ago

I found the following that may shed some light on this. The savedStateHandle that is on the BackStackEntry vs on the ViewModel are intentionally not the same instances with jetpack compose

The important thing to realize is that every ViewModel instance gets its own SavedStateHandle - if you accessed two separate ViewModel classes on the same screen, they would each have their own SavedStateHandle.

So when you call navController.currentBackStackEntry?.savedStateHandle, you aren't actually getting the SavedStateHandle associated with your CreatePostViewModel - if you look at the NavBackStackEntry source code, you'll note that the SavedStateHandle it is returning is for a private ViewModel subclass that is completely independent of any other ViewModels you create.

Therefore if you want to send a result back specifically to your own custom ViewModel (like your CreatePostViewModel), you need to specifically ask for exactly that ViewModel in your other screen

(see https://stackoverflow.com/questions/76892268/jetpack-compose-sending-result-back-with-savedstatehandle-does-not-work-with-sav/76901998#76901998)

Therefore, I think this is pretty much how compose works vs a koin bug.