Over the past few years, we have developed common approaches to creating Android applications. Pure architecture, architectural patterns (MVC, MVP, MVVM, MVI), repository pattern 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 (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 large number 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 used navigation implementations 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.
class ShoppingCartActivity : Activity() { override fun onCreate(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:
class ShoppingCartActivity : Activity() { @Inject lateinit var navigator : Navigator override fun onCreate(b : Bundle?){ super.onCreate(b) setContentView(R.layout.activity_shopping_cart) val checkoutButton = findViewById(R.id.checkoutButton) checkoutButton.setOnClickListener { navigator.showCheckout(this) } } } class Navigator { fun showCheckout(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:
class ShoppingCartActivity : ShoppingCartView, Activity() { @Inject lateinit var navigator : Navigator @Inject lateinit var presenter : ShoppingCartPresenter override fun onCreate(b : Bundle?){ super.onCreate(b) setContentView(R.layout.activity_shopping_cart) val checkoutButton = findViewById(R.id.checkoutButton) checkoutButton.setOnClickListener { presenter.checkoutClicked() } } override fun navigateToCheckout(){ navigator.showCheckout(this) } } class ShoppingCartPresenter : Presenter<ShoppingCartView> { ... override fun checkoutClicked(){ view?.navigateToCheckout(this) } }
Or if you prefer MVVM, you can use SingleLiveEvents or EventObserver
class ShoppingCartActivity : ShoppingCartView, Activity() { @Inject lateinit var navigator : Navigator @Inject lateinit var viewModel : ViewModel override fun onCreate(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) }) } } class ShoppingCartViewModel : ViewModel() { val navigateToCheckout = MutableLiveData<Event<Unit>> fun checkoutClicked(){ navigateToCheckout.value = Event(Unit)
Or let's put the navigator in the view model instead of using an EventObserver as shown in the previous example.
class ShoppingCartViewModel @Inject constructor(val navigator : Navigator) : ViewModel() }
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 the 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 view 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. 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 very 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 he needs 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.

class NewsFlowCoordinator (val navigator : Navigator) { fun start(){ navigator.showNewsList() } fun readNewsArticle(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 was 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 shown. 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 presentation model, which will be called when the user clicks on the article.
class NewsListViewModel( newsRepository : NewsRepository, var onNewsItemClicked: ( (Int) -> Unit )? ) : ViewModel() { val newsArticles = MutableLiveData<List<News>> private val disposable = newsRepository.getNewsArticles().subscribe { newsArticles.value = it } fun newsArticleClicked(id : Int){ onNewsItemClicked!!(id)
onNewsItemClicked: (Int) -> Unit is a lambda, which 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:
class Navigator{ var activity : FragmentActivity? = null fun showNewsList(){ activty!!.supportFragmentManager .beginTransaction() .replace(R.id.fragmentContainer, NewsListFragment()) .commit() } fun showNewsDetails(newsId: Int) { activty!!.supportFragmentManager .beginTransaction() .replace(R.id.fragmentContainer, NewsDetailFragment.newInstance(newsId)) .addToBackStack("NewsDetail") .commit() } } class MainActivity : AppCompatActivity() { @Inject lateinit var navigator : Navigator override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) navigator.activty = this } override fun onDestroy() { super.onDestroy() navigator.activty = null
The given implementation of the navigator is not perfect, 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 in 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?
class LoginFlowCoordinator( val navigator: Navigator ) { fun start(){ navigator.showLogin() } fun registerNewUser(){ navigator.showRegistration() } fun forgotPassword(){ navigator.showRecoverPassword() } } class LoginViewModel( val usermanager: Usermanager, var onSignUpClicked: ( () -> Unit )?, var onForgotPasswordClicked: ( () -> Unit )? ) { fun login(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.
class RootFlowCoordinator( 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() } } } fun onboardingCompleted(){ 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.

class OnboardingFlowCoordinator( val navigator: Navigator, val onboardingFinished: () -> Unit // this is RootFlowCoordinator.onboardingCompleted() ) { fun start(){ navigator.showOnboardingWelcome() } fun welcomeShown(){ navigator.showOnboardingPersonalInterestChooser() } fun onboardingCompleted(){ 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 work 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.

class NewsFlowCoordinator ( val navigator : Navigator, val abTest : AbTest ) { fun start(){ navigator.showNewsList() } fun readNewsArticle(id : Int){ navigator.showNewsArticle(id) } fun closeNews(){ 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 can freely 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 make the logic of navigation in the tested weakly coupled component. 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 don’t 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 the 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 great 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) with the FragmentLifecycleCallback and upon the occurrence of this event we can work with animations just like we did 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.