В данной статье вы ознакомитесь с довольно простой навигацией для 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<Screen> = 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<Screen> = emptyList()) :
   NavigationAction


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

...

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

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

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

class Navigation {

   private val _commandsFlow = Channel<NavigationCommand>()


   val commandsFlow: Flow<NavigationCommand> = _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<FeatureDependenciesProvider> {
   error("FeatureDependenciesProvider not found")
}

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

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


   val navigationCommands: Flow<NavigationCommand> = 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<NavigationCommand> = navigation.commandsFlow

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

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

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

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

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

Если в таком проекте нужно из фичи 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<NavigationCommand> = featureNavigation.commandsFlow

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

Заключение

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