Implementation of the Modo navigation library in a multi-module Compose project
In this article you will learn about fairly simple navigation for Android.
The article describes the use of the library in a multi-module project. If you want to know how the navigation of this library works, then you can find out more find out in the repository.
A little about UDF
To understand how the library works, let's take a look at UDF.
The UDF consists of the following parts
State
– the source of truth or the current state of the application at a certain point in timeView
– UI drawn based onState
Action
– events in the application that changeState
It turns out UDF, this is when Action
changes State
A View
changes based on State
.
You can learn more about UDF read here.
Getting to know the library
Modo is a navigation library for Android that is based on UDF principles.
In this library, the list of screens is stored as List
V State
data class StackState(
val stack: List<Screen> = emptyList(),
)
And there are actions:
class Forward(val screen: Screen, vararg val screens: Screen) : NavigationAction
class Replace(val screen: Screen, vararg val screens: Screen) : NavigationAction
class NewStack(val screen: Screen, vararg val screens: Screen) : NavigationAction
class BackTo(val screen: Screen) : NavigationAction
...
At this point you can already understand that since the library is based on UDF, then Action
changes State
A State
changes the state of the screens.
Creating navigation
Often in multi-module projects you need to get specific navigation. For example, get either root navigation or get feature navigation.
Therefore, to simplify obtaining navigation dependencies, we will first duplicate the Modo actions
interface NavigationAction
data class NavigationForward(val screen: Screen, val screens: List<Screen> = emptyList()) :
NavigationAction
data class NavigationReplace(val screen: Screen, val screens: List<Screen> = emptyList()) :
NavigationAction
...
Next, we'll create a mapper to translate our actions into Modo actions.
fun NavigationContainer<StackState>.navigate(command: NavigationCommand) {
val action = when (command) {
is NavigationSetStack -> SetStack(StackState(stack = command.screens))
is NavigationForward -> Forward(command.screen, *command.screens.toTypedArray())
...
Then we’ll create a navigation class where we send our actions using the method navigate(command: NavigationCommand)
and listen to our actions with commandsFlow
.
class Navigation {
private val _commandsFlow = Channel<NavigationCommand>()
val commandsFlow: Flow<NavigationCommand> = _commandsFlow.receiveAsFlow()
suspend fun navigate(command: NavigationCommand) {
_commandsFlow.send(command)
}
}
This completes the preparations for working with navigation.
Synchronizing navigation with the screen
Now let's start working with the Dagger library.
Let's create our navigation in the dagger module.
@Module
internal class FeatureModule {
@Provides
@Singleton
@FeatureNavigationQualifier
fun provideNavigation(): Navigation = Navigation()
}
Let's create an interface and attach it to the dagger component to access our navigation.
interface FeatureDependenciesProvider {
@FeatureNavigationQualifier
fun navigation(): Navigation
}
internal interface FeatureComponent : FeatureDependenciesProvider
Then let's create CompositionLocal
with which we will access navigation through the interface
val LocalFeatureDependenciesProvider = compositionLocalOf<FeatureDependenciesProvider> {
error("FeatureDependenciesProvider not found")
}
Let's add navigation to ViewModel
and create an action listener navigationCommands
where in the UI these actions will already be mapped into Modo actions
internal class FeatureViewModel @Inject constructor(
@FeatureNavigationQualifier private val navigation: Navigation,
) : ViewModel() {
val navigationCommands: Flow<NavigationCommand> = rootNavigation.commandsFlow
}
In the UI we will use the class StackScreen
from Modo, which renders the last screen from the stack using the method TopScreenContent()
@Parcelize
class FeatureStackScreen(
private val navigationModel: StackNavModel,
) : StackScreen(navigationModel = navigationModel) {
@Composable
override fun Content() {
val componentHolder = daggerViewModel {
ComponentHolder(DaggerFeatureComponent.builder().build())
}
val viewModel = daggerViewModel { componentHolder.component.viewModel() }
LaunchedEffect(Unit) {
viewModel.navigationCommands.collectLatest { command ->
navigate(command)
}
}
CompositionLocalProvider(
LocalFeatureDependenciesProvider provides componentHolder.component as FeatureDependenciesProvider
) {
TopScreenContent()
}
}
}
where, this piece of code is responsible for mapping our action in modo action
LaunchedEffect(Unit) {
viewModel.navigationCommands.collectLatest { command ->
navigate(command)
}
}
and this piece of code is responsible for rendering the last screen from the stack using the method TopScreenContent()
and providing dependencies to dagger components using FeatureDependenciesProvider
CompositionLocalProvider(
LocalFeatureDependenciesProvider provides componentHolder.component as FeatureDependenciesProvider
) {
TopScreenContent()
}
It remains to apply the above code for the root and navigation features. There are no differences in implementation for the root and navigation features. Unless you will need to write different dependency providers and different Qualifiers for each navigation.
We use it in action
Writing code for different navigations will not have any differences. Even root and feature navigation will work the same. This code will always look like this.
internal class FeatureViewModel @Inject constructor(
@FeatureNavigationQualifier private val navigation: Navigation,
private val screens: Screens,
) : ViewModel() {
init {
viewModelScope.launch {
rootNavigation.navigate(NavigationReplace(screens.someScreen()))
}
}
val navigationCommands: Flow<NavigationCommand> = navigation.commandsFlow
fun onSomeActionHappened() {
navigation.navigate(NavigationReplace(screens.someScreen()))
}
}
Let's imagine a more complex project that consists of two main features Complex
And Simple
where is the feature Complex
contains a sub-feature within itself. A Feature Simple
is a simple screen.
Where will the following navigation be:
Root Navigation – navigation of main features
Feature Navigation – navigation of Complex features
If in such a project you need from a feature Complex
open feature Simple
then in our ViewModel
there will be two navigation classes:
Root Navigation – navigation that is located one level above our screen.
Feature Navigation – feature navigation
Complex
It turns out when the feature opening action is sent Simple
then root navigation will work, not feature navigation.
internal class ComplexViewModel @Inject constructor(
@RootNavigationQualifier private val rootNavigation: Navigation,
@FeatureNavigationQualifier private val complex: Navigation,
private val rootScreens: RootScreens,
) : ViewModel() {
val navigationCommands: Flow<NavigationCommand> = featureNavigation.commandsFlow
fun onSomeActionHappened() {
rootNavigation.navigate(NavigationReplace(rootScreens.simpleScreen()))
}
}
Conclusion
In this article, we looked at one of the options for implementing the Modo library in a multi-module project. You can continue to change the described navigation, reducing the dependence on the library, or you can simplify it by working directly with Modo, without creating your own classes. You can also familiarize yourself with the example discussed in the article in more detail..