Android arch exploration: Compose navigation but ViewModel dictates where to go

Juan Mengual
adidoescode
Published in
17 min readMay 27, 2022

Recently we had to go back to one of the oldest (and darkest) parts of our codebase, the login, and make Single Sign On happen. Our login had a few design mistakes from the start (Oh man, we have learned a lot during the years) and tons of devs have work their way into it to add, sometimes with more grace than others, their requirements. Our login UI is specially complex because it has a lot of customization (different login options, extra steps…) depending on the country selected by the user, so the result is a code with tons of side effects, difficult to understand and where any new change might carry unforeseen consequences. The refactor drums already sound in the background, so I have started my particular investigation.

A lot of triangle shaped mirrors together reflecting the view.
A look to our current login code (also known as the Mirror Dimension) where an overwhelmed android dev sits taking a break (Photo by Erik Eastman on Unsplash)

The first step in the investigation for the new login is gonna be the navigation between screens (or steps) and since we are developing all our new screens in Jetpack Compose we are gonna explore Compose navigation library. There is much more to cover in our way to have a highly maintainable login flow, so the whole process might become a series of articles with the full refactor, who knows.

Small disclaimer: this post assumes you are already familiar with Jetpack Compose, ViewModels, Flow and Navigation component. There are great documentation to learn about those specific topics.

Preconditions: Compose navigation + ViewModel

To get things started, let’s take a look to what we want to build. We are going to create a new project with a few different screens, one per each login step (Selecting between social or email, entering email, entering password…).

A pretty basic login flow

The auto-imposed technical requirements are:

  • Use Compose and Compose navigation
  • ViewModel stores business logic and is the responsible from deciding which is the next step to navigate to depending on the info submitted by the user. This will be important for future iterations when we’ll want to inject different logic per country. Quick disclaimer, we keep business logic in the ViewModel for simplicity.
  • One single Flow of data is exposed by the ViewModel with all the information needed for Compose to draw the correct UI and for Compose navigation to navigate to the next step.
  • Unidirectional data flow: User does something (UI) -> ViewModel is notified -> ViewModel emits new step to navigate to -> Compose navigation does the navigation to the step composable
  • There will be different kind of screens: selection (user clicks a button, like selecting login with email/password), input with validation (like entering an email) and input with network request. Some of these screens might have error and loading states.

There are some things that we are going to left out for now, like missing layers in the architecture as UseCases and DataSources and our Repository will be mocking data.

Show me the code

Before getting into details, you can check the whole code in the following branch of the repo:

The initial (partially failed) plan

In my ideal world the ViewModel exposes a single flow which has everything that the UI needs to display the current step of the login. This “everything” is:

  • Which step screen should be displayed
  • Any data required to be displayed inside that step screen
  • State of the current step (success/loading/error)

When our flow emits something new, compose would recompose only the part of the screen that has changed.

Continuing with the unidirectional data flow, whenever the user interacts with a screen, this is notified to the ViewModel, which will execute its business logic and produce an emission through the flow. This emission might just change the current screen to display loading state or it might produce the navigation to a new screen of another step of the login. Something like the following:

A look to the flow of data
  1. ViewModel emits the step that needs to be displayed to the user
  2. Compose navigation takes care of navigating to that step composable
  3. User interacts with the step composable which ends up calling a method in the ViewModel with the data introduced by the user (could be pressing a button or entering and email in a TextInput)
  4. ViewModel executes logic to decide what is the next step based on user input
  5. A new step is emitted through the flow, leaving us to the beginning of the unidirectional data flow again

The plan will encounter a big drawback: Compose navigation has some limitations on how to pass parameters to the destination composables. At the moment of this writing, only url like parameters (string, numbers) can be passed without going for dirty tricks. We will look for an alternative way to pass the data from our uiState flow to the destination composables, but more on that later.

We will also encounter another issue related with the back navigation, but again more on that later. Now let’s take a look to the main actors of the solution by layer.

Data layer: LoginRepository

Just for transparency, this is our mocked repo. We don’t need anything else for today.

class LoginRepository {     /**
* Launches login call and returns created userId if success
* @return userId if the login operation worked or exception
*/
suspend fun login(userEmail: String, password: String): String {
// Simulate call
delay(2000)
return Random.nextInt().toString()
}
}

Domain layer

This post is focusing on the connection between ViewModel and UI, so to keep things simple, we put all our business logic in the ViewModel. This logic is about validation (check if an email is valid), calling repository to perform a login request and deciding where to navigate depending on the data entered by the user. To expose data to the UI we will be using a few data classes:

Outcome is the classic wrapper of state, useful to communicate if the screen is in normal, error or loading state.

sealed class Outcome<T>(open val data: T) {
data class Loading<T>(override val data: T) : Outcome<T>(data)
data class Success<T>(override val data: T) : Outcome<T>(data)
data class Error<T>(override val data: T, val ex: Throwable) : Outcome<T>(data)
}

Each of the screen is represented by a sealed class Step:

sealed class Step(val id: NavId) {
object Start : Step(NavId.START)
class LoginOptionsStep(val options: List<String>) : Step(NavId.OPTIONS)
class EnterEmailStep(val enteredEmail: String?) : Step(NavId.EMAIL)
class EnterPassword : Step(NavId.PASSWORD)
object End : Step(NavId.END)

enum class NavId {
START, OPTIONS, EMAIL, PASSWORD, END
}
}

Every Step needs an id, which will be used by Compose navigation as a route. Also each Step contains all the data needed to display the proper UI. I.e: Login Options screen should display a list of options to select from. This list is decided by the ViewModel and passed to the UI inside LoginOptionsStep. By following this approach, the ViewModel will have control to provide different options depending on the country we are in (or any other logic).

We will be using this in future improvements (in another article).

MainViewModel, the one who decides where to navigate

Our ViewModel has several duties:

  • Expose a single flow of UI state to the UI, containing everything that's needed to render the UI
  • Store the data provided by the user in the steps. Every time the user provides new data, the next step to be displayed will be decided depending on the data we have.
  • User data should survive app recreation, so user data is saved into savedState.

Let’s take a look on the ViewModel more closely.

LoginViewModel has three internal private flows, each with a very simple purpose:

  • mutableActionState stores the current state of the UI (if its in loading, error or success)
  • mutableFilledData holds all the data provided by the user.
  • mutableCurrentStep holds the step we are in
class LoginViewModel(
private val loginRepo: LoginRepository,
private val savedStateHandle: SavedStateHandle
) : ViewModel() {

/**
* Holds the current status of a login action
*/
private val mutableActionState: MutableStateFlow<Outcome<Unit>> = MutableStateFlow(Outcome.Success(Unit))

/**
* Holds the data filled by the user, which can be used by the strategy to decide what is the next step.
*/
private val mutableFilledData = MutableSharedFlow<FilledData>(
replay = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST
)
/**
* Holds the step the user is currently in. Emitting new step means that we need to navigate to that Step screen
*/
private val mutableCurrentStep = MutableSharedFlow<Step>(
replay = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST
)
...}

Every time new data is emitted into mutableFilledData, we want to produce a new state into mutableCurrentStep. For that, we have the following logic in the init block of the ViewModel:

init {
// Start the emission, currentFilledData will get data from saved State if there are any, so recreation is handled
mutableFilledData.tryEmit(currentFilledData)

viewModelScope.launch {
mutableFilledData.collectLatest { data ->
// Store in saved state to survive recreation
savedInstanceData = data
// decide next step based on data introduced by the user
val nextStep = onDataProvided(data)
mutableCurrentStep.emit(nextStep)
}
}
}

When mutableFilledData emits something new, onDataProvided() is called to decide which is the next step we should navigate to and this one is emitted into mutableCurrentStep. Our onDataProvided is pretty simple for this example and just checks which data is missing and returns the Step needed for that data to be provided. In future iterations we’ll put something fancier and customizable per country.

private fun onDataProvided(data: FilledData) : Step {
val nextNavId = when {
data.flowType == null -> Step.NavId.OPTIONS
data.userEmail == null -> Step.NavId.EMAIL
data.userPassword == null ||
data.userId == null -> Step.NavId.PASSWORD
else -> {
// userId != null means our login request worked
Step.NavId.END
}
}

return generateStep(nextNavId, data)
}

We have now our step in mutableCurrentStep, but we still need to merge mutableActionState and mutableCurrentStep to expose one single flow to the UI, we use combine for that:

/**
* One single flow expose ui state, represented by [Outcome] (success, loading, error) and the [Step] that should be displayed in the UI
*/
val uiState: Flow<Outcome<Step>> = combine(mutableActionState, mutableCurrentStep) { actionStatus: Outcome<Unit>, step: Step ->
// Use Outcome from action flow but with the data from step flow. I prefer to expose one single flow to the UI
actionStatus.map { step }
}
.shareIn(viewModelScope, SharingStarted.WhileSubscribed(), replay = 1)

Now we have a single flow exposing everything, and every time mutableFilledData receives new data or mutableActionState changes, the combine will emit something new into uiState. To understand how this works, lets check the step where the user enters its email as an example:

fun onEmailEntered(email: String) {
viewModelScope.launch {
// Do validation (business logic)
if (email.contains('@')){
val updatedData = currentFilledData.copy(userEmail = email)
mutableFilledData.emit(updatedData)
mutableActionState.emit(Outcome.Success(Unit))
} else {
mutableActionState.emit(Outcome.Error(ex = InvalidEmailException(), data = Unit))
}
}
}

This method is called from the UI when the user has entered an email and pressed the “continue” button.

  1. First we do some simple validation, if it doesn’t pass, we will emit an error into mutableActionState, which will arrive to uiState and will expose the same Step we are in but with Outcome.Failure.
  2. If the validation does pass, then we will emit success into mutableActionState but also emit the new data into mutableFilledData, which will end up calling onDataProvided and emitting the new step we should navigate to into mutableCurrentStep.
  3. Both the new Step and the Outome.Success from mutableActionState, will be combined and emitted to the UI in uiState.

A few things are worth commenting. We’ve created an Outcome.map{} extension to change the data field to another, but keeping the state (success, loading or error). This is syntax sugar and can be checked in the repo.

We use SharedFlow instead of StateFlow because the last does a distinctUntilChange() which skips the emission of repeated values, something we don’t want (related with the user and back navigation). Also this back navigation is the reason to keep mutableFilledData and mutableCurrentStep as separated mutable flows, more on that at the end of the article.

This completes our look to the ViewModel, now is the time to see how Compose uses the exposed data to draw the UI and navigate to one composable or another.

A closer look to our uiState flow, where a new pair of Outcome and Step are being emitted to the UI. Good luck in the UI layer fellas! (Photo by Werner Du plessis on Unsplash)

UI Layer

Main Activity

Starting with the easy part, this will be just our canvas where we get our ViewModel and connect it with Compose. Here we get the navController, used by navigation-compose and the ViewModel to pass them to our LoginScreen composable.

class MainActivity : ComponentActivity() {

private val viewModel: LoginViewModel by viewModels { MainViewModelFactory(this) }

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
LoginExplorationTheme {
val navController = rememberNavController()
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colors.background) {
LoginScreen(viewModel, navController)
}
}
}
}
}

LoginScreen composable, defining the navigation graph and listening ViewModel

Navigation compose defines the navigation graph in a way very easy to understand. Every destination (a screen) is defined by a route (the path to that screen) and the composable that needs to be drawn when navigating to that route.

This is a simplified version of the code you’ll find in the project, but we will be complicating it pretty soon.

@Composable
private fun LoginScreen(viewModel: LoginViewModel, navController: NavHostController) {
NavHost(
navController = navController,
startDestination = Step.NavId.OPTIONS.name
) {
composable(Step.NavId.OPTIONS.name) {
OptionsOverviewScreen(onOptionSelected = viewModel::onOptionSelected)
}
composable(Step.NavId.EMAIL.name) {
EnterEmailScreen(onEmailEntered = viewModel::onEmailEntered)
}
composable(Step.NavId.PASSWORD.name) {
EnterPasswordScreen(onPasswordEntered = viewModel::onPasswordEntered)
}
composable(Step.NavId.END.name) {
Text(
modifier = Modifier.padding(32.dp),
text = "Login Successful"
)
}
}
...
}

A couple of things to be noted here:

  • Step.NavId is an enum with all the possible destinations. We use Step.NavId.name as the route for each destination.
  • Instead of passing the ViewModel to each composable, we pass the ViewModel method to be called. We want the composables to be as clean as possible and we also want to have some previews.

Now we need to use the ViewModel’s public flow to navigate to a screen with new emissions. LoginScreen needs the step we should navigate to, so it can load the corresponding composable for that step. Our uiState flow exposes more than that, so we use map{} to get what we want and pass it to LoginStep composable, which will take care of using navController to navigate to the corresponding composable.

@Composable
private fun LoginScreen(viewModel: LoginViewModel, navController: NavHostController) {
...
// We want a flow which only cares about the step we should navigate to
val stepFlow = viewModel.uiState.map { outcome ->
outcome.data
}

LoginStep(stepFlow = stepFlow, navController = navController)

LoginStep, using navController to navigate

This composable has some generic Compose logic to convert flows into a state which can be used in the composable. When that state change, navController is used to navigate to the required screen

@Composable
fun LoginStep(stepFlow: Flow<Step>, navController: NavHostController) {
val lifecycleOwner = LocalLifecycleOwner.current
val stepFlowLifecycleAware = remember(stepFlow, lifecycleOwner) {
stepFlow.flowWithLifecycle(lifecycleOwner.lifecycle, Lifecycle.State.STARTED)
}

val stepState by stepFlowLifecycleAware.collectAsState(Step.Start)

when (val step = stepState) {
Step.Start -> { /* only here because we need an start destination */}
is Step.LoginOptionsStep -> navigateToOptions(navController)
is Step.EnterEmailStep -> navigateToEmailStep(navController)
is Step.EnterPassword -> navigateToPasswordSep(navController)
Step.End -> { navigateToEnd(navController) }
}
}

We’ll add some navigateToXXX() methods to wrap using the navController with different options (like launching singleTop, something we want for our login screens, or popping the back stack when reaching certain destination, so the user cannon go back.

private fun navigateToEmailStep(navController: NavHostController) {
navController.navigate(Step.NavId.EMAIL.name) {
launchSingleTop = true
}
}
private fun navigateToEnd(navController: NavHostController) {
navController.navigate(Step.NavId.END.name){
// Remove previous screens from the nav stack, so cannot go there pressing back
popUpTo(Step.NavId.OPTIONS.name) {
inclusive = true
}
launchSingleTop = true
}
}

Step composables

Each screen is represented by a composable. We mentioned that our goal is to pass to them every data needed for it to be rendered. As an example we’ll take a look to the enter email screen composable. This screen displays a TextInput to enter the email and a button to continue. Email is validated in the ViewModel, so it can have Loading state and Error state. This composable receives all the data needed for that screen in it’s parameters:

  • Outcome indicates if the screen is in normal, loading or error state
  • Step.EnterEmailStep have an email field, which we will use to pre-fill the TextInput.
@Composable
fun EnterEmailScreenContent(uiState: Outcome<Step.EnterEmailStep>, onEmailEntered: (email: String) -> Unit) {
var text by rememberSaveable {
mutableStateOf(uiState.data.enteredEmail.orEmpty())
}

Column {
TextField(
value = text,
onValueChange = { text = it },
label = { Text("Email:") }
)
when (val o = uiState){
is Outcome.Error -> {
ErrorLabel(o.ex.toString())
}
is Outcome.Loading -> {
Text(text = "loading...")
}
is Outcome.Success -> { /* display nothing */ }
}
Button(onClick = { onEmailEntered.invoke(text) }) {
Text(text = "Continue")
}
}
}

When pressing the button, we’ll call the onEmailEntered lambda, which in the end will call a ViewModel’s method, producing a new emission on the flow.
Imagine that the user entered an invalid email, the ViewModel will emit a Outcome.Error(Step.EnterEmailStep()), and this composable will be recomposed, displaying the error label. Now imagine that the user entered a correct email, in this case the ViewModel will emit the next step to go into, the navController will remove this composable and display the one for the next step.

Wait, how do we pass the parameter from the uiState flow to the screen composables?

Maybe you spotted that we are missing how the required data is passed to a Step composable. Our EnterEmailScreenContent receives an Outcome<Step.EnterEmailStep> input parameter but we are not passing that in any place. Navigation compose only supports passing url like parameters to the composables, which is for sure a big limitation. There are some hacks that can be done, but since our ViewModel is already exposing the data we need to the UI layer, we just need a way to make it accessible to the composables for each Step.

We are gonna iterate our LoginScreen composable to pass the data we want to our composables.

@Composable
private fun LoginScreen(viewModel: LoginViewModel, navController: NavHostController) {
...
NavHost(...) {

composable(Step.NavId.EMAIL.name) {
// Get the flow with the data required for this Composable
val enterEmailFlow = viewModel.uiState.filterStep<Step.EnterEmailStep>()
EnterEmailScreen(enterEmailFlow, onEmailEntered = viewModel::onEmailEntered)
}
...
}
...

}

We are using filterStep(), which is our newly created extension function which filters the contents of our flow to be the ones expected for that Step

/**
* Sugar for this filter that is repeated for every screen.
* We decided to crash if the cast fails, since it's more likely a problem that will be detected in development and should not happen in prod.
* Still, having this kind of "trust" behavior is one of the drawbacks of the approach
*/
private inline fun <reified S>Flow<Outcome<Step>>.filterStep(): Flow<Outcome<S>> {
return filter { it.data is S } as Flow<Outcome<S>>
}

Now let’s take a look to the composable that receives those two params, which just takes care of converting that flow to state so the composable is recomposed when the flow changes.

@Composable
fun EnterEmailScreen(dataFlow: Flow<Outcome<Step.EnterEmailStep>>, onEmailEntered: (email: String) -> Unit) {
val lifecycleOwner = LocalLifecycleOwner.current
val dataFlowLifecycleAware = remember(dataFlow, lifecycleOwner) {
dataFlow.flowWithLifecycle(lifecycleOwner.lifecycle, Lifecycle.State.STARTED)
}

val uiState by dataFlowLifecycleAware.collectAsState(Outcome.Success(Step.EnterEmailStep("")))
EnterEmailScreenContent(uiState = uiState, onEmailEntered = onEmailEntered)
}

EnterEmailScreenContent is the composable we have taken a look a few blocks above. Now, we have the data we want into the composable, provided from one single flow exposed by the ViewModel. When the uiState flow changes the Step, navigation compose will take care of displaying the composable for that Step. On the other hand, our step composable uses the uiState flow to get the data which is important for it, and when that info changes (i.e: goes from Outcome.Success to Outcome.Error) it will be recomposed.

Almost done, solving an issue with back navigation

If you run the sample app at this point and start navigating to some screens and then going back several times, there is a moment when it stops working. Do you remember the filterStep() method we added to get the data needed for each step? Well, it was getting the content of uiState flow and filtering for the step we want to display. When pressing back, Navigation controller loads the previous step composable, but the uiState flow still have the value for the last step we navigated into. When filterStep does the filter() logic to our flow content, since it’s not the same step we are filtering for, it won’t return anything.

To solve it, we are gonna use a little trick to inform the ViewModel when the user has navigated back to a previous Step, so the uiState flow can be matched to be emitting that step. This way, we will be able to emit our next step and not have the recomposition issue.

Our savior is the addDestinationListener() from navController.

@Composable
private fun LoginScreen(viewModel: LoginViewModel, navController: NavHostController) {

// We need to get our step flow back on sync with what is visible on screen, this will help
navController.addOnDestinationChangedListener { controller, destination, _ ->
destination.route?.let { navigatedRoute ->
val navId = Step.NavId.valueOf(navigatedRoute)
viewModel.onDestinationChanged(navId)
}
}
...
}

Then the method in our ViewModel will looks like this:

/**
* Notify ViewModel that destination has changed so we can detect back navigation and adjust uiState accordingly
*/
fun onDestinationChanged(navId: Step.NavId) {
viewModelScope.launch {
val currentStep = mutableCurrentStep.first()
// When the user navigates back, our step in uiState flow won't match with the step visible to the user. This match is needed in order for the
// screen being able to get the screen data (due to the filter being done).
if (currentStep.id != navId) {
mutableCurrentStep.tryEmit(generateStep(navId = navId, currentFilledData))
}
}
}

Now our uiState flow will always match the step we are into and filterStep() will be able to cast the data for the specific step. That’s the reason we defined mutableCurrentStep as a separated flow, so we could use it for this particular case. This is for sure one of the drawbacks of the solution, since our choices of a library in the UI are forcing us to change our internal structure of the ViewModel. To some mental relief, ViewModel is considered part of the UI layer, so some minimal coupling could be tolerated.

This marks the end of our implementation, let’s go with the thoughts.

Final thoughts on the solution

The purpose of this investigation was to try to connect Compose navigation to a ViewModel through a single flow which emits where the user should navigate and the data is needed at that destination. That is accomplished, but not in the straightforward way I’d have expected. The main pain points in my opinion are:

  • SharedFlow instead of StateFlow. We need to avoid the distinctOverChange() behavior which StateFlow does in order to every Step emission to arrive its destination. Not bad, but the UI layer will need to be prepared to not to recompose when getting too many emissions. The good part is that Compose gives us a pretty good control over that.
  • How we pass data to each step composable. We use some sugar which filter and cast to the data class we are expecting. It works but it’s pretty opaque. This comes from our desire of making the ViewModel responsible from deciding where to go and provide all that’s needed in that destination. Compose navigation falls short in the way parameters are passed to destinations.
  • Workaround for back navigation issue. It forced us to have one extra private mutableFlow inside the ViewModel, adding some extra complexity.

Would I use it in production? Tough question. It has potential, but it is not a clear yes from my perspective. I don’t particularly like the way parameters are passed to destinations in Compose navigation, as devs we loose some power there. Although, it forces some good practices as passing ids to the screen and use other mechanisms to retrieve data from our data sources, which is not bad.

Having each screen in a composable felt right and the way saved state is used in the ViewModel is a great way to have every screen protected against recreation.

I liked the challenge and will continue adding more layers on top as we match the full complexity we have in our login, so if you wanna see how it ends, stay tuned. If we were to use it for the refactor, it’s something we have to decide as a team.

Special Thanks

Thanks a lot to Alberto Sanz and Teresa Silvero for their help with the revision of this article and to you for reaching to the end.

The views, thoughts, and opinions expressed in the text belong solely to the author, and do not represent the opinion, strategy or goals of the author’s employer, organization, committee or any other group or individual.

--

--