Introducing InboxRecyclerView, a library for building expandable descendant navigation

When Google released Inbox for Android some 4 years ago, their UI was rad. I was obsessed with the navigation transition, where emails expanded from their list item when clicked, pushing all other items out of the screen. When pulled downwards, the emails collapsed back to their positions.

I wanted to recreate this UI. I started by capturing a screen-recording of Inbox and playing it many hundred times at 1/10th the speed. When animations are slowed down, our eyes are able to catch all the details that otherwise look like magic at 60 frames per second. After spending a few days on it, I had a working prototype.

That was 4 years ago. I never managed to finish it for public usage — until now. Today, I’m releasing it for everyone to use. Thankfully, the design is still in line with the newest material design guidelines. Google has also used this transition in a case study for a hypothetical app called Reply.

Introducing, InboxRecyclerView — a library for building expandable descendant navigation with pull-to-dismiss gesture: https://github.com/saket/InboxRecyclerView

If you’re interested in knowing how InboxRecyclerView works, here’s a detailed explanation.

Smoke and mirrors

There are two parts to the library: InboxRecyclerView for the list items and ExpandablePageLayout for the expandable content. When an item is clicked, InboxRecyclerView performs three steps:

1. Prepare to expand

InboxRecyclerView aligns the content with the clicked list item. During expansion, they are cross-faded into each other to make it look like the list item itself is expanding.

val itemLocation: Rect = captureViewLocation(clickedItem)
contentPage.visibility = View.VISIBLE
contentPage.translationY = itemLocation.y
contentPage.setDimensions(itemLocation.width, itemLocation.height)

At this point, the app will also load the content into ExpandablePageLayout. You can take a look at the sample app for reference.

2. Expanding content

Once the content is aligned, the next step is to animate its expansion.

I initially experimented with animating the dimensions by updating the View’s LayoutParams. As you might have already guessed, this resulted in terrible performance. Any change in dimensions invalidates the entire View hierarchy in a recursive manner (except in some cases where a ViewGroup is smart enough to optimize this). Doing this on a loop at 60fps was a bad idea.

My breakthrough came in when I learned that Views are also capable of clipping their content. Using View#setClippedBounds(Rect), the visible portion of a View can be animated to create an illusion that it’s getting resized.

fun animateDimensions(toWidth: Int, toHeight: Int) {
  val fromWidth = clipBounds.width()
  val fromHeight = clipBounds.height()

  ObjectAnimator.ofFloat(0F, 1F)
    .addUpdateListener {
      val scale = it.animatedValue as Float
      val newWidth = (toWidth - fromWidth) * scale + fromWidth
      val newHeight = (toHeight - fromHeight) * scale + fromHeight)
      contentPage.clipBounds = Rect(0, 0, newWidth, newHeight)
    }
    .start()
}

Using the Transitions API with ChangeBounds is also an option. It takes advantage of a hidden function called ViewGroup#suppressLayout() that disables invalidation of the View hierarchy, resulting in equally smooth animations.

fun animateDimensions(toWidth: Int, toHeight: Int) {
  val transition = TransitionSet()
    .addTransition(ChangeBounds())
    .addTransition(ChangeTransform())
    .addTransition(Slide())
    .setOrdering(TransitionSet.ORDERING_TOGETHER)
  TransitionManager.beginDelayedTransition(parent as ViewGroup, transition)

  val params = contentPage.layoutParams
  params.width = toWidth
  params.height = toHeight
  contentPage.layoutParams = params
}

Unfortunately, I had a bad experience with Transitions API where the expand animation was occasionally completing abruptly. So I decided to stay away from using it.

3. Animating list items

To make it look like the expanding item is pushing other items outside the screen, the items are also moved in sync with the expanding content during the animation. This is done inside ItemExpandAnimator, and is customisable.

Pull to collapse

This is probably my favourite part of InboxRecyclerView. The content can be collapsed by dragging it vertically in either direction.

The gesture for this takes advantage of a property of Views where touch events can be intercepted by ViewGroups before they reach their children. I’ve explained this in a bit more detail in my other post.

When a vertical gesture is detected, the page is scrolled along with the gesture. The interesting part of this is that the content doesn’t exactly move with the user’s finger. Some friction is added to the movement.

override fun onTouch(view, event): Boolean {

  when (event.action) {
    ACTION_MOVE -> {
      val deltaY = event.rawY - lastTouchY
      val friction = 4F
      var deltaYWithFriction = deltaY / frictionFactor

      view.translationY += deltaYWithFriction
      val lastTouchY = event.rawY
    }

    ACTION_UP -> {
      if (isEligibleForCollapse()) {
        collapsePage()
      } else {
        smoothlyResetPage()
      }
    }
  }
}

This friction grows even larger once the page has crossed the threshold distance and is eligible for collapse.

if (isEligibleForCollapse()) {
  val extraFriction = collapseDistanceThreshold / view.translationY
  deltaYWithFriction *= extraFriction
}

Polish

InboxRecyclerView applies a soft tint on the list when its covered. When the content page is pulled, the tint is faded away to give a visual indication when the page can be released to collapse.

Apps can be really creative with this. Dank, for example, uses the status bar color to indicate when the content is eligible for collapse.

The material case study also features a beautiful FAB animation. While my less than stellar design skills cannot recreate that, I did manage to create a good enough animation using ShapeShifter by Alex Lockwood.

That’s all folks!