Photo by Tabl-trai under Creative Commons licence

Fixing RecyclerView nested scrolling in opposite direction

and making ViewPager2 usable

Christophe Beyls

--

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

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

RecyclerView also supports nested scrolling by implementing the NestedScrollingChild3 interface, which means it can cooperate with a parent view implementing NestedScrollingParent3 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 RecyclerView is placed inside a CoordinatorLayout next to an app bar which will automatically slide up or collapse when a scroll gesture is initiated.

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

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 ViewPager which only scrolls horizontally, ignoring vertical gestures.

But there is a problem with RecyclerView, and by extension ViewPager2.

Suppose we have a RecyclerView with a vertical LinearLayoutManager and each item of this vertical list consists of a RecyclerView with an horizontal LinearLayoutManager. 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 RecyclerViews 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 RecyclerView, the parent RecyclerView kicks in and intercepts the touch event instead of the child RecyclerView, 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 RecyclerViews are rare so the severity of this issue is low.

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

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

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

This time the ViewPager2 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 ViewPager2 adapter, including FragmentStateAdapter. In my opinion this makes ViewPager2, in its current state, practically unusable compared to the legacy ViewPager.

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

The cause

Rather than giving up and reverting to ViewPager, I decided to investigate by debugging and looking at the source code of RecyclerView. The culprit is the code responsible for initiating a scroll inside the onInterceptTouchEvent() 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;

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

The RecyclerView will switch to SCROLL_STATE_DRAGGING mode and the method will return true 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 RecyclerView will not interfere. But if it’s not perfect and rather diagonal, then a vertical RecyclerView will consider it as a vertical gesture nonetheless and start a vertical scroll.

In summary, the problem is that when the RecyclerView 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 (abs(dx) > abs(dy)) or vertical ( abs(dy) > abs(dx)) 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 RecyclerView scrolls in both directions, but will check the gesture direction before intercepting it if the RecyclerView 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 RecyclerView class and override the onInterceptTouchEvent() 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 super.onInterceptTouchEvent() method, then duplicate some of the detection logic and tweak it in order to cancel the scroll and finally return false instead of true for the cases discussed earlier. Next, you would have to change all your layout files to use that new class. But then, what about ViewPager2? It will still use the original RecyclerView internally instead of the fixed one, which means you’d have to create and maintain a custom version of ViewPager2 as well! This turns out to be quite impractical.

There is a better way. As mentioned in the introduction, RecyclerView 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 RecyclerView APIs:

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

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

RecyclerViewExt.kt

For ViewPager2, an additional extension property has to be created in order to make its internal RecyclerView 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 ViewPager2 instance, one would call:

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

Mission accomplished. Now the parent RecyclerView and ViewPager2 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 ViewPager2, 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 RecyclerView 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.

--

--

Christophe Beyls

Android developer from Belgium, blogging about advanced programming topics.