Reputation: 26573
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.
Upvotes: 59
Views: 17853
Reputation: 1
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 }
Upvotes: 0
Reputation: 380
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.
Upvotes: 1
Reputation: 26573
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);
}
}
Upvotes: 170
Reputation: 170
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.
Upvotes: 0
Reputation: 2254
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);
}
}
Upvotes: 28