HorizontalScrollView within ScrollView Touch Handling
Asked Answered
S

9

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;
        }
    }
}
Sericeous answered 15/4, 2010 at 14:12 Comment(2)
I've tried all the methods in this post, but none of them work for me. I am using MeetMe's HorizontalListView library.Montane
There's an article with some similar code (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.Justinejustinian
S
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);
        }
    }
}
Sericeous answered 16/4, 2010 at 19:51 Comment(11)
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 scrollviewHulse
@All thanks it's working , I forget to initialized the gesture detector in scrollview constructorHulse
How should i use this code for implementing a ViewPager inside ScrollViewMimetic
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 developer.android.com/training/custom-views/…) This solved my issue.Computer
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!Worry
Works perfectly! Still able to scroll the list view when start dragging from the view pager. Great solution! Simple and get the work done!Th
@Sericeous I am using the HLV library 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?Evansville
Can you help me with #30417995Guacharo
after adding vertical scrollowing working fine, but horizontal scroll view not working smoothly... my situation is 4 viewpagers in scrollviewOnondaga
I used similar stuff with SwipeRefreshLayout.Linders
How do I use class CustomScrollView in my activity?Plaything
Q
180

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);
    }
}
Quinonez answered 9/10, 2011 at 13:12 Comment(17)
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!Colatitude
Thanks! Also worked with a ViewPager inside a ListView, with a custom ListView.Glossography
Just replaced the accepted answer with this and it's working much better for me now. Thanks!Hairsplitting
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 codeHulse
@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.Quinonez
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.Quinonez
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.Guideline
This works better for me. The other solutions makes scrolling feel unnatural, since it blocks scroll events till the initial scroll is done!Dowd
The other answer sorta works, but is touchy. This one is rock solid.Liu
I had lots of problems with the answer with GestureDetector, this ones works great.Gobble
what about horizontal scrollview?Ingroup
This same worked for me when having a ViewPager inside a ScrollView.Saucedo
after adding vertical scrollowing working fine, but horizontal scroll view not working smoothly... my situation is 4 viewpagers in scrollviewOnondaga
if you still having problems with viewpager's horizontal scroll after this solution, see my answer here: https://mcmap.net/q/36892/-viewpager-inside-a-scrollview-does-not-scroll-correcltyJapheth
how do you use class VerticalScrollView in your Activity?Plaything
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.Seriocomic
Terrific solution! Works well with the ViewPager2 being a child of this customized ScrollView.Uncle
R
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.

Reproduction answered 24/4, 2012 at 9:15 Comment(4)
boolean ret = super.onInterceptTouchEvent(ev); only ever returned false for me. Using on UninterceptableViewPager in a ScrollviewOsborne
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?Argentina
@Osborne & @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 (line 1010)Reproduction
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 childrenEsdras
C
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;
        }
    });
Clearance answered 27/11, 2012 at 20:10 Comment(2)
what adapter are u passing ?Mimetic
I checked again and realised that it's not actually a ScrollView I'm using, but a ViewPager and I'm passing it a FragmentStatePagerAdapter which includes all the images for the gallery.Clearance
M
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;
        }
    }
}
Mourning answered 5/3, 2014 at 13:58 Comment(2)
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!Adda
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.Italia
T
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);

}
Tautog answered 7/11, 2013 at 18:28 Comment(0)
P
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.

Pycnometer answered 18/10, 2015 at 8:27 Comment(0)
A
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

Andyane answered 1/2, 2013 at 20:53 Comment(0)
M
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"

Millais answered 12/5, 2021 at 11:40 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.