228

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;
        }
    }
}
Ali Khaki
  • 1,184
  • 1
  • 13
  • 24
Joel
  • 6,193
  • 6
  • 22
  • 22
  • I've tried all the methods in this post, but none of them work for me. I am using `MeetMe's HorizontalListView` library. – The Nomad Jun 12 '14 at 08:02
  • There's an article with some similar code (`HomeFeatureLayout extends HorizontalScrollView`) here http://www.velir.com/blog/index.php/2010/11/17/android-snapping-horizontal-scroll/ There are some additional comments about what's going on as the custom scroll class is composed. – CJBS Sep 04 '14 at 17:58

9 Answers9

285

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);
        }
    }
}
Shubham Chaudhary
  • 47,722
  • 9
  • 78
  • 80
Joel
  • 6,193
  • 6
  • 22
  • 22
  • i am using this same but at the time of scrolling in x direction I am getting exception NULL POINTER EXCEPTION at public boolean onInterceptTouchEvent(MotionEvent ev) { return super.onInterceptTouchEvent(ev)&& mGestureDetector.onTouchEvent(ev); } I have used horizontall scroll inside scrollview – Vipin Sahu Aug 20 '12 at 11:59
  • @All thanks it's working , I forget to initialized the gesture detector in scrollview constructor – Vipin Sahu Aug 20 '12 at 12:27
  • How should i use this code for implementing a ViewPager inside ScrollView – Harsha M V Dec 11 '12 at 19:40
  • I had some issues with this code when I had an always expanded grid view in it, sometimes it wouldn't scroll. I had to override onDown(...) method in YScrollDetector to always return true as suggested throughout documentation (like here http://developer.android.com/training/custom-views/making-interactive.html) This solved my issue. – Nemanja Kovacevic Feb 06 '13 at 13:28
  • 3
    I just ran into a small bug that's worth mentioning. I believe that the code in onInterceptTouchEvent should split out the two boolean calls, to guarantee that `mGestureDetector.onTouchEvent(ev)` will get called. As it is now, it won't get called if `super.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! – GLee Nov 05 '13 at 00:46
  • Works perfectly! Still able to scroll the list view when start dragging from the view pager. Great solution! Simple and get the work done! – tbraun Apr 15 '14 at 11:50
  • @Joel I am using the HLV library http://www.dev-smart.com/archives/34. Facing the same problem. I tried with Math.abs(distanceY) > Math.abs(distanceX) in onScroll of com.devsmart.android.ui.HorizontalListView class. Still I am facing the same problem. Could you pls suggest some ideas? – Karthikeyan Ve Mar 23 '15 at 15:37
  • Can you help me with http://stackoverflow.com/questions/30417495/android-listview-inside-viewpager-fragement-no-scroll/30417535 – user2800040 May 25 '15 at 14:32
  • after adding vertical scrollowing working fine, but horizontal scroll view not working smoothly... my situation is 4 viewpagers in scrollview – NareshRavva Jun 09 '15 at 14:03
  • I used similar stuff with SwipeRefreshLayout. – Shubham Chaudhary Sep 30 '15 at 04:43
  • How do I use class CustomScrollView in my activity? – joeabala Sep 08 '16 at 07:36
178

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);
    }
}
Suragch
  • 484,302
  • 314
  • 1,365
  • 1,393
neevek
  • 11,760
  • 8
  • 55
  • 73
  • excellent, thanks for this refactor. I was getting issues with the above approach when scrolling to the bottom of the listView as any touch behaviour over child elements started to not be intercepted no matter what the Y/X movement ratio. Weird! – Dori Jan 25 '12 at 20:06
  • 1
    Thanks! Also worked with a ViewPager inside a ListView, with a custom ListView. – Sharief Shaik Feb 16 '12 at 06:55
  • 2
    Just replaced the accepted answer with this and it's working much better for me now. Thanks! – David Scott Jul 10 '12 at 15:41
  • thanks and great work But Neevek would mind telling me to guess the direction of X like one using Gesture detector is easy to find . I wish to find from the above code – Vipin Sahu Aug 22 '12 at 11:12
  • 1
    @VipinSahu, to tell the direction of touch move, you can take the delta of current X coordinate and lastX, if it is larger than 0, touch is moving from left to right, otherwise right to left. And then you save the current X as lastX for next calculation. – neevek Aug 23 '12 at 01:25
  • You can always intercept user touch events by overriding `View.onTouchEvent()`, by returning `true` from this method, you tell its parent view that you have handled/digested the event and the parent view should/need not handle the event again. – neevek Aug 23 '12 at 08:56
  • THanks! this approach worked better for me. The solution @joel posted caused an issue where I could not scroll the scrollview vertical on the viewpager. This works great. – Patrick Jackson Oct 02 '12 at 17:07
  • This works better for me. The other solutions makes scrolling feel unnatural, since it blocks scroll events till the initial scroll is done! – Leo Dec 11 '12 at 01:16
  • The other answer sorta works, but is touchy. This one is rock solid. – Adam Aug 02 '13 at 00:01
  • I had lots of problems with the answer with GestureDetector, this ones works great. – tasomaniac Aug 06 '13 at 08:32
  • 1
    what about horizontal scrollview? – Zin Win Htet Dec 03 '14 at 09:10
  • This same worked for me when having a ViewPager inside a ScrollView. – roshi Apr 30 '15 at 05:16
  • after adding vertical scrollowing working fine, but horizontal scroll view not working smoothly... my situation is 4 viewpagers in scrollview – NareshRavva Jun 09 '15 at 14:04
  • if you still having problems with viewpager's horizontal scroll after this solution, see my answer here: http://stackoverflow.com/a/33696740/2093236 – Dmide Nov 13 '15 at 15:58
  • how do you use class VerticalScrollView in your Activity? – joeabala Sep 08 '16 at 07:11
  • **onInterceptTouchEvent** continue receive events if you return **false** and **ACTION_MOVE** may be called multiple times. Therefore `xDistance > yDistance` calculates multiple times too. User can start **horizontal** gesture then switch direction to **vertical** and when total distance become greater then distance of initial direction **parent** will intercept Touch Events therefore child receive **ACTION_CANCEL**. I'm using bool flag to calculate direction only **once** per whole gesture. e.g. set flag after first calculation(inside **ACTION_MOVE**) and reset in **ACTION_DOWN**. – CeH9 Aug 15 '18 at 13:20
60

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.

Giorgos Kylafas
  • 2,243
  • 25
  • 25
  • boolean ret = super.onInterceptTouchEvent(ev); only ever returned false for me. Using on UninterceptableViewPager in a Scrollview – scottyab Nov 13 '12 at 16:47
  • This doesn't work for me, though I like it's simplicity. I use a `ScrollView` with a `LinearLayout` in which the `UninterceptableViewPager` is placed. Indeed, `ret` always is false... Any clue how to fix this? – Peterdk May 27 '13 at 23:54
  • @scottyab & @Peterdk: Well, mine is in a `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](https://android.googlesource.com/platform/frameworks/base/+/android-4.2.2_r1.2/core/java/android/widget/Gallery.java) (line 1010) – Giorgos Kylafas May 31 '13 at 07:11
  • I've just hardcoded ret as true and it works fine. It was returning false earlier on but I believe that's because the scrollview has a linear layout which holds all the children – Amanni Jun 07 '13 at 09:58
14

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;
        }
    });
Marius Hilarious
  • 855
  • 9
  • 18
8

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;
        }
    }
}
Suragch
  • 484,302
  • 314
  • 1,365
  • 1,393
snapix
  • 344
  • 2
  • 8
  • This is the answer for my project. I have a view pager which act as a gallery can be click in a scrollview. I use the solution provide above, it works on horizontal scroll, but after i click the pager' s image which start a new activity and go back, the pager cannot scroll. This works well, tks! – longkai Aug 29 '14 at 03:04
  • Works perfectly for me. I had a custom "swipe to unlock" view inside a scroll view which was giving me the same trouble. This solution solved the issue. – hybrid Oct 03 '18 at 03:14
6

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

}
Saqib
  • 1,737
  • 2
  • 19
  • 30
5

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

Ebrahim Byagowi
  • 10,338
  • 4
  • 70
  • 81
1

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

Don
  • 221
  • 1
  • 3
0

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"

Rishad Baniya
  • 665
  • 8
  • 14