Photo by Tabl-trai under Creative Commons licence

Fixing RecyclerView nested scrolling in opposite direction

and making ViewPager2 usable

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.

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.

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

Introducing ViewPager2

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

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

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;
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;
}

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.

  • 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.
RecyclerViewExt.kt
import androidx.core.view.get
import androidx.viewpager2.widget.ViewPager2
val ViewPager2.recyclerView: RecyclerView
get() {
return this[0] as RecyclerView
}
val pager: ViewPager2 = findViewById(R.id.pager)
pager.recyclerView.enforceSingleScrollDirection()

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.

Android developer from Belgium, blogging about advanced programming topics.