7

Jetpack Compose navigation architecture with ViewModels

 3 years ago
source link: https://proandroiddev.com/jetpack-compose-navigation-architecture-with-viewmodels-1de467f19e1c
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

How it worked in ancient times

In the beginning there was l̵i̵g̵h̵t̵ startActivity(AnyActivity::class.java) whenever we wanted to show a new screen, alternatively you would use fragments with the FragmentManager and FragmentTransactions, manage the backstack(s), remember to use the ChildFragmentManager, too, whenever you had to, remember there is an “old” FragmentManager and a SupportFragmentManager that you could mix up etc. Google decided that this sucks and developed the navigation component, giving us navigation graphs and a NavController that has all the power of previous times combined.

The very basics

Let’s follow the tutorial on the Compose navigation component, use the Compose version of the new NavController, and we quickly have something like this:

class HomeActivity : AppCompatActivity() {

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

setContent {
val navController = rememberNavController()

MyTheme {
Scaffold {
NavigationComponent(navController)
}
}
}
}
}

@Composable
fun NavigationComponent(navController: NavHostController) {
NavHost(
navController = navController,
startDestination = "home"
) {
composable("home") {
HomeScreen(navController)
}
composable("details") {
DetailScreen()
}
}
}

@Composable
fun HomeScreen(navController: NavController) {
Button(onClick = { navController.navigate("detail") }) {
Text(text = "Go to detail")
}
}

@Composable
fun DetailScreen() {
Text(text = "Detail")
}

We created a simple NavHost with two routes, home and detail, where the home screen has a button to go to the detail screen, each consisting of a simple text field.

Introduction of ViewModels

When you reach a certain point, you will want to introduce some more complex logic, and this is typically done with ViewModels from the Android Architecture package. This is explained in detail here. Fortunately, they also explained how to connect this with the navigation component from earlier, which is described here.

Let’s create a ViewModel following the tutorials for our detail screen and get the text to display from it, while also providing it from our navigation component:

@Composable
fun NavigationComponent(navController: NavHostController) {
NavHost(
navController = navController,
startDestination = "home"
) {
composable("home") {
HomeScreen(navController)
}
composable("details") {
DetailScreen(viewModel())
}
}
}

@Composable
fun DetailScreen(viewModel: DetailViewModel) {
Text(text = viewModel.getDetailText())
}

class DetailViewModel : ViewModel() {

fun getDetailText(): String {
// some imaginary backend call
return "Detail"
}
}

In contrast with the “ancient times”, where we retrieved a ViewModel within an activity or fragment and it was pretty obvious when ViewModel.onCleared() was called, being that it was tied to the lifecycle of the activity/fragment, when is it now called?

Regardless of whether you use viewModel() or with Hilt hiltViewModel() to retrieve your ViewModel, they both will call onCleared() when the NavHost finishes transitioning to a different route. So whenever you navigate to another Composable, it will be cleaned up. This is achieved by defining a DisposableEffect on the navigation route when the NavHost is created and you can mimic the behavior even if you’re not using the navigation library and need to clean up the ViewModel yourself.

But now my @Preview is broken 🤔

With the introduction of ViewModels our detail screen now needs a DetailViewModel instance as an input. So, if you defined a Preview anywhere, it is broken now.

@Preview
@Composable
fun DetailScreenPreview() {
DetailScreen(viewModel = ??)
}

After reading into this problem I found this Slack conversation with Google’s Jim Sproch:

Best practice is probably to avoid referencing AAC ViewModels in your composable functions.

Oh.. so, great, let’s forget all the examples we read in the tutorials and refactor our composable functions:

@Composable
fun NavigationComponent(navController: NavHostController) {
NavHost(
navController = navController,
startDestination = "home"
) {
composable("home") {
HomeScreen(navController)
}
composable("details") {
val viewModel = viewModel<DetailViewModel>()
DetailScreen(viewModel::getDetailText)
}
}
}

@Composable
fun DetailScreen(textProvider: () -> String) {
Text(text = textProvider())
}

@Preview
@Composable
fun DetailScreenPreview() {
DetailScreen { "Sample text" }
}

The idea is that your composable functions only takes low level inputs, like lambdas, LiveData or a Flow (which you might need if want to work with a state). This actually also enables us, now, to easily preview different texts 🎉.

Okay, cool, but how to preview the home screen?

Remember our initial screen which we created exactly like shown in the navigation tutorial and passed the NavController to it so we are able to navigate to the detail screen?

@Composable
fun HomeScreen(navController: NavController) {
Button(onClick = { navController.navigate("detail") }) {
Text(text = "Go to detail")
}
}

In order to create a preview for this we would need to provide a value for NavController obviously. You don’t have a mock version handy you say..? How to actually connect this now for the case the ViewModel asks to navigate to some other screen?

My recommendation is to move any navigation logic out of your composable functions. My suggestion is to create a middle layer for navigation:

class Navigator {

private val _sharedFlow =
MutableSharedFlow<NavTarget>(extraBufferCapacity = 1)
val sharedFlow = _sharedFlow.asSharedFlow()

fun navigateTo(navTarget: NavTarget) {
_sharedFlow.tryEmit(navTarget)
}

enum class NavTarget(val label: String) {

Home("home"),
Detail("detail")
}
}

Instead of Kotlin’s SharedFlow you’re of course free to use whatever you’d like. Pass the singleton reference to your ViewModels and whenever you want to navigate to another screen simply call the navigateTo() function.

The last step is to actually navigate to a different screen, which will be done inside our composable NavigationComponent function from the beginning:

@Composable
fun NavigationComponent(
navController: NavHostController,
navigator: Navigator
) {
LaunchedEffect("navigation") {
navigator.sharedFlow.onEach {
navController.navigate(it.label)
}.launchIn(this)
}

NavHost(
navController = navController,
startDestination = NavTarget.Home.label
) {
...
}
}

With LaunchedEffect we create a CoroutineScope that is started as soon as our composable component is created and canceled as soon as the composition is removed. As a result, whenever Navigator.navigateTo() is called, this snippet listens to it and performs the actual transition.

Thanks for reading 🙂


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK