Внедрение библиотеки навигации Modo в многомодульный Compose проект

В данной статье вы ознакомитесь с довольно простой навигацией для Android.

В статье рассказывается про применение библиотеки в многомодульном проекте. Если вы хотите узнать как работает навигация этой библиотеки, то подробнее можно узнать в репозитории.

Немного про UDF

Для понимания как работает библиотека ознакомимся с UDF.

UDF состоит из следующих частей

  • State — источник правды или текущее состояние приложения в определенный момент времени

  • View — UI отрисованный на основе State

  • Action — события в приложении, которые меняют State

UDF

UDF

Получается UDF, это когда Action меняет State, а View меняется на основе State.

Более подробно про UDF можно прочитать здесь.

Знакомство с библиотекой

Modo — это библиотека навигации для Android, которая основана на принципах UDF. 

В данной библиотеке список экранов хранится как List в State

data class StackState(
   val stack: List = emptyList(),
)

И есть экшены:

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
...

На это моменте можно уже понять раз библиотека основана на UDF, то Action меняет State, а State меняет состояние экранов.

Создаем навигацию

Часто в многомодульных проектах нужно получить определенную навигацию. Например, получить либо корневую навигацию, либо получить навигацию фичи.

Поэтому для упрощения получения зависимостей навигации сначала продублируем экшены Modo

interface NavigationAction


data class NavigationForward(val screen: Screen, val screens: List = emptyList()) :
   NavigationAction


data class NavigationReplace(val screen: Screen, val screens: List = emptyList()) :
   NavigationAction

...

Затем создадим маппер, чтобы переводить наши экшены в экшены Modo.

fun NavigationContainer.navigate(command: NavigationCommand) {
   val action = when (command) {
       is NavigationSetStack -> SetStack(StackState(stack = command.screens))
       is NavigationForward -> Forward(command.screen, *command.screens.toTypedArray())
       ...

После создадим класс навигации, где мы отправляем наши экшены с помощью метода navigate(command: NavigationCommand) и слушаем наши экшены с помощью commandsFlow.

class Navigation {

   private val _commandsFlow = Channel()


   val commandsFlow: Flow = _commandsFlow.receiveAsFlow()


   suspend fun navigate(command: NavigationCommand) {
       _commandsFlow.send(command)
   }
}

На этом приготовления для работы с навигацией заканчиваются.

Синхронизация навигации с экраном

Теперь начнем работать с библиотекой Dagger.

Создадим в даггер-модуле нашу навигацию.

@Module
internal class FeatureModule {

   @Provides
   @Singleton
   @FeatureNavigationQualifier
   fun provideNavigation(): Navigation = Navigation()
}

Создаем интерфейс и прикрепим его к даггер-компоненте, чтобы получить доступ к нашей навигации.

interface FeatureDependenciesProvider {


   @FeatureNavigationQualifier
   fun navigation(): Navigation
}


internal interface FeatureComponent : FeatureDependenciesProvider

Затем создадим CompositionLocal с помощью которого мы будем получать доступ к навигации через интерфейс

val LocalFeatureDependenciesProvider = compositionLocalOf {
   error("FeatureDependenciesProvider not found")
}

Добавим навигацию в ViewModel и создадим слушатель экшенов navigationCommands, где в UI эти экшены уже будут мапиться в Modo-экшены

internal class FeatureViewModel @Inject constructor(
   @FeatureNavigationQualifier private val navigation: Navigation,
) : ViewModel() {


   val navigationCommands: Flow = rootNavigation.commandsFlow
}

В UI будем использовать класс StackScreen из Modo, который рендерит последний экран из stack с помощью метода 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()	
       }
   }
}

где, этот кусок кода отвечает за маппинг нашего экшена в modo экшен

LaunchedEffect(Unit) {
  viewModel.navigationCommands.collectLatest { command ->
    navigate(command)
  }
}

а этот кусок кода отвечает за рендеринг последнего экрана из stack c помощью метода TopScreenContent() и обеспечения зависимостями даггер-компоненты c помощью FeatureDependenciesProvider

CompositionLocalProvider(
  LocalFeatureDependenciesProvider provides componentHolder.component as FeatureDependenciesProvider
) {
    TopScreenContent()	
  }

Осталось применить вышеперечисленный код для рутовой и фича навигации. Отличий в внедрении для рутового и фича навигации нет. Разве что нужно будет писать разные провайдеры зависимостей и разные Qualifier для каждой из навигации.

Используем в действии

Написание кода для разных навигаций не будет иметь никаких отличий. Даже root и feature навигации будут работать одинаково. Этот код всегда будет выглядеть так.

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 = navigation.commandsFlow

  fun onSomeActionHappened() {
    navigation.navigate(NavigationReplace(screens.someScreen()))
  }
}

Представим более сложный проект, который состоит из двух основных фичей Complex и Simple, где фича Complex содержит внутри себя sub-feature. А Фича Simple представляет из себя простой экран.

Где будут следующие навигации:

  1. Root Navigation — навигация основных фичей

  2. Feature Navigation — навигация фичей Complex

9502e80d51b5f4ad53faebed8d347b7d.png

Если в таком проекте нужно из фичи Complex открыть фичу Simple, то в нашем ViewModel будет два класса навигации:

  • Root Navigation — навигация, которая находится на уровень выше нашего экрана.

  • Feature Navigation — навигация фичи Complex

Получается, когда отправится экшен открытия фичи Simple, то будет работать рутовая навигация, а не фичовая навигация.

internal class ComplexViewModel @Inject constructor(
   @RootNavigationQualifier private val rootNavigation: Navigation,
   @FeatureNavigationQualifier private val complex: Navigation,
   private val rootScreens: RootScreens,
) : ViewModel() {

  val navigationCommands: Flow = featureNavigation.commandsFlow

  fun onSomeActionHappened() {
    rootNavigation.navigate(NavigationReplace(rootScreens.simpleScreen()))
  }
}

Заключение

abdccfb9010c6497f240f347c90a7d8d.gif

В данной статье мы рассмотрели один из вариантов внедрения библиотеки Modo в многомодульном проекте. Можно дальше продолжать изменять описанную навигацию снижая зависимость от библиотеки или можно упростить работая напрямую с Modo, не создавая свои классы. Также вы можете более подробно ознакомиться с примером рассмотренным в статье.

© Habrahabr.ru