Clean Navigation Between Screens in Android Applications

In this post I’m going to talk about navigation between screens in Android applications, architecture and Navigation Architecture Component.

On the first sight, the topic of navigation might look mundane, almost trivial. However, by the end of this post, you’ll see that this first impression couldn’t be farther from truth. Navigation between screens is the core architectural aspect in your app and the way you approach this task makes a huge difference in its long-term maintainability.

Starting Activities, replacing Fragments

There are two main ways to represent “screens” in Android apps: Activity-per-screen and Fragment-per-screen. There are other approaches as well, but they are much less popular. Therefore, I’ll concentrate just on these two in the context of our discussion here.

If you’d like to navigate to a screen represented by Activity, you’d do it like this:

    Intent intent = new Intent(context, TargetActivity.class);
    context.startActivity(intent);

With Fragments, you need to write a bit more code:

     SomeFragment fragment = new SomeFragment();
     fragmentManager
             .beginTransaction()
             .replace(R.id.fragment_container, fragment)
             .commit();

But that’s not all. When you start a new Activity, the old one is automatically added to the backstack by default. That’s not the case with Fragments. To enable back-navigation with Fragments, you need to explicitly add transactions to the backstack:

     TargetFragment fragment = new TargetFragment();
     fragmentManager
             .beginTransaction()
             .addToBackStack(null)
             .replace(R.id.fragment_container, fragment)
             .commit();

All in all, not that difficult, right? Let’s move on.

Activity extras and Fragment arguments

In some cases, you’ll want to pass data to the destination screen. For example, imagine that you have a list of products, and when the user clicks on one of them, you want to show a new screen with that product’s description. To make this work, you’ll need to pass either the entire data structure representing the product, or, at least, product’s ID to the next screen.

To pass data into Activities, you use so-called “Intent extras”:

    Intent intent = new Intent(context, TargetActivity.class);
    intent.putExtra(TargetActivity.INTENT_EXTRA_PRODUCT, product);
    context.startActivity(intent);

The destination TargetActivity will then extract this data from the intent:

    Product product = (Product) getIntent().getExtras().getSerializable(INTENT_EXTRA_PRODUCT);

With Fragments, you’d use so-called “Fragment arguments”:

    TargetFragment fragment = new TargetFragment();
    Bundle args = new Bundle();
    args.putSerializable(TargetFragment.ARG_PRODUCT, product);
    fragment.setArguments(args);
    fragmentManager
            .beginTransaction()
            .addToBackStack(null)
            .replace(R.id.fragment_container, fragment)
            .commit();

The destination TargetFragment will then extract the data from its arguments:

    Product product = (Product) getArguments().getSerializable(ARG_PRODUCT);

Please note that I rely on automatic Java serialization (using Serializable marker interface) to put data into intents and arguments. That’s my preferred way to pass non-primitive data structures. It’s simple to use and maintain. However, many Android developers don’t like this approach and prefer to use Parcelables. If that’s your preference too, be my guest.

Coupling between screens

Let’s talk about the coupling between different screens in your application a bit.

Despite the fact that the term “coupling” is usually used in a very negative context, there is really nothing wrong with it. In fact, coupling is what you do when you interconnect multiple components to work together. However, there are different types and different degrees of coupling, and not all of them created equal.

In the very first examples in this article, I coupled the current screen to the next one either through a reference to Activity class, or a call to Fragment constructor. This kind of coupling is alright.

However, in the examples that demonstrated exchange of data, the coupling became much stronger. In addition to dependency on the high-level details of the target screen, I also made the current screen coupled to the constants defined inside that screen. In fact, the current screen is not just coupled to the constants, but it’s also “assuming” that the target screen will use these constants to retrieve the data from either intent or arguments. But even that isn’t the entire story. See, using this seemingly simple and innocent approach, I managed to couple the target screen to the current one as well. If I’ll forget to provide data that the target screen expects, or provide incorrect types of data, then I can break the target screen, even if it worked fine until this exact moment!

In relatively small applications, such a coupling might not become an issue. But in larger codebases which need to be maintained for years, it might lead to serious problems.

For example, in older codebases I saw screens which were navigated to from multiple other screens and used more than ten different constants for data exchange. These constants would be combined in different ways upon navigation from different screens. When you jump into such code and need to add additional route to that screen, you basically face a very complex puzzle because you need to figure out which constants are mandatory to use, which types of data should be used, whether there are mutually exclusive constants that shouldn’t be used together, etc. Since this contract isn’t enforced by the compiler, you discover your mistakes only when you run the application. And if the application has multiple flavors which use the data injected into that screen in different ways, then good luck to you.

However, since that’s the approach Android framework forces us to use if we want to pass data between screens, there is seemingly no alternative. We should just accept the inevitability of bad design, right? Not on my watch!

Static factory methods to the rescue

To address the issues described in the previous section, I usually use static factory methods.

If the navigation target is Activity, then I’ll add this method to its public API:

    public static void start(Context context, Product product) {
        Intent intent = new Intent(context, TargetActivity.class);
        intent.putExtra(INTENT_EXTRA_PRODUCT, product);
        context.startActivity(intent);
    }

Then the clients that want to navigate to that Activity will simply do the following:

    TargetActivity.start(context, product);

Note: static methods for starting new Activities don’t really qualify as factories because they don’t instantiate new objects. However, I still call them static factories for convenience.

If the target is Fragment, then, similarly, I’d do the following:

    public static TargetFragment newInstance(Product product) {
        TargetFragment fragment = new TargetFragment();
        Bundle args = new Bundle();
        args.putSerializable(ARG_PRODUCT, product);
        fragment.setArguments(args);
        return fragment;
    }

And then call this method from clients:

    TargetFragment fragment = TargetFragment.newInstance(product);
    fragmentManager
            .beginTransaction()
            .addToBackStack(null)
            .replace(R.id.fragment_container, fragment)
            .commit();

Nothing complex here, right? Just moving a bit of code from one place to another. However, this simple design trick brings enormous benefits:

  1. The clients no longer depend on the constants from target screens.
  2. The number and the type of parameters is enforced by the compiler through method signature.
  3. All the details about mapping of constants to parameters are encapsulated within the target class. The constants are private.
  4. If you need to navigate to the same screen from multiple places, then, instead of code duplication, you just call the same method.

In more complex scenarios, when different clients need to pass different sets of data to target screens, I simply add multiple factory methods. I give these methods descriptive names to convey information about their intent to future readers of my code. This way, I explicitly define all valid combinations of data, document their usage and let the compiler enforce that.

It’s impossible to overstate the impact of this technique on your codebase. I find it especially beneficial in big and complex applications, but it’ll make your life easier in any project beyond the most trivial “hello world”. Since it’s that simple, you should probably always use it. As far as I can tell, there are no downsides at all.

Screen navigator abstraction

If you think about it, handling navigation within your app is a standalone responsibility. For example, in case of Fragments navigation, the clients shouldn’t care about the low level mechanics of FragmentTransaction and whatnot. They just need to state which screen should be shown next and provide the respective parameters (if required).

Therefore, according to Single Responsibility Principle, you should extract this functionality into a standalone component.

So, let’s create ScreenNavigator abstraction:

public class ScreenNavigator {

    private final Activity mActivity;
    private final FragmentManager mFragmentManager;

    public ScreenNavigator(Activity activity, FragmentManager fragmentManager) {
        mActivity = activity;
        mFragmentManager = fragmentManager;
    }

    public void toProductDetailsScreen(Product product) {
        TargetFragment fragment = TargetFragment.newInstance(product);
        replaceFragment(fragment);
    }

    private void replaceFragment(Fragment fragment) {
        mFragmentManager
                .beginTransaction()
                .addToBackStack(null)
                .replace(R.id.fragment_container, fragment)
                .commit();
    }

    public void toProductDetailsScreen2(Product product) {
        TargetActivity.start(mActivity, product);
    }

}

Now, whenever you need to navigate to another screen, you just call ScreenNavigator’s methods:

    mScreenNavigator.toProductDetailsScreen(product);

In essence, all I did was just extracting the code from individual clients into this new class. I’m pretty sure you aren’t mind blown by that. But you should be. See, with this humble change I introduce crucially important architectural boundary into my application.

Note that the clients no longer depend on individual Activity and Fragment classes at all. In fact, the clients don’t even know whether a call to ScreenNavigator will result in a new Activity being shown, or a new Fragment (or any other component, really). These implementation details are now abstracted out and I can even change my approach in the future without affecting the existing code. In addition, elimination of dependencies on Android classes in my clients will allow me to unit test them, including their interaction with ScreenNavigator.

Also note that once you have ScreenNavigator in your codebase, it becomes much less important how you actually handle the navigation. Since all these details are encapsulated in a single class now, you can easily switch between manual approach and different external libraries. In essense, the choice of navigation mechanism ceases to be part of your architecture and becomes implementation detail of ScreenNavigator.

I have ScreenNavigator abstractions in all my apps and it’s among the first classes I add in clients’ codebases. So far, I implemented Activity-per-screen, Activity-per-flow, Fragment-per-screen and Fragment-per-screen-with-bottom-tabs approaches using ScreenNavigator and all of them worked like charm.

Back navigation

When you add ScreenNavigator to your application, also add navigateBack() method to its API and delegate onBackPressed() from all Activities to this method. There is a bit of nuance here, so let me just copy-paste code example from one of my projects:

public class ScreenNavigator {

    ...
    
    public boolean navigateBack() {
        if(mFragNavController.isRootFragment()) {
            return false;
        } else {
            mFragNavController.popFragment();
            return true;
        }
    }

}

This specific project uses Single-Activity approach and FragNav library to handle Fragments, so I can use FragNavController class instead of hacking with FragmentManager. Modify the implementation of navigateBack() according to your implementation details.

Then, inside your Activities, do the following (if you have many Activities, consider extracting this logic into a base class):

    @Override
    public void onBackPressed() {
        if (!mScreensNavigator.navigateBack()) {
            super.onBackPressed();
        }
    }

Just in case you’ve got some other action to do on back press, like closing the nav drawer if it’s open, or dismissing a dialog, you can modify this code like this:

    @Override
    public void onBackPressed() {
        if (mViewMvc.isDrawerVisible()) {
            mViewMvc.closeDrawer();
        } else if (!mScreensNavigator.navigateBack()) {
            super.onBackPressed();
        }
    }

In addition to handling back button clicks by the user, you can now call navigateBack() in your code to get back to the previous screen. Magic!

Navigation Architecture Component

Now it’s time to address the elephant in the room: which parts of what I wrote in this post are still relevant given there is an official Navigation Architecture Component from Google?

If you think about it, conceptually, Navigation Component attempts to address the same issue that ScreenNavigator addressed: we’d like to have centralized management of navigation inside Android application and decouple the clients from the details of navigation. Sure, Navigation Component also has “navigation editor” (copy of “storyboards” from Xcode), but it has nothing to do with software design and architecture, so it isn’t that important in the context of our discussion.

Now, unlike ScreenNavigator, Navigation Component is horribly complex:

  • You need to use XMLs to define your navigation graphs.
  • If you want to pass any parameters between the origin and the destination in a type-safe and decoupled way, it looks like your only option is to use some Gradle plugin that generates code.
  • If you don’t like code generation, then back to coupling through Bundles and code duplication.
  • You can’t have navigation graph that spans multiple activities.

There are probably more complexities and limitations, but the above list is already pretty bad.

I guess many developers will think at this point: “This guy is complaining about XMLs. It’s not really that big of a deal!”. Well, not exactly. I don’t mind XMLs where they make sense, but in this case, it was a poor choice.

Just think about this trivial use case: you want to find all places in code that navigate to a specific screen. With ScreenNavigator, this amounts to just finding usages of one or more simple methods defined in ScreenNavigator. With Navigation Component, it’s more nuanced and requires to look “under the hood” of the abstraction, making it so called “leaky abstraction”. Furthermore, if you used the aforementioned plugin to pass parameters to the next screen in a type-safe manner, this task will require completely different approach.

In my opinion, Google took a very wrong turn with Navigation component. When I look at this library, I see Loaders all over again: extremely complex library that addressed already solvable problem in a very complex and inconvenient way. My prediction is that it’ll also share the fate of Loaders: waste millions of man-hours of developers’ time and then fade into irrelevance (unless Google somehow forces us to use this monster, of course). I just hope that all this waste will be accompanied by some second-order positive impact. For example, maybe Google will finally make Fragment transitions simple and reliable.

Therefore, all in all, my recommendation is to stay away from Navigation Component. If you absolutely want to use it, then, at the very least, wrap this nonsense in ScreenNavigator abstraction. This way you’ll be able to refactor it out of your codebase relatively easily in the future.

Conclusion

As I told you at the beginning of this post, the way you structure navigation between screens in your app is a core part of its architecture. Well, to be precise, it becomes core part of app’s architecture if you don’t give this topic enough attention. However, if you extract SreenNavigator abstraction (you can give it different name, btw), then navigation concern will remain simple and elegant. You won’t even notice its importance in your codebase and that’s exactly what good design feels like.

Unfortunately, I don’t have anything positive to say about Navigation Architecture Component. To me, it looks like yet another over-engineered library that will become legacy in couple of years. Before this happens, however, it’ll take an enormous toll in the form of developers’ effort, attention and, at a later stage, refactoring time.

As always, leave your comments and questions below and don’t forget to subscribe to my mailing list if you liked this article.

If you liked this post, then you'll surely like my courses

16 thoughts on “Clean Navigation Between Screens in Android Applications”

    • Hi Adrian,
      In multi-module app you won’t be able to put ScreenNavigator inside the main module and then reference it in child modules. However, it’s not an issue with this approach per se, but a general Gradle’s feature. It doesn’t allow circular dependencies between modules.
      You work around this just like you do with all other instances of this inconvenience. Your best bet is to avoid a need for ScreenNavigator in child modules. If that’s not an option, then you can extract an interface into a standalone module, and then make child modules depend on this interface instead of ScreenNavigator itself. Some kind of “infrastructure” or “common” module is often used for that. Alternatively, you could screate sub-interfaces within child modules and then make ScreenNavigator implement those. That would constitute usage of both Dependency Inversion and Interface Segregation principles of SOLID.
      These are some of your options from the top of my head.
      Regards
      Vasiliy

      Reply
  1. Hi!
    Nice article, as always. I have few questions, though.
    Given the signature of ScreenNavigator, I assume it comes from a ‘single Activity-multiple Fragments’ project. I also assume that it is bound to Activity instance, so: one ScreenNavigator shared among all Fragments? Second: how would you set it up for a multi-activity project? (I’d go with App-scoped Navigator, as it wouldn’t need a Activity/FragmentManager references, only context)

    Reply
    • Hi,
      You can use ScreenNavigator abstraction in “no Fragment” projects. Whether you need to share one instance of ScreenNavigator between Fragments within the same Activity depends on its internal implementation details. For example, if you’re using manual FragmentTransactions, then there is no reason to share ScreenNavigator because the shared state will be handled inside Android’s FragmentManager in any case. If you use some other stateful library, then you might need to do that.
      As for multi-module project, see my other answer to Adrian.
      Cheers

      Reply
  2. Great article. I have been writing android apps for 10 years and always find myself agreeing with your assessments (well almost always). Navigation can have lots of little requirements for example sometimes you are showing a web view and want handle back by first checking if the web view can go back. It gets very involved in a complex app.

    Reply
  3. Amazing. I really liked fluent and simple writing. Solutions discussed in this post are really going to help me to maintain a clean code and remove coupling between screens.

    Reply
  4. This great and very clear article is both impressive and appreciated 🙂

    I believe some good capabilities of Jetpack’s Navigation library were left out:
    1. The library does have the ability to launch activities – the nav graph have an “activity” items which can be navigated to. I think it also supports data transfer.
    2. The library brings deep link capabilities to fragments – generates intent filters for activities automatically and automatically transform the URI into fragment arguments.
    3. The library (especially the kotling library) generates a grate data classes for arguments, in which each argument is null-safe, type-safe and can have default values (in kotlin, at least).
    4. The complexity (at least in my opinion, after integrating the library) is not as bad as mentioned in the article, even easier then maintaining code – just create nodes, directions and arguments.
    For XML haters, Android Studio 3.5+’s UI fully supports Navigation library.
    5. When you hire a new developer, he/she can have a great snapshot about the whole UI flows and relations between screens.
    6. You don’t need to manage navigation yourself, just define a graph in XML – less code to maintain.

    Regarding self navigation management:
    1. I liked way you can navigate to a specific destination in a different contexts (each have a different set of arguments) using multiple factory methods – this is missing in Navigation library.
    2. One downside – yes, when you navigate to a destination, you pass a type-safe and null-safe arguments, but you still need to parse them manually inside the destination, while they are not null-safe or type-safe.

    Reply
    • Hi Shlomi,
      Thanks for your feedback.
      I don’t see how the complexity of Navigation Component can be “easier then maintaining code” while what you wrote is already much more complex than what I’d want to see in my apps. However, it’s totally possible that we just have different reference points. I guess time will tell whether my predictions about Navigation Component were correct or not.
      Note, however, that non-null-or-type-safe parsing is not a limitation of ScreenNavigator approach. It’s a general limitation of Android framework related to lifecycles of Activities and Fragments. You can “hide” this limitation using some code-generation plugin, but I don’t consider this to be a good trade-off.
      Regards

      Reply
    • It’s worth noting that you can use the same pattern with static functions that abstract away Bundle creation for passing args between screens when using Nav Component. The function should just return the `Bundle` itself, rather than the `Fragment`.

      findNavController().navigate(
        R.id.action_fragmentOne_to_fragmentTwo,
        FragmentTwo.getBundleArgs(someParameter, someOtherParameter)
      )
      
      class FragmentTwo : Fragment() {
        private val paramOne: Int = requireNotNull(arguments?.get(EXTRA_PARAM_ONE))
        private val paramTwo: String = requireNotNull(arguments?.get(EXTRA_PARAM_TWO
        ...
        companion object {
          private const val EXTRA_PARAM_ONE = "abc"
          private const val EXTRA_PARAM_TWO = "def"
          @JvmStatic
          fun getBundleArgs(someParamOne: Int, someParamTwo: String) = bundleOf(
            EXTRA_PARAM_ONE to someParamOne
            EXTRA_PARAM_TWO to someParamTwo
          )
        }
      }
      
      Reply
  5. At first, I was thinking “Why I’m reading what I already know?” But then I understood, that a lot of newcomers don’t freaking know those basics! How often I’ve seen passing data to Fragment constructor? You can’t even imagine… Few months ago I did the same basically – I’ve written Fragment navigation best practices for our internal confluence.
    And, of course, Navigation Component roasting – can’t expect less from Vasiliy.

    Reply
    • Hello Sviatoslav,
      Unfortunately, not just newcomers don’t know these concepts. Many developers follow Google’s guidelines for years, so they wouldn’t have an example of how to structure the navigation properly.
      As for Navigation Component… well, I’ve got to live up to my reputation somehow 😉
      Cheers

      Reply
  6. Great article. I’ve been Android dev-ing since 2014, and I agree that Navigation Architecture Components is something that will “come and go”.

    The concept of abstracting navigation paths is really important advice that can be applied, regardless if you’re creating an Android or iOS app.

    Specifically, when you’re required to interface with another framework as intrusive on your app’s architecture like “Navigation Architecture Components” is, which ties your architecture to the framework and can be problematic to navigate down the track.

    Reply
  7. Hi Vasiliy!
    Thanks for the great article.
    I noticed that you didn’t mention Intent flags such as Intent.FLAG_ACTIVITY_CLEAR_TOP, etc.
    Just wondered how do you handle them in your projects. Do you just make a separate factory method?
    Thanks in advance!

    Reply
    • Hello Arthur,
      Yes, just separate Factory method. That’s an example from one of my projects:

          public static void startTransferPaymentClearTopAndClearTask(Context context, TransferPayment transferPayment) {
              Intent intent = new Intent(context, MainActivity.class);
              intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK);
              intent.putExtra(EXTRA_TAB, Tab.TRANSFER_PAYMENT);
              intent.putExtra(EXTRA_TRANSFER_PAYMENT, transferPayment);
              context.startActivity(intent);
          }
      

      Please don’t ask me why I used these specific flags. They are always kind of black magic to me and I don’t remember why I had to use these ones for that app.

      Reply

Leave a Comment

Subscribe for new posts