koin icon indicating copy to clipboard operation
koin copied to clipboard

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

Open babew opened this issue 1 year ago • 1 comments

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<Int>(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

babew avatar Aug 06 '24 16:08 babew

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.

mschwerz-bitrip avatar Sep 19 '24 22:09 mschwerz-bitrip

@mschwerz-bitrip thanks for getting the full clarification 👍 I will add a note for the documentation.

arnaudgiuliani avatar Dec 16 '24 15:12 arnaudgiuliani

I am having the same issue:

TypeSafe Navigation:

composable<Route.AddEditDreamScreen> { backStackEntry ->
                val args = backStackEntry.toRoute<Route.AddEditDreamScreen>()
                val image = args.backgroundID
                
                val addEditDreamViewModel = koinViewModel<AddEditDreamViewModel>()
                
                println("Dream ID: ${navController.currentBackStackEntry?.savedStateHandle?.get<String>("dreamID")}")
                AddEditDreamScreen(
                    dreamImage = image,
                    onMainEvent = { onMainEvent(it) },
                    onAddEditDreamEvent = { addEditDreamViewModel.onEvent(it) },
                    animateVisibilityScope = this,
                    onNavigateToDreamJournalScreen = {
                        navController.popBackStack()
                        navController.navigate(Route.DreamJournalScreen)
                    },
                    onImageClick = { imageID ->
                        navController.navigate(
                            Route.FullScreenImageScreen(imageID)
                        )
                    }
                )
            }

Koin:

val viewModelModule = module {
    viewModelOf(::AddEditDreamViewModel)
}

ViewModel:

class AddEditDreamViewModel(
    private val savedStateHandle: SavedStateHandle,
    private val dreamUseCases: DreamUseCases,
    private val authRepository: AuthRepository,
    private val dictionaryRepository: DictionaryRepository,
    private val vibratorUtil: VibratorUtil,
) : ViewModel() {

    private val _addEditDreamState = MutableStateFlow(
        AddEditDreamState(
            authRepository = authRepository
        )
    )
    val addEditDreamState: StateFlow<AddEditDreamState> = _addEditDreamState.asStateFlow()
    

    init {
        savedStateHandle.get<String>("dreamID")?.let { dreamId -> //value is Null (worked before transition to KMM)
        }
    }

This problem started after I transitioned my project from Android to KMM. Am I doing something wrong?

ErickSorto avatar Jan 17 '25 19:01 ErickSorto

Is there any resolution to this? I'm doing it for Compose multiplatform

bhavik-m-7span avatar May 16 '25 10:05 bhavik-m-7span

@bhavik-m-7span As mentioned, this is the expected behaviour of Koin and also the behaviour of the androidx.lifecycle.viewModel() extension and I assume the hiltViewModel().

But you can still send the result of a screen back to the screen that navigates to it manipulating the navController back stack entries.

The trick is at the moment that you navigate, pass a onResult lambda and start to listen to the back stack entry flow. When the first item is emitted, just call the lambda. In the destination screen, when navigating back, set the result on the previousBackStackEntry.

I wrote a little extension and sample demonstrating:

https://gist.github.com/allanveloso/be95b04c2d320986046e9ca0bbae93c4

allanveloso avatar Jul 29 '25 13:07 allanveloso