Can't send result from one view model to another using back stack entry and SavedStateHandle
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
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 thanks for getting the full clarification 👍 I will add a note for the documentation.
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?
Is there any resolution to this? I'm doing it for Compose multiplatform
@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