WHOOP’s New Android Navigation

My name is Dan Peluso, and I’m a co-op on WHOOP’s Community Team. We’re responsible for the social page in our app, including all teams, leaderboards, and communications available through this landing page. I joined the team as a Junior Android Developer this July, and immediately began work on the all-new navigation component our Product team wanted to launch by this fall. Today, I’m super excited to talk about our new Android navigation architecture, and some examples of where we’re refactoring.

Our goal was to allow for easier access to features receiving the most interaction, a problem our old navigation system addressed with an overloaded ‘hamburger menu’ that contained all the navigation points for the app. 

All our features in a single list hamburger menu? No thanks

We decided this was the perfect time to implement the popular Jetpack Navigation Component. With an app of this scale, having all the screens and flows available in a graph made adding screens easy, and keeping a Single-Activity architecture had its benefits as well. Specifically, to keep a shared toolbar component on the bottom of every screen, we had to maintain its state by keeping all other components and screens in the same Activity. It also made our app easier to test, using Fragment Scenarios to launch specific views to be tested orthogonally.

Standardizing Navigation

Our old design, similar to many Android applications, fell victim to the dynamic nature of the Android ecosystem. Many practices and standards that exist in the Android community today were not developed at the time our legacy stack was written. Our screens were implemented as Activities. Though we did have some Fragments for multi-layout pages, every screen that existed as an Activity needed to be converted into a Fragment. These screens must be above the new navigation bar, and users need the ability to navigate between all 5 landings at any point. Additionally, we need to save all the states of each navigation pillar for seamless switching between.

As a co-op on the Community Team, it was my responsibility to take legacy Activities and convert them to Fragments in our section of the app. While the two aren’t inherently much different to work with, it was also my responsibility to update any deprecated methods, libraries, or design patterns we were using. 

For those unfamiliar with the Android SDK – Google deprecates Android components frequently

The following are some prominent results of our switched navigation change. 

The Good

Navigation Arguments

Passing arguments is a way to send data to a newly created Activity or Fragment. This prevents the launched page from making an API call, or spending time getting values from a different data source. With every Activity, there was an included builder to help package these arguments with the launch:

companion object {
  @JvmStatic fun intentBuilder(context: Context, team: Team): IntentBuilder =
          IntentBuilder(context, CommunityAddMembersActivity::class.java).apply {
              withExtras(Bundle().apply { putParcelable(KEY_TEAM_ID, team) })
          }
}

Paired with this was our old way of grabbing these arguments for use in the Activity:

intent.getParcelableExtra<Team>(KEY_TEAM_ID)?.let { viewModel.loadTeam(it) }

The new navigation library allowed us to declare the arguments needed by a fragment in the navigation graph:

<fragment
   android:id="@+id/communityAddMemberFragment"
   android:name="com.whoop.CommunityAddMemberFragment">
   <argument
       android:name="team"
       app:argType="Team" />
</fragment>

Using safe args guarantees this Fragment is never navigated to unless with the required argument:

private fun onAddMember(team: Team) {
  navController.navigate(actionCommunityFragmentToCommunityAddMemberFragment(team))
}

Unpacking these arguments is made even easier:

private val args by navArgs<CommunityAddMemberFragmentArgs>()

viewModel.loadTeam(args.team)

Animations

Animations are an important part of our app navigation. Our legacy method of doing this was through the Builder design pattern, declaring our animations as we build the Activity about to be launched. 

public IntentBuilder asSlideUp() {
  BaseActivity.markAsSlideUp(getIntent());
  setActivityOptions(ActivityOptionsCompat.makeCustomAnimation(
          context, R.anim.slide_up_in, R.anim.slide_up_stay));
  return this;
}

public IntentBuilder asZoomIn() {
  BaseActivity.markAsZoom(getIntent());
  setActivityOptions(ActivityOptionsCompat.makeCustomAnimation(
          context, R.anim.zoom_in, R.anim.zoom_in_exit));
  return this;
}

Using the XML navigation graph, we can skip this step by associating the animations with the actions themselves. No need to define Intent Builders, and we can reuse all our existing animations:

<action
  android:id="@+id/action_communityFragment_to_teamProfileViewDialogFragment"
  app:destination="@id/teamProfileViewDialogFragment"
  app:enterAnim="@anim/zoom_in"
  app:exitAnim="@anim/zoom_out_enter"
  app:popEnterAnim="@anim/zoom_in_exit"
  app:popExitAnim="@anim/zoom_out" />

The Less Good

Starting Fragments for Result

A great feature baked into the Activity structure is the ability to declare that certain Activities should only be launched with the intent of returning a value. There are tons of use cases for this, here’s an example where we use it for editing a team.

public void launchEditTeam() {
  Team team = fragment.getTeam();
  if (team != null) {
      Intent intent = TeamManageActivity.intentBuilder(this, team)
              .asSlideUp()
              .build();
      startActivityForResult(intent, REQUEST_MANAGE_TEAM);
  }
}

Here we launch the ‘TeamManageActivity” screen for team admins. We grab the current team from the Fragment, pass it as an Intent argument, then launch the Activity for the result identified by the constant. Getting the result from this action is super simple, because we define the result in the same Activity we launch from:

@Override
public void onActivityResult(int requestCode, int resultCode, Intent data) {
  super.onActivityResult(requestCode, resultCode, data);
  User currentUser = getCurrentUser();
  if (requestCode == REQUEST_MANAGE_TEAM) {
      if (resultCode == RESULT_DELETE_TEAM || resultCode == RESULT_TRANSFER_OWNERSHIP) {
              goToNavPoint();
      } else {
              getCurrentContent().refresh();
      }
  }

Unfortunately, there is no startFragmentForResult, and we had to adapt by implementing these simple actions as Fragments as well. 

Starting Activities for results are able to be chained together, and you can program your Activities to pop multiple screens from the back stack after performing an action. This makes travel from screen A to B to C back to A easy. 

The user flow above shows this situation. Notice how before transferring ownership the top right icon of the team is for editing the team. After transferring, we need to reload this page as a user rather than admin, simultaneously keeping our back stack clean so not to allow users to navigate back to the ‘manage team’ page after transferring ownership. 

Fortunately, we found a workaround for this by using another XML field when defining our actions:

<action
   android:id="@+id/action_communityRemoveMembersFragment_to_communityFragment"
   app:destination="@id/communityFragment"
   app:popUpTo="@id/communityFragment"
   app:popUpToInclusive="true" />

Rather than popping the back stack traditionally with findNavController.popBackStack() , defining an action to return to the A screen forced us to use the safe arguments required for this Fragment. In the case of loading a member view instead of an admin view, it’s exactly what we need to ensure we’re loading the most up-to-date team without needing a web request and keep the back stack clean of old screens. 

Saving States

One of the greatest benefits of Activities is the ability to easily save state in a multi-screen architecture. This happens when Activities are opened with Intents – the newly opened Activity is added onto the stack of Activities. Every new screen requires more memory from the system, and can slow down apps of scale. Jetpack’s new navigation component aims to fix this problem, but in doing so uses a method of ‘replacing’ screens rather than ‘adding’ them; we save memory, but lose the state preserved in the stack. 

In the Community Landing, it’s common for users to check out a team, scroll to one of three leaderboards in this team, and then click on a specific user within that leaderboard.

Which states are worth saving here? 

If we had launched each of these screens as an Activity, back navigating would keep these states for us. Because we used Fragments and the Navigation Component, we were forced to come up with a better way to keep state. For now, we’ve refactored our Fragments to load directly from the View Model when launching. In the case of loading a profile, we sacrificed the persistent bottom toolbar to use a DialogFragment. This saves the scroll position of the leaderboard when going back.

Recently, we implemented a new method of saving states across each bottom navigation pillar. Each pillar is paired with an individual back stack. This allows users to persist their views when navigating from the different sections of our app. 

The Promise

Even with any hiccups the new navigation component may have, the Android Team’s goal at WHOOP is to provide a robust user experience to all of our subscribers. Our growing team is working diligently to find the best architecture, components, and libraries to improve our system. 

WHOOP’s co-op program allowed me to take ownership of refactoring sections of both the new Community Landing and Settings Landing. I helped review my peers’ code, as well as assist in our deployment and critical path testing pipeline. It was more responsibility than I thought I’d see as an intern, but it taught me to work well among a small team of talented developers. I hope you all enjoy the new update, and be on the lookout for much more from WHOOP!

Dan Peluso
Dan Peluso

Android Engineer

https://www.pelusodan.com/