HorizontalScrollView inside SwipeRefreshLayout

AndroidHorizontalscrollviewSwiperefreshlayout

Android Problem Overview


I implemented the new SwipeRefreshLayout component in my application and it works well with any vertical views, like ListView, GridView and ScrollView.

It behaves very bad with horizontal views, like HorizontalScrollView. When scrolling to the right or left, the SwipeRefreshLayout view caches the touch, prevents the HorizontalScrollView from receiving it and starts scrolling vertically to perform the refresh.

I tried solving this issue as I previously solved issues with vertical ScrollView with ViewPager inside, using requestDisallowInterceptTouchEvent but it didn't work. I also noticed that this method is overridden in the original SwipeRefreshLayout class without returning the super. Google's developer left a comment instead "//Nope." :)

Because SwipeRefreshLayout component is relatively new, I couldn't find a solution that fixes the horizontal scroll issue while still allowing the swipe to refresh view to track and handle vertical scrolling so I thought I'll share my solution with hopes it will spare someone an hour or two.

Android Solutions


Solution 1 - Android

I solved it by extending SwipeRefreshLayout and overriding its onInterceptTouchEvent. Inside, I calculate if the X distance the user has wandered is bigger than the touch slop. If it does, it means the user is swiping horizontally, therefor I return false which lets the child view (the HorizontalScrollView in this case) to get the touch event.


public class CustomSwipeToRefresh extends SwipeRefreshLayout {



private int mTouchSlop;
private float mPrevX;

public CustomSwipeToRefresh(Context context, AttributeSet attrs) {
    super(context, attrs);

    mTouchSlop = ViewConfiguration.get(context).getScaledTouchSlop();
}

@Override
public boolean onInterceptTouchEvent(MotionEvent event) {

    switch (event.getAction()) {
        case MotionEvent.ACTION_DOWN:
            mPrevX = MotionEvent.obtain(event).getX();
            break;

        case MotionEvent.ACTION_MOVE:
            final float eventX = event.getX();
            float xDiff = Math.abs(eventX - mPrevX);

            if (xDiff > mTouchSlop) {
                return false;
            }
    }

    return super.onInterceptTouchEvent(event);
}




}

}

Solution 2 - Android

If you do not memorize the fact that you already declined the ACTION_MOVE event, you will eventually take it later if the user go back near your initial mPrevX.

Just add a boolean to memorize it.

public class CustomSwipeToRefresh extends SwipeRefreshLayout {

    private int mTouchSlop;
    private float mPrevX;
    // Indicate if we've already declined the move event
    private boolean mDeclined;

    public CustomSwipeToRefresh(Context context, AttributeSet attrs) {
        super(context, attrs);

        mTouchSlop = ViewConfiguration.get(context).getScaledTouchSlop();
    }

    @Override
    public boolean onInterceptTouchEvent(MotionEvent event) {

        switch (event.getAction()) {
            case MotionEvent.ACTION_DOWN:
                mPrevX = MotionEvent.obtain(event).getX();
                mDeclined = false; // New action
                break;

            case MotionEvent.ACTION_MOVE:
                final float eventX = event.getX();
                float xDiff = Math.abs(eventX - mPrevX);

                if (mDeclined || xDiff > mTouchSlop) {
                    mDeclined = true; // Memorize
                    return false;
                }
        }

        return super.onInterceptTouchEvent(event);
    }
}

Solution 3 - Android

The solution proposed by Lior Iluz with overriding onInterceptTouchEvent() has a serious issue. If the content scrollable container is not fully scrolled-up, then it may be not possible to activate swipe-to-refresh in the same scroll-up gesture. Indeed, when you start scrolling the inner container and move finger horizontally more then mTouchSlop unintentionally (which is 8dp by default), the proposed CustomSwipeToRefresh declines this gesture. So a user has to try once more to start refreshing. This may look odd for the user.

I extracted the source code of the original SwipeRefreshLayout from the support library to my project and re-wrote the onInterceptTouchEvent(). The new class name is TouchSafeSwipeRefreshLayout

private boolean mPendingActionDown;
private float mInitialDownY;
private float mInitialDownX;
private boolean mGestureDeclined;

@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
    ensureTarget();
    final int action = ev.getActionMasked();
    int pointerIndex;

    if (mReturningToStart && action == MotionEvent.ACTION_DOWN) {
        mReturningToStart = false;
    }
    
    if (!isEnabled() || mReturningToStart || mRefreshing ) {
        // Fail fast if we're not in a state where a swipe is possible
        if (D) Log.e(LOG_TAG, "Fail because of not enabled OR refreshing OR returning to start. "+motionEventToShortText(ev));
        return false;
    }
    
    switch (action) {
        case MotionEvent.ACTION_DOWN:
            setTargetOffsetTopAndBottom(mOriginalOffsetTop - mCircleView.getTop());
            mActivePointerId = ev.getPointerId(0);

            if ((pointerIndex = ev.findPointerIndex(mActivePointerId)) >= 0) {

                if (mNestedScrollInProgress || canChildScrollUp()) {
                    if (D) Log.e(LOG_TAG, "Fail because of nested content is Scrolling. Set pending DOWN=true. "+motionEventToShortText(ev));
                    mPendingActionDown = true;
                } else {
                    mInitialDownX = ev.getX(pointerIndex);
                    mInitialDownY = ev.getY(pointerIndex);
                }
            }
            return false;

        case MotionEvent.ACTION_MOVE:
            if (mActivePointerId == INVALID_POINTER) {
                if (D) Log.e(LOG_TAG, "Got ACTION_MOVE event but don't have an active pointer id.");
                return false;
            } else if (mGestureDeclined) {
                if (D) Log.e(LOG_TAG, "Gesture was declined previously because of horizontal swipe");
                return false;
            } else if ((pointerIndex = ev.findPointerIndex(mActivePointerId)) < 0) {
                return false;
            } else if (mNestedScrollInProgress || canChildScrollUp()) {
                if (D) Log.e(LOG_TAG, "Fail because of nested content is Scrolling. "+motionEventToShortText(ev));
                return false;
            } else if (mPendingActionDown) {
                // This is the 1-st Move after content stops scrolling.
                // Consider this Move as Down (a start of new gesture)
                if (D) Log.e(LOG_TAG, "Consider this move as down - setup initial X/Y."+motionEventToShortText(ev));
                mPendingActionDown = false;
                mInitialDownX = ev.getX(pointerIndex);
                mInitialDownY = ev.getY(pointerIndex);
                return false;
            } else if (Math.abs(ev.getX(pointerIndex) - mInitialDownX) > mTouchSlop) {
                mGestureDeclined = true;
                if (D) Log.e(LOG_TAG, "Decline gesture because of horizontal swipe");
                return false;
            }

            final float y = ev.getY(pointerIndex);
            startDragging(y);
            if (!mIsBeingDragged) {
                if (D) Log.d(LOG_TAG, "Waiting for dY to start dragging. "+motionEventToShortText(ev));
            } else {
                if (D) Log.d(LOG_TAG, "Dragging started! "+motionEventToShortText(ev));
            }
            break;

        case MotionEvent.ACTION_POINTER_UP:
            onSecondaryPointerUp(ev);
            break;

        case MotionEvent.ACTION_UP:
        case MotionEvent.ACTION_CANCEL:
            mIsBeingDragged = false;
            mGestureDeclined = false;
            mPendingActionDown = false;
            mActivePointerId = INVALID_POINTER;
            break;
    }

    return mIsBeingDragged;
}

See my example project on Github.

Solution 4 - Android

If you use the Tim Roes EnhancedListView

See this issues. I was very usefull for me because they add a fonction who detect when the swipe begin and when the swipe finish.

When swipe begin, i disable the SwipeRefreshLayout and when swipe finish, i enble the swipeRefreshLayout.

Solution 5 - Android

Here is what I did:

class HorizontalScrollViewWithDragListener
    @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) : HorizontalScrollView(context, attrs) {
    var draggingState: Boolean = false
        set(value) {
            if (field != value) {
                field = value
                listener?.invoke(value)
            }
        }

    var listener: ((draggingState: Boolean) -> Unit)? = null

    override fun onInterceptTouchEvent(ev: MotionEvent) =
        super.onInterceptTouchEvent(ev)
            .also { draggingState = it }


    @SuppressLint("ClickableViewAccessibility")
    override fun onTouchEvent(ev: MotionEvent) =
        super.onTouchEvent(ev)
            .also {
                if(ev.action == MotionEvent.ACTION_UP) {
                    draggingState = false
                }
            }
}

Then I just do this in setup code:

   myScrollView.listener = { refreshView.isEnabled = !it }

Attributions

All content for this solution is sourced from the original question on Stackoverflow.

The content on this page is licensed under the Attribution-ShareAlike 4.0 International (CC BY-SA 4.0) license.

Content TypeOriginal AuthorOriginal Content on Stackoverflow
QuestionLior IluzView Question on Stackoverflow
Solution 1 - AndroidLior IluzView Answer on Stackoverflow
Solution 2 - AndroidTwibitView Answer on Stackoverflow
Solution 3 - AndroidStanislav PerchenkoView Answer on Stackoverflow
Solution 4 - AndroidJNDView Answer on Stackoverflow
Solution 5 - AndroidDale KingView Answer on Stackoverflow