Buttery smooth animations on a stacking RecyclerView

By George Venios

A few months ago Shazam launched Discover, which gives users a daily digest of content curated to their individual musical preferences. This is its journey to smoothness.

Click here for actual video 🎬

The feed is implemented as a RecyclerView with a purpose-built LayoutManager that supports any stacking transformation by delegating the actual animations to the card views themselves. While there are many considerations for this specific implementation, the key insight is that we are translating Y-axis RecyclerView scroll inputs to per-card stacking progress deltas, basing the mapping on max allowed stacked items, current item height and screen height. [0]

The obvious problem with this design is that it introduces lots and lots of overdraw. A very rough estimate is that we’re drawing each pixel ~6 times (RecyclerView background + 3 stacked cards’ backgrounds + 3 stacked cards’ contents, which can even be full-bleed images). Another issue is that cards can have deep hierarchies making their inflation, measurement and layout costly. And to make matters worse, the LayoutManager has to create and add views as the items are moving, making framerate drops all the more noticeable. [1]

When optimising code always start with the changes that will have the greatest impact.

A good approach is to first verify things are actually running slow by using the Profile GPU Rendering developer option. Then, if they actually are, use SysTrace to identify the cause of the slowdown. Its interface might look daunting at first but it’s a powerful aid on the road to performant apps. As always, remember that not all API levels and devices behave the same so make sure you test on a number of configurations.

Following the techniques described next, we were able to get a silky smooth scrolling experience.

Views are only ever going to update once, when binding children to data [2]. Using layers will flatten the hierarchy to a single texture and obviously make drawing operations faster in exchange for memory. In the case of the digest cards, all scrolling transformations can be done natively on the GPU, so our item view textures will not update on every new frame (we’re only changing scaleXY, translationY and translationZ).

Be extra cautious when using hardware-backed layers because if used incorrectly they can destroy your performance as you could easily end up redrawing your View and re-uploading the texture to GPU on every single frame. A good rule of thumb is: never invalidate() and always check with the Show hardware layer updates developer option.

This remedies the internal overdraw caused by the cards’ contents and makes drawing faster as we no longer have to traverse the items’ subtrees or upload a myriad commands to GPU when drawing them.

Even after utilising View layers we’re still on ~3x overdraw. By definition, our LayoutManager stacks views on top of each other with large chunks of them being invisible.

The good news is that we can completely eliminate card overdraw [3] by iterating from top to bottom child (top = last drawn), updating an area-already-drawn Rect and at the same time passing the non-overlapped area of each card’s visible Rect to its View.setClipBounds().

A caveat here is that the coordinates passed to setClipBounds() are pre-transformation so we have to take view scaling into account when updating the area-already-drawn, and then translate back to pre-transformation coordinates when passing to setClipBounds().

Large bitmap uploads to GPU are expensive and a bunch of our item layouts require large, full-bleed images. As our LayoutManager will create and try to bind data to new children mid-scroll [1], trying to immediately load images will drop a noticeable amount of frames at the worst time possible.

Delaying image loading until the RecyclerView is in SCROLL_STATE_IDLE allows us to load the images at a time when dropping frames is much lower-impact as our views are no longer reacting to user interaction.

By this point, the scrolling performance was as good as one could get once you had scrolled through a few cards, but we were visibly dropping frames on the first few cards — arguably the most important part of the experience. The cause for this was card layout inflations.

We had already made a decision to go with XML in defining the card layouts due to readability, theming and the benefits of configuration-based resource resolving so migrating our definitions to Java wasn’t an option. The solution to this came in the form of the AsyncLayoutInflater. Migration is very easy — we only had to take care of waiting for the inflation to finish and then bind the data instead of doing it right away.

Since the inflation would take at most ~5 frames on our test devices, the maximum amount of time empty cards would be on-screen is less than 85ms, and since cards that are first appearing are roughly 90% obscured, this is a very low-impact issue.

After introducing these changes, the cards no longer lagged behind the user’s finger making for a much more fluid and natural interaction which in turn gives the whole experience a more polished feel.

Every implementation is different so these techniques may or may be worth the effort for everyone so remember to not jump the gun trying to squeeze out these precious few extra milliseconds. You know what they say about premature optimisation. 👹

Don’t forget to install the app, play with the feed and let us know what you think!

[0] For help with implementing a LayoutManager the Building a RecyclerView LayoutManager blog series from Dave Smith is invaluable.

[1] This is because we don’t want cards at the bottom of the stack to visibly pop in/out of view and therefore the best time to add and remove children is when a moving card completely overlaps the space between the peeking bottom card and the card at the top of the stack. This can either happen when the user drags or when the stack snaps — either way, things will be moving and framerate is important.

[2] The backing surfaces will actually update again when images are loaded but that’s indirectly handled by deferring image loading.

[3] We still have ~1x overdraw on the whole layout but clipping the background (a solid colour) at roughly the union of all card’s visible Rects is not worth the effort. Additionally, elevation shadows introduce some overdraw but I trust Android to optimise that one.

Originally published at medium.com on April 12, 2017.