Navigation in the Android application using coordinators

Original author: Hannes Dorfmann
  • Transfer
Over the past few years, we have developed common approaches to creating Android applications. Pure architecture, architectural patterns (MVC, MVP, MVVM, MVI), template “repository” and others. However, there are still no generally accepted approaches to organizing navigation through the application. Today I want to talk to you about the “coordinator” template and its application possibilities in the development of Android applications.
The coordinator pattern is often used in iOS applications and was introduced by Soroush Khanlou in order to simplify navigation through the application. There is an opinion that Sorush’s work is based on the Application Controller approach described in the book of Patterns of Enterprise Application Architecture by Martin Fowler.
The “coordinator” template is designed to solve the following tasks:

  • the fight against the Massive View Controller problem (a problem has already been written on Habré - approx. translator), which often manifests itself with the advent of God-Activity (activations with a lot of responsibilities).
  • separation of navigation logic into a separate entity
  • reuse of application screens (activations / fragments) due to weak connection with navigation logic

But, before starting to get acquainted with the template and try to implement it, let's take a look at the navigation implementations used in Android applications.

The navigation logic is described in the activation / snippet


Since the Android SDK requires Context to open a new activation (or FragmentManager in order to add a fragment to the activation), quite often the navigation logic is described directly in the activation / fragment. Even examples in the Android SDK documentation use this approach.

classShoppingCartActivity : Activity() {  
  overridefunonCreate(b : Bundle?){
    super.onCreate(b)
    setContentView(R.layout.activity_shopping_cart)
    val checkoutButton = findViewById(R.id.checkoutButton)
    checkoutButton.setOnClickListener {
      val intent = Intent(this, CheckoutActivity::class.java)
      startActivity(intent)
    }
  }
}

In the example above, navigation is closely related to activations. Is it convenient to test this code? It might be argued that we can select navigation in a separate entity and name it, for example, Navigator, which can be embedded. Let's get a look:

classShoppingCartActivity : Activity() {
  @Injectlateinitvar navigator : Navigator  
  overridefunonCreate(b : Bundle?){
    super.onCreate(b)
    setContentView(R.layout.activity_shopping_cart)
    val checkoutButton = findViewById(R.id.checkoutButton)
    checkoutButton.setOnClickListener {
      navigator.showCheckout(this)
    }
  }
}
classNavigator{
   funshowCheckout(activity : Activity){
    val intent = Intent(activity, CheckoutActivity::class.java)
    activity.startActivity(intent)
  }
}

It turned out well, but I want more.

Navigation with MVVM / MVP


I'll start with the question: where would you place the navigation logic when using MVVM / MVP?

In the layer under the presenter (let's call it business logic)? Not a good idea, because most likely you will reuse your business logic in other presentation models or presenters.

In the presentation layer? Do you really want to transfer events between the presentation and the presentation / presentation model? Let's look at an example:

classShoppingCartActivity : ShoppingCartView, Activity() {
  @Injectlateinitvar navigator : Navigator
  @Injectlateinitvar presenter : ShoppingCartPresenter
  overridefunonCreate(b : Bundle?){
    super.onCreate(b)
    setContentView(R.layout.activity_shopping_cart)
    val checkoutButton = findViewById(R.id.checkoutButton)
    checkoutButton.setOnClickListener {
      presenter.checkoutClicked()
    }
  }
  overridefunnavigateToCheckout(){
    navigator.showCheckout(this)
  }
}
classShoppingCartPresenter : Presenter<ShoppingCartView> {
  ...
  overridefuncheckoutClicked(){
    view?.navigateToCheckout(this)
  }
}

Or if you prefer MVVM, you can use SingleLiveEvents or EventObserver

classShoppingCartActivity : ShoppingCartView, Activity() {
  @Injectlateinitvar navigator : Navigator
  @Injectlateinitvar viewModel : ViewModel
  overridefunonCreate(b : Bundle?){
    super.onCreate(b)
    setContentView(R.layout.activity_shopping_cart)
    val checkoutButton = findViewById(R.id.checkoutButton)
    checkoutButton.setOnClickListener {
      viewModel.checkoutClicked()
    }
    viewModel.navigateToCheckout.observe(this, Observer {
      navigator.showCheckout(this)
    })
  }
}
classShoppingCartViewModel : ViewModel() {
  val navigateToCheckout = MutableLiveData<Event<Unit>>
  funcheckoutClicked(){
    navigateToCheckout.value = Event(Unit) // Trigger the event by setting a new Event as a new value
  }
}

Or let's put the navigator in the view model instead of using the EventObserver as shown in the previous example.

class ShoppingCartViewModel @Inject constructor(val navigator : Navigator)  : ViewModel() {
  fun checkoutClicked(){
    navigator.showCheckout()
  }
}

Please note that this approach can be applied to the presenter. We also ignore a possible memory leak in the navigator in case it keeps a link to the activation.

Coordinator


So where do we place the navigation logic? Business logic? We have previously considered this option and came to the conclusion that this is not the best solution. Moving events between the view and the view model may work, but it does not look like an elegant solution. Moreover, the presentation is still responsible for the logic of navigation, even though we brought it to the navigator. Following the exception method, we still have the option of placing the navigation logic in the view model, and this option seems promising. But should the presentation model take care of navigation? Isn't it just a layer between the view and the model? That is why we came to the concept of a coordinator.

“Why do we need another level of abstraction?” - you ask. Is it worth the complication of the system? In small projects, an abstraction can indeed be obtained for the sake of abstraction, but in complex applications or in the case of using A / B tests, the coordinator may be useful. Suppose a user can create an account and login. We already have some logic where we have to check if the user has logged in and show either the login screen or the main screen of the application. The coordinator can help in the given example. Please note that the coordinator does not help writing less code, it helps to extract the navigation logic code from the view or view model.

The idea of ​​the coordinator is extremely simple. He knows only which application screen to open next. For example, when a user clicks on the order payment button, the coordinator receives the corresponding event and knows that further it is necessary to open the payment screen. In iOS, the coordinator is used as a locator service, to create ViewControllers and manage back-stacks. This is quite a lot for the coordinator (remember about the principle of sole responsibility). In Android applications, the system creates activites, we have a lot of tools for dependency injection, and there is a backpack for activations and fragments. And now let's go back to the original idea of ​​the coordinator: the coordinator just knows what screen will be next.

Example: News application using coordinator


Let's finally talk directly about the template. Imagine that we need to create a simple news application. The application has 2 screens: “list of articles” and “article text”, which is opened by clicking on the list item.



classNewsFlowCoordinator(val navigator : Navigator) {
  funstart(){
    navigator.showNewsList()
  } 
  funreadNewsArticle(id : Int){
    navigator.showNewsArticle(id)
  }
}

Script (Flow) contains one or more screens. In our example, the news script consists of 2 screens: “article list” and “article text”. The coordinator turned out extremely simple. When starting the application, we call NewsFlowCoordinator # start () to show the list of articles. When the user clicks on the list item, the NewsFlowCoordinator # readNewsArticle (id) method is called and the screen with the full text of the article is displayed. We are still working with the navigator (we will talk about this a little later), to whom we delegate the opening of the screen. The coordinator has no state, he does not depend on the implementation of the back-end and implements only one function: determines where to go next.

But how to connect the coordinator with our presentation model? We will follow the principle of dependency inversion: we will pass a lambda to the view model, which will be called when the user clicks on the article.

classNewsListViewModel(
  newsRepository : NewsRepository, 
  var onNewsItemClicked: ( (Int) -> Unit )?
) : ViewModel() {
  val newsArticles = MutableLiveData<List<News>>
  privateval disposable = newsRepository.getNewsArticles().subscribe { 
      newsArticles.value = it
  }
  funnewsArticleClicked(id : Int){
    onNewsItemClicked!!(id) // call the lambda
  }
  overridefunonCleared() {
    disposable.dispose()
    onNewsItemClicked = null// to avoid memory leaks
  }
}

onNewsItemClicked: (Int) -> Unit is a lambda, for which it has one integer argument and returns Unit. Please note that lambda may be null, this will allow us to clear the link in order to avoid memory leaks. The creator of the view model (for example, Dagger) must provide a link to the coordinator method:

return NewsListViewModel(
  newsRepository = newsRepository,
  onNewsItemClicked = newsFlowCoordinator::readNewsArticle
)

Earlier we mentioned the navigator, which performs the change of screens. Implementation of the navigator is at your discretion, since it depends on your specific approach and personal preferences. In our example, we use one activit with several fragments (one screen - one fragment with its own presentation model). I give a naive implementation of the navigator:

classNavigator{
  var activity : FragmentActivity? = nullfunshowNewsList(){
    activty!!.supportFragmentManager
      .beginTransaction()
      .replace(R.id.fragmentContainer, NewsListFragment())
      .commit()
  }
  funshowNewsDetails(newsId: Int) {
    activty!!.supportFragmentManager
      .beginTransaction()
      .replace(R.id.fragmentContainer, NewsDetailFragment.newInstance(newsId))
      .addToBackStack("NewsDetail")
      .commit()
    }
}
classMainActivity : AppCompatActivity() {
  @Injectlateinitvar navigator : Navigator
  overridefunonCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContentView(R.layout.activity_main)
    navigator.activty = this
  }
  overridefunonDestroy() {
    super.onDestroy()
    navigator.activty = null// Avoid memory leaks
  }
}

The given implementation of the navigator is not ideal, but the main idea of ​​this post is an introduction to the coordinator pattern. It is worth noting that since the navigator and coordinator do not have a state, they can be declared within the application (for example, Singleton Scup in Dagger) and can be instantiated in Application # onCreate ().

Let's add authorization to our application. We will define a new login screen (LoginFragment + LoginViewModel, for simplicity we will omit password recovery and registration) and LoginFlowCoordinator. Why not add a new functionality to NewsFlowCoordinator? We do not want to get God-Coordinator, who will be responsible for all the navigation in the application? Also, the authorization script does not apply to the news reading script, right?

classLoginFlowCoordinator(
  val navigator: Navigator
) {
  funstart(){
    navigator.showLogin()
  }
  funregisterNewUser(){
    navigator.showRegistration()
  }
  funforgotPassword(){
    navigator.showRecoverPassword()
  }
}
classLoginViewModel(
  val usermanager: Usermanager,
  var onSignUpClicked: ( () -> Unit )?,
  var onForgotPasswordClicked: ( () -> Unit )?
) {
  funlogin(username : String, password : String){
    usermanager.login(username, password)
    ...
  }
  ...
}

Here we see that for each UI-event there is a corresponding lambda, but there is no lambda for a successful login login. This is also an implementation detail and you can add the appropriate lambda, but I have a better idea. Let's add the RootFlowCoordinator and subscribe to the model changes.

classRootFlowCoordinator(
  val usermanager: Usermanager,
  val loginFlowCoordinator: LoginFlowCoordinator,
  val newsFlowCoordinator: NewsFlowCoordinator,
  val onboardingFlowCoordinator: OnboardingFlowCoordinator
) {
  init {
    usermanager.currentUser.subscribe { user ->
      when (user){
        is NotAuthenticatedUser -> loginFlowCoordinator.start()
        is AuthenticatedUser -> if (user.onBoardingCompleted)
                                    newsFlowCoordinator.start()
                                else
                                    onboardingFlowCoordinator.start()
      }
    }
  }
  funonboardingCompleted(){
    newsFlowCoordinator.start()
  }
}

Thus, the RootFlowCoordinator will be the entry point of our navigation instead of NewsFlowCoordinator. Let's stop our attention on the RootFlowCoordinator. If the user is logged in, then we check whether he went onboarding (more on that later) and start the news or onboarding script. Please note that LoginViewModel does not participate in this logic. We describe the script onboarding.



classOnboardingFlowCoordinator(
  val navigator: Navigator,
  val onboardingFinished: () -> Unit// this is RootFlowCoordinator.onboardingCompleted()
) {
  funstart(){
    navigator.showOnboardingWelcome()
  }
  funwelcomeShown(){
    navigator.showOnboardingPersonalInterestChooser()
  }
  funonboardingCompleted(){
    onboardingFinished()
  }
}

Onboarding is started by calling OnboardingFlowCoordinator # start (), which shows WelcomeFragment (WelcomeViewModel). After clicking the “next” button, the OnboardingFlowCoordinator # welcomeShown () method is called. Which shows the following screen PersonalInterestFragment + PersonalInterestViewModel, where the user selects categories of interesting news. After selecting categories, the user clicks on the “next” button and the OnboardingFlowCoordinator # onboardingCompleted () method is called, which proxies the call to RootFlowCoordinator # onboardingCompleted (), which launches NewsFlowCoordinator.
Let's see how a coordinator can simplify working with A / B tests. I will add a screen with an offer to make a purchase in the app and will show it to some users.



classNewsFlowCoordinator(
  val navigator : Navigator,
  val abTest : AbTest
) {
  funstart(){
    navigator.showNewsList()
  } 
  funreadNewsArticle(id : Int){
    navigator.showNewsArticle(id)
  }
  funcloseNews(){
    if (abTest.isB){
      navigator.showInAppPurchases()
    } else {
      navigator.closeNews()
    }
  }
}

Again, we did not add any logic to the view or its model. Have you decided to add InAppPurchaseFragment to onboarding? To do this, only the onboarding coordinator will need to be changed, since the purchase fragment and its viewmodel are completely independent of other fragments and we are free to reuse it in other scenarios. The coordinator will also help implement the A / B test, which compares the two onboarding scenarios.

Full source can be found on github , and for the lazy, I prepared a video demonstration


Useful advice: using the cotlin you can create a convenient dsl to describe the coordinators in the form of a navigation graph.

newsFlowCoordinator(navigator, abTest) {
  start {
    navigator.showNewsList()
  } 
  readNewsArticle { id ->
    navigator.showNewsArticle(id)
  }
  closeNews {
    if (abTest.isB){
      navigator.showInAppPurchases()
    } else {
      navigator.closeNews()
    }
  }
}

Results:


The coordinator will help to bring the logic of navigation into the weakly coupled component under test. At the moment there are no production-ready libraries, I have described only the concept of solving the problem. Does the coordinator apply to your application? I do not know, it depends on your needs and how easy it will be to integrate it into the existing architecture. It may be useful to write a small application using the coordinator.

FAQ:

The article does not mention the use of a coordinator with an MVI template. Is it possible to use a coordinator with this architecture? Yes, I have a separate article .

Google recently introduced the Navigation Controller as part of the Android Jetpack. How does the coordinator compare with the navigation from Google? You can use the new Navigation Controller instead of the navigator in the coordinators or directly in the navigator instead of manually creating transaction fragments.

And if I do not want to use fragments / activations and want to write my own back-end for managing views, can I use the coordinator in my case? I also thought about it and am working on a prototype. I will write about it in my blog. It seems to me that the state machine will greatly simplify the task.

Is the coordinator tied to a single-activity-application approach? No, you can use it in various scenarios. The implementation of the transition between the screens is hidden in the navigator.

With the described approach, you get a huge navigator. We kind of tried to get away from God-Object'a? We are not required to describe the navigator in the same class. Create several small supported navigators, for example, a separate navigator for each custom script.

How to work with continuous transition animations? Describe the transition animations in the navigator, then the activation / fragment will not know anything about the previous / next screen. How does the navigator know when to run the animation?Suppose we want to show the animation of the transition between fragments A and B. We can subscribe to the onFragmentViewCreated event (v: View) using the FragmentLifecycleCallback and upon the occurrence of this event we can work with animations just like we did directly in the fragment: add OnPreDrawListener to wait for readiness and call startPostponedEnterTransition (). Similarly, you can also implement an animated transition between activites using ActivityLifecycleCallbacks or between ViewGroups using OnHierarchyChangeListener. Do not forget to unsubscribe from events to avoid memory leaks.

Also popular now: