33

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!

Hasan
  • 589
  • 2
  • 6
  • 11
  • A good way to share business logic accross view models and avoid redundancy would be to use "Use cases" from the domain layer. Read more in https://developer.android.com/topic/architecture/domain-layer – Iglesias Leonardo Aug 27 '23 at 21:02

5 Answers5

19

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.

akhris
  • 475
  • 4
  • 7
17

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")
        }
    }
}
Geert Berkers
  • 653
  • 7
  • 19
  • It worked perfectly, thanks a million! I was asking this question everywhere, but no one seems to know the answer yet. I hope this thread will help many others in the future. – Hasan Jul 30 '21 at 07:14
  • Also a side question: I tried to initialize the view model in onCreate() of the activity and pass it to the main compose function, but it shows an error if I try to initialize it there like this: `val viewModel: ConversionViewModel = viewModel()`. Can you please explain why? – Hasan Jul 30 '21 at 07:16
  • 1
    You have to put the function in setContent { } because `=viewmodel` is a Composable function, so it needs to be called from a composable scope. – Geert Berkers Aug 04 '21 at 08:26
  • 3
    While this solution works, it seems like the [official documentation](https://developer.android.com/jetpack/compose/interop/compose-in-existing-arch) says that this approach is a no-go: `You should never pass down ViewModel instances to other composables, pass only the data they need and functions that perform the required logic as parameters.` – BenjyTec Jan 06 '23 at 11:57
  • @BenjyTec Does that mean each composable should have their own ViewModel? – adravel May 29 '23 at 09:15
8

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)
    ...
}
ch4k4uw
  • 181
  • 2
  • 4
0

A recommended approach, if you want to access the ViewModel scoped to navigation routes or the navigation graph i.e. that is shared between navigation routes or the navigation graph, you should use:

@Composable 
fun MyApp() {
    val navController = rememberNavController()
    val startRoute = "example"
    val innerStartRoute = "exampleWithRoute"
    NavHost(navController, startDestination = startRoute) {
        navigation(startDestination = innerStartRoute, route = "Parent") {
            composable("exampleWithRoute") { backStackEntry ->
                //IMPORTANT PART: getting the scoped ViewModel reference.

                val parentEntry = remember(backStackEntry) {
                    navController.getBackStackEntry("Parent")
                }
                val parentViewModel = hiltViewModel<ParentViewModel>(parentEntry)

                ExampleWithRouteScreen(parentViewModel)
            }
        }
    }
}

To make it easier, you may use the following extension functions might be useful:

@Composable
inline fun <reified T : ViewModel> NavBackStackEntry.sharedViewModel(
    navController: NavController,
): T {
    val navGraphRoute = destination.parent?.route ?: return viewModel()
    val parentEntry  = remember(this){
        navController.getBackStackEntry(navGraphRoute)
    }
    return viewModel(parentEntry)
}

Then you can simply call inside your composable route:

val parentViewModel = backStackEntry.sharedViewModel<ParentViewModel>(navController)

You may also want to watch this Philipp Lackner's video on the topic.

Hope it helps!

Geraldo Neto
  • 3,670
  • 1
  • 30
  • 33
-3

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()
) {
    // ...
}
msickpaler
  • 89
  • 1
  • 7