Started from the Bottom: How We Incrementally Rewrote Our Android App’s Navigation System

--

Reverb helps music makers of all levels connect with sellers all over the world ranging from small businesses, family-owned shops, and individuals to the world’s largest musical instrument retailers and well-known musicians. So, providing users with an app that helps them find the perfect instrument for their next song or sell the gear they no longer need is top of mind for our engineering team.

An older version of the app with the nav drawer (left) vs. a recent version with bottom navigation (right).

Since the Reverb Android app’s inception in 2014, we’ve used the navigation drawer / “hamburger menu” to aid in navigation. At the time, it was considered standard and part of Android’s overall design guidelines. Over time, we became interested in replacing the navigation drawer with a bottom navigation “tab bar” for a few reasons:

  • Bottom navigation was becoming more standard across apps on Android.
  • A bottom nav component was added to Android’s standard UI library, reducing our need to maintain our own solution or rely on a third-party UI library.
  • Easier design / collaboration / support between the Android and iOS app, which also uses a tab bar.

By far, our largest reason for wanting to make the change is we believed in the pattern for our users. Our app has a handful of very important screens: Homepage, Updates, and Selling. Making those readily available and not hiding them alongside less-used screens / settings in a “junk drawer” was something we thought all users would benefit from.

Challenge

At Reverb, we always want to be able to release incrementally and keep our normal pace of deployment. We did not just want to stop development of other priorities to focus entirely on this change, nor did we want a long-lived feature branch with these changes that would be impossible to merge back to main and come with significant risk when deploying.

With those constraints, we decided on an approach that would have us refactor each screen (over thirty!) to become significantly more generic. A large part of this was decoupling any specific or custom navigation or toolbar logic the old code may have had. We established common interfaces to replace custom code in these screens and started working through them one by one.

A critical part of this approach was to still be able to support these newer, generic interfaces in our old navigation drawer layout. This allowed us to change one screen at a time, open a PR, merge to main, and ship it. Users never knew and the risk of regression was reduced from our entire app to single screens.

Results

In the end, this approach significantly reduced the burden and risk of this large change. It paved the road to make the actual switch to a bottom navigation much smaller. It was so successful that it enabled us to do something we had earlier ruled out as too complex: shipping a single release that supported both the navigation drawer and bottom nav with a feature-flag controlling which experience the users saw (without major code duplication), further reducing our risk and increasing our confidence in shipping.

Now, we are going to dive into some of the specifics of how we implemented this approach in our Android codebase.

Step 0. Where We Started / Where We Are Going

First, some basic terms if you’re not familiar with the Android framework:

Activity: the highest-level component of an Android screen. Every app needs at least one as an entry point. The Activity stack is managed by the system.

Fragment: a component which is shorter-lived than an Activity, but can do many of the same things. An Activity (or a Fragment itself) can contain multiple Fragments which may represent some or all of its screen real estate. Fragment stacks are managed by their parent Activity or Fragment.

Before we introduced bottom navigation, the general structure of each screen was an Activity which housed a toolbar, any headers, and a Fragment, which contained the main content. If we wanted to navigate to another screen, the Activity would launch a new, separate Activity and let the system take it from there. Since the hamburger menu was always tucked away out of sight, it gave the impression of being constant, but it was simply recreated on every screen.

Our old architecture: navigating to new screen creates a new Activity.

Where we wanted to go for bottom navigation was a single Activity that housed the bottom tab bar, a shared toolbar, and a container to hold any screens we needed to display within this frame. The main content for each screen would still be controlled via Fragments, but we could no longer hand off navigation to the system since we needed the tab bar to remain constant. We also needed more granular control of what should happen when you navigate to a new screen or navigate back (e.g. tapping a different tab vs tapping on a link which should keep you on the same tab).

Our desired architecture: navigating to a new screen creates a new Fragment, but not a new Activity.

Step 1. Introduce Temporary “Container” Activity

In a lot of ways, this was our most important step — it is what allowed us to incrementally refactor and ship changes without going all or nothing. By creating a generic container Activity and implementing all the interfaces/changes described in later sections, it allowed our new and old architectures to operate generally the same.

The goal of this container was to be short-lived. At a high level, this was an Activity that we could navigate to that would have only the minimum amount of configuration needed to instantiate an existing screen and nothing else. For the the time being, navigating to a new screen would still create a new Activity, but it would be a new instance of this container class rather than our custom Activities. Our final bottom navigation architecture would then implement all the same interfaces, so to the individual fragments nothing would have changed.

Step 2. Create Generic Interfaces for Common Features

In our existing architecture, each Activity had its own layout with its own toolbar, set its own title (or got it from the Manifest label), and initialized its options Menu if necessary and handled when those options were selected. To make it more generic, we moved the definition and handling to the Fragments and the implementation / display to our generic container (which would eventually get replaced by our single shared Activity with tabs).

For example, for the toolbar and its title, we created a sealed class called ToolbarStrategy that our Fragments could expose to tell the parent Activity how to populate its toolbar, or alternatively hide its toolbar for the occasional Fragment with a custom header:

sealed class ToolbarStrategy {  open val staticTitleSource : TitleType? = null
open val dynamicTitleFlow
: Flow<TitleType>? get() = null
open class StaticTitle(
override val staticTitleSource : TitleType?
) : ToolbarStrategy()
open class DynamicTitle(
override val dynamicTitleFlow : Flow<TitleType>
) : ToolbarStrategy() {
constructor(channel : Channel<String>) : this(
channel.receiveAsFlow().map { TitleType.StringTitle(it) }
)
}
class CustomDesign(
dynamicTitleFlow : Flow<TitleType>
) : DynamicTitle(dynamicTitleFlow) {
constructor(channel : Channel<String>) : this(
channel.receiveAsFlow().map { TitleType.StringTitle(it) }
)
}
sealed class TitleType {
class StringTitle(val title : String) : TitleType()
class StringResourceTitle(val titleRes : Int) : TitleType()
class Icon(val iconRes : Int) : TitleType()
}
}

Step 3. Establish a common interface for Navigation

Similarly, we wanted to introduce a common interface which Fragments could use for navigation. In our former architecture, Fragments were already agnostic to how most navigation happened, but relied on many one-off callback interfaces which would be impractical to swap out all at once. We needed something more unified and scalable.

Before defining the interface, we thought about what a future implementation might look like. We considered some open source libraries but ultimately ended up with our own implementation for a couple of reasons:

  1. We wanted to support multiple back-stacks, one for each tab, which either wasn’t supported or felt cumbersome in some libraries.
  2. We needed our solution to work seamlessly for both the new and old implementations, which would have been difficult in some libraries.

Our approach took inspiration from some of these libraries (such as key-based navigation in SimpleStack) but was simplified to suit our current, specific needs.

Here you can see a simplified version of our Navigator interface along with our sealed class ScreenKey. Here we are still supporting both Activities and Fragments, to allow iterative refactoring.

interface Navigator {
fun goToScreen(key : ScreenKey)
fun goToRootScreen()
fun goBack()
}
sealed class ScreenKey : Parcelable {
open val requiresAuth : Boolean = false
}
abstract class FragmentKey : ScreenKey() {
abstract val fragmentClass : Class<out Fragment>
open fun createArguments() : Bundle? = null fun createFragment() : Fragment {
return fragmentClass.newInstance().apply {
arguments = createArguments()
}
}
}
abstract class ActivityKey : ScreenKey() {
abstract fun createIntent(context : Context) : Intent
}

We used dependency injection to pass the concrete Navigator implementation into each Fragment we refactored. This enabled easier testing due to now having one common interface that could be mocked in a reusable way, and would enable us to swap the implementation later without touching the individual Fragment code again.

Step 4. Do the work

Now we had all the high-level architecture in place, the only thing left to do was the work itself. As we interacted and refactored more screens, we found that most of our screens fit the model and abstractions we planned ahead of time. Occasionally, some of our more complex screens needed special behaviors to handle their special behaviors. In each case, we followed our general principle and moved as much of that logic into the shared Navigator or container as possible.

As the work went on, we were able to remove many one-off interfaces and custom Activity classes, reducing our code size and, through increased usage of dependency injection, made things more testable.

This was a massive undertaking, but if you’ve looked at our Android app recently, you know already that we managed to pull it off. Designing a navigation system that was more generic, scalable, and testable was not only essential to allow for gradual refactoring to prepare for the eventual implementation of our bottom navigation, it improved the codebase and in turn our productivity. And of course, that means we are in a better position to continue delivering a delightful experience to our users and make the world more musical!

Interested in solving challenges like this one and making the world more musical? We’re hiring! Visit Reverb Careers to see open positions and learn more about our team, values, and benefits.

--

--