How to fix horizontal scrolling in your Android app

In Android, you need to have a vertically scrolling view somewhere at the top of your view hierarchy in almost all of your screens, if you want to support all screen sizes, densities, and orientations. This is more true with the recent introduction of the multi-window feature in Android-N. With a vertical scroll at the root if you add a horizontally scrolling content in your page like a recommended products section at the end of your product page or horizontally scrolling sections in your feed like Headout, you end up with a bad scrolling experience in your app.

The Problem

The main vertical scroll and the child horizontal scroll fight a lot consume the touches and end up with a bad experience.
Headout Feed

What's happening inside

As seen in the image, there is a root RecyclerView with a vertical LinearLayoutManager and child RecyclerView with horizontal LinearLayoutManager. If you are familiar with the android touch system (I suggest you watch Dave Smith's Mastering Android's touch system talk), you can observe that the root view is intercepting the touch when the user is trying to scroll horizontally even if the root can only scroll vertically. Let's look at onInterceptTouchEvent() in RecyclerView.java.

@Override
public boolean onInterceptTouchEvent(MotionEvent e) {  
  ...

  switch (action) {
    case MotionEvent.ACTION_DOWN:
        ...

    case MotionEvent.ACTION_MOVE: {
        ...

        if (mScrollState != SCROLL_STATE_DRAGGING) {
          boolean startScroll = false;
          if (canScrollHorizontally && Math.abs(dx) > mTouchSlop) {
            ...
            startScroll = true;
          }
          if (canScrollVertically && Math.abs(dy) > mTouchSlop) {
            ...
            startScroll = true;
          }
          if (startScroll) {
            setScrollState(SCROLL_STATE_DRAGGING);
          }
       }
    } break;
      ...

  }
  return mScrollState == SCROLL_STATE_DRAGGING;
}

Focus on this if condition

if(canScrollVertically && Math.abs(dy) > mTouchSlop) {...}  

Got it? RecyclerView doesn't care about the angle in which the user is trying to drag/scroll the view, it just cares whether there is enough movement in the scrollable direction. Which might be the desired behaviour if there is no horizontally scrolling child, but we do have one in our case. So what should be the right condition?

if(canScrollVertically && Math.abs(dy) > mTouchSlop && (canScrollHorizontally || Math.abs(dy) > Math.abs(dx))) {...}  

Similarly change the adjacent if block. We do this by writing our own BetterRecyclerView.java

You can find similar implementation inside ScrollView as well. Which (I guess) can be overridden in the same way.

Bonus

RecyclerViews take quite a bit of time to settle after a fling. As you can see, if the user tries to scroll vertically while the child is settling, the child consumes the touch. Headout Feed

Again let's look at onInterceptTouchEvent() of RecyclerView but this time from the child's perspective.

@Override
public boolean onInterceptTouchEvent(MotionEvent e) {  
    ...

    switch (action) {
        case MotionEvent.ACTION_DOWN:
            ...

            if (mScrollState == SCROLL_STATE_SETTLING) {
                getParent().requestDisallowInterceptTouchEvent(true);
                setScrollState(SCROLL_STATE_DRAGGING);
            }

            ...
    }
    return mScrollState == SCROLL_STATE_DRAGGING;
}

When the RecyclerView is settling and the user touches the child, the child requests the parent to not to intercept the touches.

Which is good.

In general.

But in our case, since our root now only intercepts if the user tries to scroll vertically and since we don't have any vertically draggable/scrollable child views, we went ahead and wrote a FeedRootRecyclerView

public class FeedRootRecyclerView extends BetterRecyclerView{  
  public FeedRootRecyclerView(Context context) {
    this(context, null);
  }

  public FeedRootRecyclerView(Context context, @Nullable AttributeSet attrs) {
    this(context, attrs, 0);
  }

  public FeedRootRecyclerView(Context context, @Nullable AttributeSet attrs, int defStyle) {
    super(context, attrs, defStyle);
  }

  @Override
  public void requestDisallowInterceptTouchEvent(boolean disallowIntercept) {
    /* do nothing */
  }
}

You can play with sample demo and fiddle with settings in the drawer. Make sure you have the kotlin plugin installed in your Android Studio.

Demo App

comments powered by Disqus