HorizontalScrollView within ScrollView Touch Handling

AndroidOntouchlistenerAndroid ScrollviewHorizontalscrollview

Android Problem Overview


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

Android Solutions


Solution 1 - Android

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

Solution 2 - Android

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

Solution 3 - Android

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.

Solution 4 - Android

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

Solution 5 - Android

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

Solution 6 - Android

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

Solution 7 - Android

This finally became a part of support v4 library, NestedScrollView. So, no longer local hacks is needed for most of cases I'd guess.

Solution 8 - Android

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

Solution 9 - Android

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"

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
QuestionJoelView Question on Stackoverflow
Solution 1 - AndroidJoelView Answer on Stackoverflow
Solution 2 - AndroidneevekView Answer on Stackoverflow
Solution 3 - AndroidGiorgos KylafasView Answer on Stackoverflow
Solution 4 - AndroidMarius HilariousView Answer on Stackoverflow
Solution 5 - AndroidsnapixView Answer on Stackoverflow
Solution 6 - AndroidSaqibView Answer on Stackoverflow
Solution 7 - AndroidEbrahim ByagowiView Answer on Stackoverflow
Solution 8 - AndroidDonView Answer on Stackoverflow
Solution 9 - AndroidRishad BaniyaView Answer on Stackoverflow