I have a ScrollView that surrounds my entire layout so that the entire screen is scrollable. The first element I have in this ScrollView is a HorizontalScrollView block that has features that can be scrolled through horizontally. I've added an ontouchlistener to the horizontalscrollview to handle touch events and force the view to "snap" to the closest image on the ACTION_UP event.
So the effect I'm going for is like the stock android homescreen where you can scroll from one to the other and it snaps to one screen when you lift your finger.
This all works great except for one problem: I need to swipe left to right almost perfectly horizontally for an ACTION_UP to ever register. If I swipe vertically in the very least (which I think many people tend to do on their phones when swiping side to side), I will receive an ACTION_CANCEL instead of an ACTION_UP. My theory is that this is because the horizontalscrollview is within a scrollview, and the scrollview is hijacking the vertical touch to allow for vertical scrolling.
How can I disable the touch events for the scrollview from just within my horizontal scrollview, but still allow for normal vertical scrolling elsewhere in the scrollview?
Here's a sample of my code:
public class HomeFeatureLayout extends HorizontalScrollView {
private ArrayList<ListItem> items = null;
private GestureDetector gestureDetector;
View.OnTouchListener gestureListener;
private static final int SWIPE_MIN_DISTANCE = 5;
private static final int SWIPE_THRESHOLD_VELOCITY = 300;
private int activeFeature = 0;
public HomeFeatureLayout(Context context, ArrayList<ListItem> items){
super(context);
setLayoutParams(new LayoutParams(LayoutParams.FILL_PARENT, LayoutParams.WRAP_CONTENT));
setFadingEdgeLength(0);
this.setHorizontalScrollBarEnabled(false);
this.setVerticalScrollBarEnabled(false);
LinearLayout internalWrapper = new LinearLayout(context);
internalWrapper.setLayoutParams(new LayoutParams(LayoutParams.FILL_PARENT, LayoutParams.FILL_PARENT));
internalWrapper.setOrientation(LinearLayout.HORIZONTAL);
addView(internalWrapper);
this.items = items;
for(int i = 0; i< items.size();i++){
LinearLayout featureLayout = (LinearLayout) View.inflate(this.getContext(),R.layout.homefeature,null);
TextView header = (TextView) featureLayout.findViewById(R.id.featureheader);
ImageView image = (ImageView) featureLayout.findViewById(R.id.featureimage);
TextView title = (TextView) featureLayout.findViewById(R.id.featuretitle);
title.setTag(items.get(i).GetLinkURL());
TextView date = (TextView) featureLayout.findViewById(R.id.featuredate);
header.setText("FEATURED");
Image cachedImage = new Image(this.getContext(), items.get(i).GetImageURL());
image.setImageDrawable(cachedImage.getImage());
title.setText(items.get(i).GetTitle());
date.setText(items.get(i).GetDate());
internalWrapper.addView(featureLayout);
}
gestureDetector = new GestureDetector(new MyGestureDetector());
setOnTouchListener(new View.OnTouchListener() {
@Override
public boolean onTouch(View v, MotionEvent event) {
if (gestureDetector.onTouchEvent(event)) {
return true;
}
else if(event.getAction() == MotionEvent.ACTION_UP || event.getAction() == MotionEvent.ACTION_CANCEL ){
int scrollX = getScrollX();
int featureWidth = getMeasuredWidth();
activeFeature = ((scrollX + (featureWidth/2))/featureWidth);
int scrollTo = activeFeature*featureWidth;
smoothScrollTo(scrollTo, 0);
return true;
}
else{
return false;
}
}
});
}
class MyGestureDetector extends SimpleOnGestureListener {
@Override
public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) {
try {
//right to left
if(e1.getX() - e2.getX() > SWIPE_MIN_DISTANCE && Math.abs(velocityX) > SWIPE_THRESHOLD_VELOCITY) {
activeFeature = (activeFeature < (items.size() - 1))? activeFeature + 1:items.size() -1;
smoothScrollTo(activeFeature*getMeasuredWidth(), 0);
return true;
}
//left to right
else if (e2.getX() - e1.getX() > SWIPE_MIN_DISTANCE && Math.abs(velocityX) > SWIPE_THRESHOLD_VELOCITY) {
activeFeature = (activeFeature > 0)? activeFeature - 1:0;
smoothScrollTo(activeFeature*getMeasuredWidth(), 0);
return true;
}
} catch (Exception e) {
// nothing
}
return false;
}
}
}
MeetMe's HorizontalListView
library.
HomeFeatureLayout extends HorizontalScrollView
) here velir.com/blog/index.php/2010/11/17/… There are some additional comments about what's going on as the custom scroll class is composed.
Update: I figured this out. On my ScrollView, I needed to override the onInterceptTouchEvent method to only intercept the touch event if the Y motion is > the X motion. It seems like the default behavior of a ScrollView is to intercept the touch event whenever there is ANY Y motion. So with the fix, the ScrollView will only intercept the event if the user is deliberately scrolling in the Y direction and in that case pass off the ACTION_CANCEL to the children.
Here is the code for my Scroll View class that contains the HorizontalScrollView:
public class CustomScrollView extends ScrollView {
private GestureDetector mGestureDetector;
public CustomScrollView(Context context, AttributeSet attrs) {
super(context, attrs);
mGestureDetector = new GestureDetector(context, new YScrollDetector());
setFadingEdgeLength(0);
}
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
return super.onInterceptTouchEvent(ev) && mGestureDetector.onTouchEvent(ev);
}
// Return false if we're scrolling in the x direction
class YScrollDetector extends SimpleOnGestureListener {
@Override
public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) {
return Math.abs(distanceY) > Math.abs(distanceX);
}
}
}
Thank you Joel for giving me a clue on how to resolve this problem.
I have simplified the code(without need for a GestureDetector) to achieve the same effect:
public class VerticalScrollView extends ScrollView {
private float xDistance, yDistance, lastX, lastY;
public VerticalScrollView(Context context, AttributeSet attrs) {
super(context, attrs);
}
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
switch (ev.getAction()) {
case MotionEvent.ACTION_DOWN:
xDistance = yDistance = 0f;
lastX = ev.getX();
lastY = ev.getY();
break;
case MotionEvent.ACTION_MOVE:
final float curX = ev.getX();
final float curY = ev.getY();
xDistance += Math.abs(curX - lastX);
yDistance += Math.abs(curY - lastY);
lastX = curX;
lastY = curY;
if(xDistance > yDistance)
return false;
}
return super.onInterceptTouchEvent(ev);
}
}
I think I found a simpler solution, only this uses a subclass of ViewPager instead of (its parent) ScrollView.
UPDATE 2013-07-16: I added an override for onTouchEvent
as well. It could possibly help with the issues mentioned in the comments, although YMMV.
public class UninterceptableViewPager extends ViewPager {
public UninterceptableViewPager(Context context, AttributeSet attrs) {
super(context, attrs);
}
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
boolean ret = super.onInterceptTouchEvent(ev);
if (ret)
getParent().requestDisallowInterceptTouchEvent(true);
return ret;
}
@Override
public boolean onTouchEvent(MotionEvent ev) {
boolean ret = super.onTouchEvent(ev);
if (ret)
getParent().requestDisallowInterceptTouchEvent(true);
return ret;
}
}
This is similar to the technique used in android.widget.Gallery's onScroll(). It is further explained by the Google I/O 2013 presentation Writing Custom Views for Android.
Update 2013-12-10: A similar approach is also described in a post from Kirill Grouchnikov about the (then) Android Market app.
ScrollView
with a LinearLayout
in which the UninterceptableViewPager
is placed. Indeed, ret
always is false... Any clue how to fix this?
TableRow
which is inside a TableLayout
which is inside a ScrollView
(yeah, I know...), and it's working as intended. Maybe you could try overriding onScroll
instead of onInterceptTouchEvent
, like Google does it (line 1010)
I've found out that somethimes one ScrollView regains focus and the other loses focus. You can prevent that, by only granting one of the scrollView focus:
scrollView1= (ScrollView) findViewById(R.id.scrollscroll);
scrollView1.setAdapter(adapter);
scrollView1.setOnTouchListener(new View.OnTouchListener() {
@Override
public boolean onTouch(View v, MotionEvent event) {
scrollView1.getParent().requestDisallowInterceptTouchEvent(true);
return false;
}
});
It wasn't working well for me. I changed it and now it works smoothly. If anyone interested.
public class ScrollViewForNesting extends ScrollView {
private final int DIRECTION_VERTICAL = 0;
private final int DIRECTION_HORIZONTAL = 1;
private final int DIRECTION_NO_VALUE = -1;
private final int mTouchSlop;
private int mGestureDirection;
private float mDistanceX;
private float mDistanceY;
private float mLastX;
private float mLastY;
public ScrollViewForNesting(Context context, AttributeSet attrs,
int defStyle) {
super(context, attrs, defStyle);
final ViewConfiguration configuration = ViewConfiguration.get(context);
mTouchSlop = configuration.getScaledTouchSlop();
}
public ScrollViewForNesting(Context context, AttributeSet attrs) {
this(context, attrs,0);
}
public ScrollViewForNesting(Context context) {
this(context,null);
}
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
switch (ev.getAction()) {
case MotionEvent.ACTION_DOWN:
mDistanceY = mDistanceX = 0f;
mLastX = ev.getX();
mLastY = ev.getY();
mGestureDirection = DIRECTION_NO_VALUE;
break;
case MotionEvent.ACTION_MOVE:
final float curX = ev.getX();
final float curY = ev.getY();
mDistanceX += Math.abs(curX - mLastX);
mDistanceY += Math.abs(curY - mLastY);
mLastX = curX;
mLastY = curY;
break;
}
return super.onInterceptTouchEvent(ev) && shouldIntercept();
}
private boolean shouldIntercept(){
if((mDistanceY > mTouchSlop || mDistanceX > mTouchSlop) && mGestureDirection == DIRECTION_NO_VALUE){
if(Math.abs(mDistanceY) > Math.abs(mDistanceX)){
mGestureDirection = DIRECTION_VERTICAL;
}
else{
mGestureDirection = DIRECTION_HORIZONTAL;
}
}
if(mGestureDirection == DIRECTION_VERTICAL){
return true;
}
else{
return false;
}
}
}
Thanks to Neevek his answer worked for me but it doesn't lock the vertical scrolling when user has started scrolling the horizontal view(ViewPager) in horizontal direction and then without lifting the finger scroll vertically it starts to scroll the underlying container view(ScrollView). I fixed it by making a slight change in Neevak's code:
private float xDistance, yDistance, lastX, lastY;
int lastEvent=-1;
boolean isLastEventIntercepted=false;
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
switch (ev.getAction()) {
case MotionEvent.ACTION_DOWN:
xDistance = yDistance = 0f;
lastX = ev.getX();
lastY = ev.getY();
break;
case MotionEvent.ACTION_MOVE:
final float curX = ev.getX();
final float curY = ev.getY();
xDistance += Math.abs(curX - lastX);
yDistance += Math.abs(curY - lastY);
lastX = curX;
lastY = curY;
if(isLastEventIntercepted && lastEvent== MotionEvent.ACTION_MOVE){
return false;
}
if(xDistance > yDistance )
{
isLastEventIntercepted=true;
lastEvent = MotionEvent.ACTION_MOVE;
return false;
}
}
lastEvent=ev.getAction();
isLastEventIntercepted=false;
return super.onInterceptTouchEvent(ev);
}
This finally became a part of support v4 library, NestedScrollView. So, no longer local hacks is needed for most of cases I'd guess.
Neevek's solution works better than Joel's on devices running 3.2 and above. There is a bug in Android that will cause java.lang.IllegalArgumentException: pointerIndex out of range if a gesture detector is used inside a scollview. To duplicate the issue, implement a custom scollview as Joel suggested and put a view pager inside. If you drag (don't lift you figure) to one direction (left/right) and then to the opposite, you will see the crash. Also in Joel's solution, if you drag the view pager by moving your finger diagonally, once your finger leave the view pager's content view area, the pager will spring back to its previous position. All these issues are more to do with Android's internal design or lack of it than Joel's implementation, which itself is a piece of smart and concise code.
http://code.google.com/p/android/issues/detail?id=18990
Date : 2021 - May 12
Looks jibberish..but trust me its worth the time if you wanna scroll any view horizontally in a vertical scrollview butter smooth!!
Works in jetpack compose as well by by making a custom view and extending the view that you wanna scroll horizontally in; inside a vertical scroll view and using that custom view inside AndroidView composable (Right now, "Jetpack Compose is in 1.0.0-beta06"
This is the most optimal solution if you wanna scroll horizontally freely and vertically freely without the vertical scrollbar intercepting ur touch when u are scrolling horizontally and only allowing the vertical scrollbar to intercept the touch when u are scrolling vertically through the horizontal scrolling view :
private class HorizontallyScrollingView @JvmOverloads constructor(
context: Context, attrs: AttributeSet? = null
) : ViewThatYouWannaScrollHorizontally(context, attrs){
override fun onTouchEvent(event: MotionEvent?): Boolean {
// When the user's finger touches the webview and starts moving
if(event?.action == MotionEvent.ACTION_MOVE){
// get the velocity tracker object
val mVelocityTracker = VelocityTracker.obtain();
// connect the velocity tracker object with the event that we are emitting while we are touching the webview
mVelocityTracker.addMovement(event)
// compute the velocity in terms of pixels per 1000 millisecond(i.e 1 second)
mVelocityTracker.computeCurrentVelocity(1000);
// compute the Absolute Velocity in X axis
val xVelocityABS = abs(mVelocityTracker.getXVelocity(event?.getPointerId((event?.actionIndex))));
// compute the Absolute Velocity in Y axis
val yVelocityABS = abs(mVelocityTracker.getYVelocity(event?.getPointerId((event?.actionIndex))));
// If the velocity of x axis is greater than y axis then we'll consider that it's a horizontal scroll and tell the parent layout
// "Hey parent bro! im scrolling horizontally, this has nothing to do with ur scrollview so stop capturing my event and stay the f*** where u are "
if(xVelocityABS > yVelocityABS){
// So, we'll disallow the parent to listen to any touch events until i have moved my fingers off the screen
parent.requestDisallowInterceptTouchEvent(true)
}
} else if (event?.action == MotionEvent.ACTION_CANCEL || event?.action == MotionEvent.ACTION_UP){
// If the touch event has been cancelled or the finger is off the screen then reset it (i.e let the parent capture the touch events on webview as well)
parent.requestDisallowInterceptTouchEvent(false)
}
return super.onTouchEvent(event)
}
}
Here, ViewThatYouWannaScrollHorizontally
is the view that you want to scroll horizontally in and when u are scrolling horizontally, you dont want the vertical scrollbar to capture the touch and think that "oh! the user is scrolling vertically so parent.requestDisallowInterceptTouchEvent(true)
will basically say the vertical scroll bar "hey you! dont capture any touch coz the user is scrolling horizontally"
And after the user is done scrolling horizontally and tries to scroll vertically through the horizontal scrollbar which is placed inside a vertical scrollbar then it will see that the touch velocity in Y axis is greater than X axis, which shows user is not scrolling horizontally and the horizontal scrolling stuff will say "Hey you! parent, you hear me?..the user is scrolling vertically through me, now u can intercept the touch and show the stuffs present below me in the vertical scroll"
Success story sharing
mGestureDetector.onTouchEvent(ev)
will get called. As it is now, it won't get called ifsuper.onInterceptTouchEvent(ev)
is false. I just ran into a case where clickable children in the scrollview can grab the touch events and onScroll won't get called at all. Otherwise, thanks, great answer!