Photo by Tabl-trai under Creative Commons licence

Fixing RecyclerView nested scrolling in opposite direction

and making ViewPager2 usable

R is one of the main building blocks of Android user interfaces today. It’s more capable and flexible than its predecessor but this also leads to more complexity and new problems.

One core feature of is the externalization of its layout engine to components called s, which are no longer limited to vertical scrolling only. Most implementations allow to choose between horizontal and vertical scrolling and a could technically support both at the same time.

also supports nested scrolling by implementing the interface, which means it can cooperate with a parent view implementing when both are configured to scroll in the same direction. In a nutshell, the scrolling child intercepts a scroll event and initiates a nested scroll in cooperation with the scrolling parent. Depending on its internal logic, the parent optionally consumes the scroll before or after the child, until they both reach their available scroll distance. A single scroll event may be consumed by both the parent and child with a seamless transition. For example, this mechanism is used when a is placed inside a next to an app bar which will automatically slide up or collapse when a scroll gesture is initiated.

Note that doesn’t implement , so using a scrollable child inside a which scrolls in the same direction is not supported. However, Google currently provides a workaround to implement nested scrolling in a .

The problem

What about nested scrolling in the opposite direction? This should be supported out-of-the-box, since a vertical scrolling view is not supposed to intercept horizontal gestures and vice versa. In practice this works well with the legacy which only scrolls horizontally, ignoring vertical gestures.

But there is a problem with , and by extension .

Suppose we have a with a vertical and each item of this vertical list consists of a with an horizontal . This kind of layout with scrollable horizontal items inside a vertical list is commonly found in Netflix-like video streaming apps with scrollable movie posters inside sections, or the Google Play Store app.

Notice that attempting to scroll the nested horizontal s often ends up with unexpected results.

Horizontal scroll is expected, but vertical scroll kicks in instead.

Many times, even though an horizontal gesture is performed on a child , the parent kicks in and intercepts the touch event instead of the child , resulting in a small vertical scroll instead of an horizontal scroll. This actually happens when the scroll gesture is not perfectly horizontal. It’s actually diagonal, even if you can see that the vertical distance of the gesture is small compared to its horizontal distance and its intent was clearly horizontal.

Most of the time when using our phones, we are actually performing such imprecise diagonal gestures because we expect the system to understand our intent based on the current context. And it usually does it pretty well, but not in this particular case where only a perfectly horizontal gesture will be recognized. It harms the user experience. That’s why I consider it a bug.

Introducing ViewPager2

At this point you may think that nested s are rare so the severity of this issue is low.

But it turns out that the new layout, which officially replaces the legacy , uses a internally. Horizontal pagers are present in almost every app I’m aware of, and the pages often contain a vertical or some form of .

Here’s what happens when you try to scroll a vertical inside a parent horizontal (which internally uses a ):

Vertical scroll is expected, but the horizontal ViewPager2 reacts instead.

This time the takes over and intercepts the gesture, unless it’s perfectly vertical (which is difficult to achieve). The result is the same with any kind of adapter, including . In my opinion this makes , in its current state, practically unusable compared to the legacy .

The same annoying behavior occurs when the pages contain a or a , hinting at which component is causing these problems: the parent .

The cause

Rather than giving up and reverting to , I decided to investigate by debugging and looking at the source code of . The culprit is the code responsible for initiating a scroll inside the method (taken from version 1.1.0 of the RecyclerView library):

boolean canScrollHorizontally = mLayout.canScrollHorizontally();
boolean canScrollVertically = mLayout.canScrollVertically();
switch (action) {
...
case MotionEvent.ACTION_MOVE: {
...
final int x = (int) (e.getX(index) + 0.5f);
final int y = (int) (e.getY(index) + 0.5f);
if (mScrollState != SCROLL_STATE_DRAGGING) {
final int dx = x - mInitialTouchX;
final int dy = y - mInitialTouchY;
boolean startScroll = false;
if (canScrollHorizontally && Math.abs(dx) > mTouchSlop) {
mLastTouchX = x;
startScroll = true;
}
if (canScrollVertically && Math.abs(dy) > mTouchSlop) {
mLastTouchY = y;
startScroll = true;
}
if (startScroll) {
setScrollState(SCROLL_STATE_DRAGGING);
}
}
} break;
...
}
return mScrollState == SCROLL_STATE_DRAGGING;

and are respectively the horizontal and vertical distances the finger has travelled the on screen since it started touching it.

The will switch to mode and the method will return to intercept the gesture (preventing the child views from detecting it) as soon as the touch slop is reached in its scrolling direction. The touch slop is the minimal distance a finger must move on the screen before a touch event is considered as an actual drag gesture and not a tap. When a gesture is perfectly horizontal, the vertical touch slop is not reached and a vertical will not interfere. But if it’s not perfect and rather diagonal, then a vertical will consider it as a vertical gesture nonetheless and start a vertical scroll.

In summary, the problem is that when the is configured to scroll in a single direction as it’s usually the case, it doesn’t test if the global shape of the gesture is more horizontal () or vertical ( ) before deciding to intercept it, potentially conflicting with a child view scrolling in the opposite direction.

A better drag gesture detection code would be:

if (canScrollHorizontally && Math.abs(dx) > mTouchSlop && (canScrollVertically || Math.abs(dx) > Math.abs(dy))) {
mLastTouchX = x;
startScroll = true;
}
if (canScrollVertically && Math.abs(dy) > mTouchSlop && (canScrollHorizontally || Math.abs(dy) > Math.abs(dx))) {
mLastTouchY = y;
startScroll = true;
}

With this code, the behavior will be the same as before if the scrolls in both directions, but will check the gesture direction before intercepting it if the scrolls in a single direction.

The solution

Ideally the above code change would be integrated directly into the RecyclerView library. One of the motivations behind this article is to raise the attention of developers working at Google, in the hopes that they will eventually release a fixed version.

In the meantime, which options are available to work around this issue today?

A first attempt would be to extend the class and override the method. This is not easy because the original code of this method is accessing private fields which are not available to child classes, so it can’t just be copy-pasted and updated. Instead, you have to call the method, then duplicate some of the detection logic and tweak it in order to cancel the scroll and finally return instead of for the cases discussed earlier. Next, you would have to change all your layout files to use that new class. But then, what about ? It will still use the original internally instead of the fixed one, which means you’d have to create and maintain a custom version of as well! This turns out to be quite impractical.

There is a better way. As mentioned in the introduction, is flexible and features a modular architecture enabling the usage of extensions. It turns out that such an extension can be created to customize the behavior of the scroll gesture detection. In detail, the extension will have to use a combination of 2 APIs:

  • An to be registered on the , where the updated gesture detection code (based on the original code of RecyclerView) will be implemented by overriding .
  • An to be registered on the as well, whose role is to detect transitions from to and revert back to if required (depending on the data provided by the updated gesture detection code). After reverting back, will now return and the gesture will not be captured.

Here is the full code, provided as a single Kotlin extension function:

RecyclerViewExt.kt

For , an additional extension property has to be created in order to make its internal public:

import androidx.core.view.get
import androidx.viewpager2.widget.ViewPager2
val ViewPager2.recyclerView: RecyclerView
get() {
return this[0] as RecyclerView
}

Then to enable the fix for a instance, one would call:

val pager: ViewPager2 = findViewById(R.id.pager)
pager.recyclerView.enforceSingleScrollDirection()

Mission accomplished. Now the parent and will react properly to diagonal gestures and the app users can enjoy easier nested scrolling.

Conclusion

As an early adopter of the first stable version of , I was surprised to hardly see anyone raising concerns about the usability of this new widget in nested lists scenarios, compared to its predecessor. Issues with nested scrolling have been present since the first release of but don’t seem to get much attention. At least we now have a simple pluggable fix which doesn’t involve subclassing, altough it should still be considered as a hack and may break in future versions of the library. I created an issue for this on the RecyclerView issue tracker so you can follow the progress of a fix in the library itself.

Thank you for reading this and please share and comment to help feeding the debate.

Special thanks to Manideep Polireddi who was the first person to document this issue.

Android developer from Belgium, blogging about advanced programming topics.