Blog Infos
Author
Published
Topics
, , , , ,
Published

Being a UI toolkit, Jetpack Compose is in rather direct contact with most of the other libraries in the UI layer. From dependency injection to ViewModel and from Multi-Module navigation to Testing.
Now the challenge, as usual, is to put these different puzzle pieces next to each other so that we can make a beautiful whole.

For demonstration purposes, imagine we have an email app with a lot of Compose code. We’ll try to see if we can find any room for improvement.

State Hoisting

Let’s start with our first file for a screen that shows the details of an email based on its ID using a ViewModel that has a StateFlow to expose the view state:

// EmailScreen.kt

@Composable
fun EmailScreen(id: String) {
  val viewModel: EmailViewModel by ...
  viewModel.fetchEmail(id)

  val email by viewModel.stateFlow.collectAsState()
  
  Column {
    Text("Subject: ${email.subject}")
    Text(email.body)
    Button(onClick = { viewModel.archive(id) }) {
      Text("Archive")
    }
  }
}

So what are the problems with this code, other than its ability to make a UI designer cry?
Firstly, injecting a ViewModel inside our screen would create two problems.

  1. We can’t use @Preview anymore as Compose doesn’t know how to create it in the preview phase.
  2. We would have to mock the entire ViewModel rather than just passing a mocked state of the screen.
  3. We are actually fetching the email on each recomposition by calling fetchEmail().

To fix them, you might be tempted to pass the ViewModel to the function as a parameter, but it won’t fix either of the issues. How about passing the StateFlow as a parameter? Try it yourself to see if you detect its issues.

Passing the email to the EmailScreen() as a parameter seems to fix all the issues. We also need to pass the archive button clicks back up as events because we won’t have the ViewModel here anymore:

// EmailScreen.kt

@Preview
@Composable
fun EmailScreen(
  @PreviewParameter(EmailProvider::class) email: Email,
  onArchiveClick: () -> Unit = {}
) {
  Column {
    Text("Subject: ${email.subject}")
    Text(email.body)
    Button(onClick = onArchiveClick) {
      Text("Archive")
    }
  }
}

Great! As you can see, now we can use @preview easily using PreviewParameter and to test this composable function, all we need to do is to pass a mocked email:

// EmailScreenTest.kt

@Test
fun `when archive is clicked then the callback is called`() {
  val listener = Mockito.mock(CustomArchiveListener::class.java)
  composeTestRule.setContent { EmailScreen(mockedEmail, listener::onArchiveClick) }
  
  with(composeTestRule) {
    onNodeWithText(mockedEmail.body).assertExists()
    onNodeWithText("archive").assertExists().performClick()
    verify(listener).onArchiveClick()
  }
}
Multi-Module Navigation

One of the most common ways to modularize an Android project is to put each screen or flow of screens in its own module (called “feature” from here on).
Separating these UI modules means that they normally won’t have access to each other.

Our example project has two feature modules. One called ui:inbox to show the list of emails received, and another called ui:email to show the content of an email. Let’s take a look at the navigation logic to see what problems we can find:

// MailNav.kt file inside app module,
// that has access to all other modules

@Composable
fun MainNav(navController: NavHostController) {
  NavHost(...) { // this: NavGraphBuilder
    composable(
      route = "inbox",
      onEmailClick = { emailId -> navController.navigate("email/$emailId") }
    ) {
      ...
    }
    composable(
      route = "email/{emailId}",
      onArchiveClick = ...
    ) {
      ...
    }
  }
}

As you can see, even though I used a couple of “…”, a lot is going on there. The first thing is that some navigation details, like the route, can be encapsulated better. The second is that the navigation parameters are not type-safe. The good news is that Google has some recommended best practices for us. So let’s divide the navigation logic into separate parts and move them into their respective modules. Using extension functions on NavGraphBuilder and NavController:

// InboxNav.kt file inside ui:inbox module

fun NavGraphBuilder.inboxScreen() {
  composable(route = "inbox") {
    InboxScreen(...)
  }
}

fun NavController.navigateToInbox() {
  navigate(route = "inbox")
}
// EmailNav.kt file inside ui:email module

fun NavGraphBuilder.emailScreen() {
  composable(route = "email/{emailId}") {
    EmailScreen(...)
  }
}

fun NavController.navigateToEmail(emailId: String) {
  navigate(route = "email/$emailId")
}

This way, not only did we hide and encapsulate the navigation details, but we also made the parameters type-safe. Now our Main Nav.kt would look like:

// MainNav.kt file inside app module

@Composable
fun MainNav(navController: NavHostController) {
  NavHost(...) { // this: NavGraphBuilder
    inboxScreen(
      onEmailClick = { emailId -> navController.navigateToEmail(emailId) }
    )
    emailScreen()
  }
}

OUR VIDEO RECOMMENDATION

, ,

Migrating to Jetpack Compose – an interop love story

Most of you are familiar with Jetpack Compose and its benefits. If you’re able to start anew and create a Compose-only app, you’re on the right track. But this talk might not be for you…
Watch Video

Migrating to Jetpack Compose - an interop love story

Simona Milanovic
Android DevRel Engineer for Jetpack Compose
Google

Migrating to Jetpack Compose - an interop love story

Simona Milanovic
Android DevRel Engin ...
Google

Migrating to Jetpack Compose - an interop love story

Simona Milanovic
Android DevRel Engineer f ...
Google

Jobs

No results found.

Dependency Injection and ViewModels

The only missing part is to inject our ViewModel. So, let’s get back to our EmailNav.kt file. By injecting the ViewModel here, events like onArchiveClick can be handled here, and other events, like navigation events, can be passed up:

// EmailNav.kt file inside ui:email module

fun NavGraphBuilder.emailScreen() {
  composable(route = "email/{emailId}") {

    val viewModel: EmailViewModel = hiltViewModel()
    viewModel.fetchEmail(...)
    val email by viewModel.stateFlow.collectAsState()

    EmailScreen(
      email = email,
      onArchiveClick = { viewModel.onArchive(email.id) }
    )
  }
}

fun NavController.navigateToEmail(emailId: String) {
  navigate(route = "email/$emailId")
}

The last issue we should fix is to move emailId into the ViewModel‘s constructor so we don’t have to fetch it using fetchEmail() on each recomposition. This is where SavedStateHandle comes in to help. We can move back to our EmailNav.kt to get the email ID in a type-safe manner:

// EmailNav.kt file inside ui:email module

fun NavGraphBuilder.emailScreen() {
  ...
}

fun NavController.navigateToEmail(emailId: String) {
  ...
}

internal val SavedStateHandle.emailId: String
  get() = checkNotNull(this["emailId"])

And in our ViewModel:

// EmailViewModel.kt file inside ui:email module

@HiltViewModel
class EmailViewModel @Inject constructor(
  savedStateHandle: SavedStateHandle,
  ...
) {

  private val emailId: String = savedStateHandle.emailId

  init {
    // fetch email based on emailId and update the stateFlow
  }
}

And that’s it!… I know, I know! I may not have been fully honest about this being type-safe. The Compose compiler does not offer full type safety yet. But at least this way we are doing all the risky operations in one place (i.e., routing and parameter parsing), which lowers the risk of producing weird bugs.

TL;DR

The Jetpack libraries have great compatibility with each other, which makes integrating them easy! And with them comes a good amount of best practices we can follow to have easier lives.
You can check out my half-baked project on Gitlab, where I try to follow these best practices.

This article is previously published on proandroiddev.com

YOU MAY BE INTERESTED IN

YOU MAY BE INTERESTED IN

blog
It’s one of the common UX across apps to provide swipe to dismiss so…
READ MORE
blog
In this part of our series on introducing Jetpack Compose into an existing project,…
READ MORE
blog
In the world of Jetpack Compose, where designing reusable and customizable UI components is…
READ MORE
blog

How to animate BottomSheet content using Jetpack Compose

Early this year I started a new pet project for listening to random radio…
READ MORE

Leave a Reply

Your email address will not be published. Required fields are marked *

Fill out this field
Fill out this field
Please enter a valid email address.

Menu