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 time

  • View – UI drawn based on State

  • Action – events in the application that change State

UDF

UDF

It turns out UDF, this is when Action changes StateA 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 StateA 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 navigationCommandswhere 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 Simplewhere is the feature Complex contains a sub-feature within itself. A Feature Simple is a simple screen.

Where will the following navigation be:

  1. Root Navigation – navigation of main features

  2. Feature Navigation – navigation of Complex features

If in such a project you need from a feature Complex open feature Simplethen 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 Simplethen 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..

Similar Posts

Leave a Reply

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