The beauty of a sticky ItemDecoration

iosched is Google’s official app for Google I/O. It has many features and visual effects, but from my perspective, one stands above all: the stickiness of temporal details in the Agenda/Schedule section.

iosched

This is achieved thanks to ItemDecoration, a feature of the RecyclerView, together with some basic math.

I will show how you can achieve the very same effect, by creating an app called Wordphabet.

Wordphabet

Wordphabet simply displays a list of words, with every word’s initial on the left. The application looks like this:

As you scroll up or down, the initial remains in the top left corner of the screen until a lower one comes and pushes it out of the screen.

Prerequisites

We’re about to start!

Before we begin, there are a few things we must do first to better understand the algorithm behind.

  1. Adding space for drawing
    An ItemDecoration allows us to draw over or under the views in the RecyclerView. To avoid overlapping, we need to leave some space. The easiest way of doing this is to add a margin to the views that will be inflated.
  2. All the words
    We provided the RecyclerView’s adapter with a list of randomly created words. In your projects you can replace those with whatever you want. Those words are ordered alphabetically.
  3. Know when a category starts
    In order to properly display a letter only on its category (“A” for words starting with A, “B” for words starting with B and so on) we need to split them apart, in categories . To do so, we find the position of each leading word in the the list that contains all of our words and memorize it. In this case, the position will be the key in a map (🐻 with me) and the values will be what we’ll be drawing for that category, in our case, that is a StaticLayout.

The StaticLayout is useful for displaying text that won’t change in the future, exactly our case.

This might be hard to understand at first, so think like this:
For the list of words:

["account", "alpha", "beta", "best", "bet", "country", "create", "delay"]

our map will look like this:

{ 0: "A", 2: "B", 5: "C", 7: "D" }

Again:

  • Key = the position of the first word from a category, the leading word in a category
  • Value = A StaticLayout (a view), that contains the text we want to draw on the screen

Under the hood

Enough with the theory, let’s get coding.

As I’ve said earlier, we’ll use an ItemDecoration to achieve that stickiness effect. In my case, I called it SimpleStickyLetterDecoration.

ItemDecoration comes with 3 functions (and 3 more deprecated), but we’ll only make use of one, respectively onDraw.

onDraw has 3 parameter: a canvas to draw on it, the RecyclerView this ItemDecoration is attached to and the state of the it. We’ll be only using the first 2.

To better understand the algorithm, we’ll further be splitting onDraw in 2 different parts:

Part 1

Drawing sticky for the leading items

In this part we iterate over each item in the RecyclerView, in a down-to-top manner. We do so because lower items can push upper items out of the screen.

// parent is the RecyclerView onDraw provides us with
for (position in (parent.size - 1) downTo 0) {
/*
* If the children is not visible, continue
*/
val view = parent.getChildAt(position)
if (childOutsideParent(view, parent)) continue

NOTE: childOutsideParent is a function that detects if the view is outside the parent (RecyclerView) or not.

Do not forget that the RecyclerView only holds as many items as they are necessary to fill the screen

If the view is not visible on the screen, we go further. Nothing to do here.
Otherwise we find its position in the adapter,

val childPosition: Int = parent.getChildAdapterPosition(view)

and try to access the corresponding value (the StaticLayout we created for this key) in the map we previously defined.

// positionToLayoutMap is the map we use to memorize the pairs of // (Position, StaticLayout)positionToLayoutMap[childPosition]?.let { ... }

This is were the beauty of Kotlin comes in. If the map does not contain the key childPosition, it will return null, meaning it’s not a leading word. let won’t be called, thanks to Kotlin’s safe calls and nothing happens. We simply go to the next iteration.

Otherwise let will execute. Let’s see what happens:

val top = (view.top + view.translationY + stickyLetterPadding)
.coerceAtLeast(stickyLetterPadding)
.coerceAtMost(previousHeaderTop - stickyLetterLayout.height)

Remember what I said earlier that we also need a bit of math? Good news: this was all the math we needed.

IMPORTANT! In Android, X and Y axis start from the TOP-LEFT corner of your screen and go right, respective bottom. So in the top-right corner, Y = 0 and X = MAX, bottom-left Y = MAX and X = 0, bottom-right Y = MAX and X = MAX

If you’d debug your application you’d see that as you scroll down, the Y value of your items decreases and if you scroll up they increases.

The idea here is that we must compute where to draw our StickyLetter/StaticLayout. Those coordinates are relative to the parent canvas.
The logic is this:

  • (view.top + view.translationY + stickyLetterPadding) — computes where the current view is on the screen
  • .coerceAtLeast(stickyLetterPadding) — makes sure if the view goes out of the screen (Y becomes negative), the StickyLetter will stay a bit below the top
  • .coerceAtMost(previousHeaderTop — stickyLetterLayout.height) — if another StickyLetter comes from bottom of the screen, it will push this one up (even out of the screen!). This is also the reason for why we iterate over the views, from the last one to the first one.

… and we’re done with the math!

NOTE: In this part the order is important! Do not call coerceAtMost before coerceAtLeast

We draw the letter on the screen:

// we move a bit on the X axis to emulate to center the text
canvas.withTranslation(y = top, x = drawingSpace / 4f) {
stickyLetterLayout.draw(canvas)
}

and save the current values for later use.

lastFoundPosition = childPosition
previousHeaderTop = top - stickyLetterPadding

NOTE: lastFoundPosition will be used in the second part, just bear with me for this one too.

If you’d run the app now, you’d notice that the StickyLetter appears on the screen only as long as the leading word is on the screen! Don’t worry as this is intended and we’ll be solving this in the next part.

The sticky letter is visible only as long as the leading word is

Part 2

We need to solve the previous problem

What if there is no leading word on the screen? For example, let’s say the “A” category has 100 items and we can only display 10 items at a time. In this case, if the items on the screen are from 55 to 65, the previous for is useless.
This is where this if and lastFoundPosition are for. If lastFoundPosition is set to NO_POSITION, it means that no leading words appear on the screen. So how do we deduce what StickyLetter should we draw?

if (lastFoundPosition == NO_POSITION) {
lastFoundPosition = parent.getChildAdapterPosition(parent.getChildAt(0)) + 1
}

We simply find the position of a view on the screen in the original list and save it! We’ll use it later to find the first leading word with the position smaller than lastFoundPosition

Once this is done, we get to the last for. This one is responsible for drawing a StickyLetter when no leading word is on the screen.

for (initialsPosition in positionToLayoutMap.keys.reversed()) {
// If this condition is true, then we know we found the category we are in.
// We draw the Sticky Letter and finish!
if (initialsPosition < lastFoundPosition) {
positionToLayoutMap[initialsPosition]?.let {
// The very same logic as in the first for.
val top = (previousHeaderTop - it.height)
.coerceAtMost(stickyLetterPadding)
// We don't need "coerceAtLeast" because the position is directly relative to previous item.
canvas.withTranslation(y = top, x = drawingSpace / 4f) {
it.draw(canvas)
}
}
// Stop since we don't have a "higher" positioned item on the screen.
break
}
}

Here we need to find what category the upper words are in. Once we found it, we draw the corresponding letter and stop, since there is nothing else above it.

Remember that the keys in our map represent the position of the leading words in the list. So, if our map looks like this: {0: ”A”, 100: ”B”, 150: “C” etc} and lastFoundPosition = 125, we draw B on the screen, because B has the first key smaller than 125.

Again, ensure that the the Sticky Letters don’t overlap and also don’t go off the screen when not intended

val top = (previousHeaderTop - it.height)
.coerceAtMost(stickyLetterPadding)

draw it…

canvas.withTranslation(y = top, x = drawingSpace / 4f) {
it.draw(canvas)
}

and break the loop! That’s it! You’re done!

Run the program! It will work just as expected now

Now your Sticky Letter remains on top as long as its category is on the screen and is being pushed out when a new one arrives from the bottom!

You can find the whole project here

Conclusions

Hoooray! We customized an ItemDecoration!

In this tutorial we saw the power of an ItemDecoration by creating a StickyLetterItemDecoration for our app, Wordphabet. You could use this in many scenarios, like showing at what index you’re in the agenda, show the current day in an organizer or anything else. You name it.

Of course, you’re not restricted to only this case, because when it comes to possibilities,

… the sky’s the limit no more!

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store