'Sharing viewModel within Jetpack Compose Navigation

Can anyone suggest how to share a ViewModel within different sections of a Jetpack Compose Navigation?

According to the documentation, viewModels should normally be shared within different compose functions using the activity scope, but not if inside the navigation.

Here is the code I am trying to fix. It looks like I am getting two different viewModels here in two sections inside the navigation:

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

        setContent {
            NavigationSystem()
        }
    }
}

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

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

@Composable
fun HomeScreen(navController: NavController) {
    val viewModel: ConversionViewModel = viewModel()
    
    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) {
    val viewModel: ConversionViewModel = viewModel()

    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")
        }
    }
}

Debug log:

2021-07-27 22:01:52.542 27113-27113/com.example.navigation D/ViewModelDebug: fh: 65, cs: 18, celcius: 18.0
2021-07-27 22:01:52.569 27113-27113/com.example.navigation D/ResultScreenDebug: celsius: 0.0

Thanks!



Solution 1:[1]

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")
        }
    }
}

Solution 2:[2]

Consider passing your activity to viewModel() fun as viewModelStoreOwner parameter since ComponentActivity implements ViewModelStoreOwner interface:

val viewModel: ConversionViewModel = viewModel(LocalContext.current as ComponentActivity)

This code will return the same instance of ConversionViewModel in all your destinations.

Solution 3:[3]

I think a better solution, than scopes your ViewModel to your entire NavGraph is to build the ViewModel in the Home route and then access from the Result route (route scoped):

//extensions
@Composable
inline fun <reified T : ViewModel> NavBackStackEntry?.viewModel(): T? = this?.let {
    viewModel(viewModelStoreOwner = it)
}

@Composable
inline fun <reified T : ViewModel> NavBackStackEntry.viewModel(
    viewModelStoreOwner: ViewModelStoreOwner = checkNotNull(LocalViewModelStoreOwner.current) {
        "No ViewModelStoreOwner was provided via LocalViewModelStoreOwner"
    }
): T {
    return androidx.lifecycle.viewmodel.compose.viewModel(
        viewModelStoreOwner = viewModelStoreOwner, key = T::class.java.name
    )
}

//use-case
@Composable
fun HomeScreen(navController: NavController) {
    val viewModel: ConversionViewModel = viewModel()
    ...
}
@Composable
fun ResultScreen(navController: NavController) {
    val viewModel: ConversionViewModel? = navController.previousBackStackEntry.viewModel()
    ...
}

But if you must to scope it to the entire NavGraph, you can do something like the @akhris said, but in a way that you could uncouple the ViewModelStoreOwner from the Activity:

//composable store-owner builder
@Composable
fun rememberViewModelStoreOwner(): ViewModelStoreOwner {
    val context = LocalContext.current
    return remember(context) { context as ViewModelStoreOwner }
}

This way you uncouple the Activity from your ViewModelStoreOwner and can do something like:

val LocalNavGraphViewModelStoreOwner =
    staticCompositionLocalOf<ViewModelStoreOwner> {
        TODO("Undefined")
    }

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

    CompositionLocalProvider(
        LocalNavGraphViewModelStoreOwner provides vmStoreOwner
    ) {
        NavHost(navController = navController, startDestination = "home") {
            composable("home") { HomeScreen(navController) }
            composable("result") { ResultScreen(navController) }
        }
    }
}

@Composable
fun HomeScreen(navController: NavController) {
    val viewModel: ConversionViewModel = viewModel(viewModelStoreOwner = LocalNavGraphViewModelStoreOwner.current)
    ...
}

@Composable
fun ResultScreen(navController: NavController) {
    val viewModel: ConversionViewModel = viewModel(viewModelStoreOwner = LocalNavGraphViewModelStoreOwner.current)
    ...
}

Solution 4:[4]

Is this not good enough?

Get the sharedViewModel at the top MainScreen and pass it explicitly. It doesn't seem to cause any memory leaks.

// MainActivity.kt
class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            MainScreen()
        }
    }
}

@Composable
fun MainScreen(sharedViewModel: SharedViewModel = viewModel()) {
    val navController = rememberNavController()
    Navigation(navController, sharedViewModel)
}
@Composable
// Navigation.kt
fun Navigation(navController: NavHostController, sharedViewModel: SharedViewModel) {
    NavHost(navController, startDestination = "Home") {
        composable("Home") {
            EpisodeListScreen(navController, sharedViewModel)
        }
        composable("Login") {
            LoginScreen(navController, sharedViewModel)
        }
        composable("Editor") {
            EditorScreen(navController, sharedViewModel)
        }
        composable("Setting") {
            SettingScreen(navController, sharedViewModel)
        }
    }
}
// EpisodeListScreen.kt
@Composable
fun EpisodeListScreen(
    navController: NavController,
    sharedViewModel: SharedViewModel = viewModel()
) {
    // ...
}

Sources

This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.

Source: Stack Overflow

Solution Source
Solution 1 Geert Berkers
Solution 2
Solution 3 ch4k4uw
Solution 4 msickpaler