Illustration by Vincent Rickey

Scrolling in Android: Custom Scroll Behavior for a List of Varying Height

Jordan Carlyon
Dow Jones Tech
Published in
6 min readJul 16, 2018

--

A scrollbar is a useful and recognizable way to show progress and position in a feed of content. It’s an important feature for a news publication to have, especially since articles have items varying in height, from one-line bylines to vertically-oriented images.

As an Android developer on The Wall Street Journal, handling lists with content of varying heights is a common use case on Android. Unfortunately, a scrollbar that doesn’t behave erratically is not well supported out of the box when working with dynamically-sized items in a RecyclerView. The scrollbar will not move smoothly – it will jump around and resize. To keep things performant, we want a scrollbar that can accurately and smoothly track the user’s position in a list of content without knowing the entire list.

To accomplish this for a list of items with varying height, you must either create a custom layoutManager that estimates how the scrollbar should move for each user interaction, or that measures the entire list to calculate the size of each item before displaying it. We chose the first approach — its performance will always be better and can support updates to the content without remeasuring everything.

Before we can go deeper, let’s discuss the nomenclature for the various parts that make up a scrollbar.

Visualization of the thumb, track and extent.

The thumb is the moving part that tracks progress, the track is the area that the thumb can move in, and the extent is the position of the thumb in the track.

There are two out-of-the-box solutions that work for some use cases. The first is setting smoothScrollEnabled() to false. Smooth Scroll is a setting in LinearLayoutManager that attempts to smooth the scrollbar’s transition between items. Setting smoothScrollEnabled() to false jumps from one item to the next, adjusting the size of the track for how large the current item is.

Too much jumping!

The other out-of-the-box option is setting smoothScrollEnabled() to true. This is similar to the default implementation, except that it resizes the thumb and interpolates the extent based on the size of on-screen items to smooth the transition.

Slightly smoother but still annoying.

Even with items only varying in height a small amount, the scrollbar thumb can be jarring.

To adjust how the scrollbar behaves for every increment of scroll for the solution we pursued, we have to use a custom LayoutManager. The LayoutManager is responsible for positioning items as well as recycling items in a RecyclerView. There are several stock layout managers, but we are interested in the LinearLayoutManager.

We want smoothScrolling off so we can can add that to our LayoutManager’s constructor.

class CoolLayoutManager extends LinearLayoutManager {   CoolLayoutManager(Context context) {
super(context);
setSmoothScrollbarEnabled(false);
}

To get our scrolling smooth, we are only interested in overriding three methods from LinearLayoutManager.

The first is computeVerticalScrollExtent(RecyclerView.State state), the length of the thumb. It can be adjusted for your specific use case, but we want to use a fixed value to avoid any odd resizing that could distract and annoy the user. For this use case, SMOOTH_VALUE represents a single item in our list. Therefore, we are using a thumb that is about three times the size of a single item.

@Override
public int computeVerticalScrollExtent(RecyclerView.State state) {
final int count = getChildCount();
if (count > 0) {
return SMOOTH_VALUE * 3;
}
return 0;
}

Next is computeVerticalScrollRange(RecyclerView.State state), the length of the scrollbar track. We also want this to be a fixed length, calculated from the size of our list multiplied by a smoothing factor. The smoothing factor is just a static value applied to the ratio of the scrolled off content and applied to our position. More on that below.

@Override
public int computeVerticalScrollRange(RecyclerView.State state) {
return Math.max((getItemCount() - 1) * SMOOTH_VALUE, 0);
}

Finally we get to computeVerticalScrollOffset(RecyclerView.State state), the interesting method. We want to avoid the default implementation that jumps from item to item, but to do this we need a way to know how far down a view a user has scrolled to move the thumb in smooth fractions of a view.

First we start by checking for some error states, then we want to find the first visible view.

@Override
public int computeVerticalScrollOffset(RecyclerView.State state) {
final int count = getChildCount();
if (count <= 0) {
return 0;
}
int heightOfScreen;
int firstPos = findFirstVisibleItemPosition();
if (firstPos == RecyclerView.NO_POSITION) {
return 0;
}
View view = findViewByPosition(firstPos);
if (view == null) {
return 0;
}

Next we find the top of the view, compare it to the height of the view, and find the ratio of the amount of screen that has been scrolled out of view. Since we are grabbing the first visible item and we are only concerned with the content that scrolled off the top of the screen (or content that moves the scroll bar), we can take the absolute value of the ratio between top and bottom. Most of the time this gives us a pretty nice transition between items of various sizes. TheSMOOTH_FACTOR also allows the use of integers without having to convert and round numbers explicitly.

The velocity of the extant — how fast the thumb will transition — will not be uniform since not all items are the same size. The impact is mitigated the longer your list is. Still, this results in a scrollbar that behaves in a more consistent and a less distracting way to the user.

// Top of the view in pixels
final int top = getDecoratedTop(view);
int height = getDecoratedMeasuredHeight(view);
if (height <= 0) {
heightOfScreen = 0;
} else {
heightOfScreen = Math.abs(SMOOTH_VALUE * top / height);
}
if (heightOfScreen == 0 && firstPos > 0) {
return SMOOTH_VALUE * firstPos - 1;
}
return (SMOOTH_VALUE * firstPos) + heightOfScreen;

Above this code block, we must check if we are at the last item – if so, scroll to the end. The reason this is needed is because we do not know the size of all the items and we are tracking the position from the top. Since the top item isn’t the same size as the bottom item, the amount scrolled will not always match up with the extant we have already traversed. The only way we know the extant we’ve traversed is by scrolling each item off the screen.

If the scroll bar jumping to the end of the list is an issue, a very simple change would be to add a static value to the heightOfScreen. This number can be adjusted based on your use case and expected size of an item.

return (SMOOTH_VALUE * firstPos) + heightOfScreen + 50;

We could choose to find the last visible item instead of the first, but then we’d have the same issue scrolling up. If you find that this works better for your app, then it is a fairly simple change to make.

This gives us a scrollbar that works well in most use cases.

You can checkout the sample app here. The only resources used for this class were the LinearLayoutManager and RecyclerView documentation. Thanks to WSJ Android team, WSJ mobile team and Dow Jones Tech.

--

--