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!