Why a New Viewmodel Is Created in Each Compose Navigation Route

Why a new ViewModel is created in each Compose Navigation route?

Usually view model is shared for the whole composables scope, and init shouldn't be called more than once.

But if you're using compose navigation, it creates a new model store owner for each destination. If you need to share models between destination, you can do it like in two ways:

  1. By passing it directly to viewModel call. In this case only the passed view model will be bind to parent store owner, and all other view models created inside will be bind(and so destroyed when route is removed from the stack) to current route.
  2. By proving value for LocalViewModelStoreOwner, so all composables inside will be bind to the parent view model store owner, and so are not gonna be freed when route is removed from the stack.
val viewModelStoreOwner = checkNotNull(LocalViewModelStoreOwner.current) {
"No ViewModelStoreOwner was provided via LocalViewModelStoreOwner"
}
val navController = rememberNavController()
NavHost(navController = navController, startDestination = "first") {
composable("first") {
val model = viewModel<Model>(viewModelStoreOwner = viewModelStoreOwner)
}
composable("second") {
CompositionLocalProvider(
LocalViewModelStoreOwner provides viewModelStoreOwner
) {
val model = viewModel<Model>()
}
}
}

Jetpack compose navigation with viewModel

collectAsState triggers recomposition each time you emit a new value to the flow. It means, that JetSurvey0App will be re-called.

You're trying to navigate using navigator.destination, but you're creating a new object on each recomposition:

val navigator = Navigator()
val destination by navigator.destination.collectAsState()

You can make your WelcomeViewModel.navigator public instead of private and collect its destination - as you change the state of this particular object.

Read more about recompositions in Compose Mental Model.

How to navigate on view model field change in Compose Navigation?

I think your view model should know nothing about navigation routes. Simple verificationNeeded flag will be enough in this case:

var verificationNeeded by mutableStateOf(false)
private set

private val signUpCallback = object : SignUpHandler {
override fun onSuccess(user: User?, signUpResult: SignUpResult?) {
verificationNeeded = true
Log.i(Constants.TAG, "sign up success")
}

override fun onFailure(exception: Exception?) {
Log.i(Constants.TAG, "sign up failure ")
}
}

The best practice is not sharing navController outside of the view managing the NavHost, and only pass even handlers. It may be useful when you need to test or preview your screen.

Here's how you can navigate when this flag is changed:

@Composable
fun CreateAccountScreen(
onRequestVerification: () -> Unit,
viewModel: CreateAccountViewModel = hiltViewModel(),
) {
if (viewModel.verificationNeeded) {
LaunchedEffect(Unit) {
onRequestVerification()
}
}
}

in your navigation managing view:

val navController = rememberNavController()
NavHost(
navController = navController,
startDestination = Screen.CreateAccount
) {
composable(Screen.CreateAccount) {
CreateAccountScreen(
onRequestVerification = {
navController.navigate(Screen.VerifyAccountScreen)
}
)
}
}

Sharing viewModel within Jetpack Compose Navigation

You could create a viewModel and pass it trough

class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)

setContent {
NavigationSystem()
}
}
}

@Composable
fun NavigationSystem() {
val navController = rememberNavController()

val viewModel: ConversionViewModel = viewModel()

NavHost(navController = navController, startDestination = "home") {
composable("home") { HomeScreen(navController, viewModel) }
composable("result") { ResultScreen(navController, viewModel) }
}
}

@Composable
fun HomeScreen(navController: NavController, viewModel: ConversionViewModel) {
var temp by remember { mutableStateOf("") }
val fahrenheit = temp.toIntOrNull() ?: 0

Column(
modifier = Modifier
.padding(16.dp)
.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Column {
OutlinedTextField(
value = temp,
onValueChange = { temp = it },
label = { Text("Fahrenheit") },
modifier = Modifier.fillMaxWidth(0.85f)
)

Spacer(modifier = Modifier.padding(top = 16.dp))

Button(onClick = {
Log.d("HomeScreen", fahrenheit.toString())
if (fahrenheit !in 1..160) return@Button
viewModel.onCalculate(fahrenheit)
navController.navigate("result")
}) {
Text("Calculate")
}
}
}
}

@Composable
fun ResultScreen(navController: NavController, viewModel: ConversionViewModel) {
Column(
modifier = Modifier
.padding(16.dp)
.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Log.d("ResultScreenDebug", "celsius: ${ viewModel.celsius.value.toString()}")
Text(
viewModel.celsius.value.toString(),
style = MaterialTheme.typography.h6
)

Spacer(modifier = Modifier.padding(top = 24.dp))

Button(onClick = { navController.navigate("home") }) {
Text(text = "Calculate again")
}
}
}

Cannot create an instance of viewmodel while using Jetpack Compose navigation

If your @HiltViewModel is scoped to the navigation graph use hiltNavGraphViewModel() instead of viewModel() to initialize. For more reference android documentaion

Update

hiltNavGraphViewModel() is now deprecated, use hiltViewModel() instead

Thanks to Narek Hayrapetyan for the reminder

How to save data in a composable function with view model constructor

It seems like I need to save the view model as a state or something?

You don't have to. ViewModels are already preserved as part of their owner scope. The same ViewModel instance will be returned to you if you retrieve the ViewModels correctly.

I seem to be missing something.

You are initializing a new instance of your NFTViewModel every time the navigation composable recomposes (gets called) instead of retrieving the NFTViewModel from its ViewModelStoreOwner.

You should retrieve ViewModels by calling viewModel() or if you are using Hilt and @HiltViewModel then call hiltViewModel() instead.

No Hilt

val vm: NFTViewModel = viewModel()

Returns an existing ViewModel or creates a new one in the given owner (usually, a fragment or an activity), defaulting to the owner provided by LocalViewModelStoreOwner.
The created ViewModel is associated with the given viewModelStoreOwner and will be retained as long as the owner is alive (e.g. if it is an activity, until it is finished or process is killed).

If using Hilt (i.e. your ViewModel has the @HiltViewModel annotation)

val vm: NFTViewModel = hiltViewModel()

Returns an existing @HiltViewModel-annotated ViewModel or creates a new one scoped to the current navigation graph present on the NavController back stack.
If no navigation graph is currently present then the current scope will be used, usually, a fragment or an activity.

The above will preserve your view model state, however you are still resetting the state inside your composable if your composable exits the composition and then re-enters it at a later time, which happens every time you navigate to a different screen (to a different "screen" composable, if it is just a dialog then the previous composable won't leave the composition, because it will be still displayed in the background).

Due to this part of the code

@Composable
fun HomeScreen(viewModel: NFTViewModel) {
val feedState by viewModel.nftResponse.observeAsState()

// this resets to false when HomeScreen leaves and later
// re-enters the composition
val fetched = remember { mutableStateOf(false) }

if (!fetched.value) {
fetched.value = true
viewModel.getFeed()
}

fetched will always be false when you navigate to (and back to) HomeScreen and thus getFeed() will be called.
If you don't want to call getFeed() when you navigate back to HomeScreen you have to store the fetched value somewhere else, probably inside your NFTViewModel and only reset it to false when you want that getFeed() is called again.



Related Topics



Leave a reply



Submit