ScrollView 内的 HorizontalScrollView 触摸处理

228
我有一个ScrollView,它包围整个布局,以便整个屏幕都可以滚动。我在这个ScrollView中的第一个元素是一个HorizontalScrollView块,其中包含可以水平滚动的功能。我已经为horizontalscrollview添加了ontouchlistener来处理触摸事件,并在ACTION_UP事件上强制视图“捕捉”到最近的图像。
所以我想要的效果就像股票android主屏幕一样,你可以从一个屏幕滑动到另一个屏幕,当你抬起手指时,它会捕捉到一个屏幕。
这一切都很好,除了一个问题:我需要左右滑动几乎完美地水平才能注册ACTION_UP。如果我在最少的情况下垂直滑动(我认为许多人在手机上左右滑动时倾向于这样做),我将收到ACTION_CANCEL而不是ACTION_UP。我的理论是,这是因为horizontalscrollview在scrollview中,而scrollview正在劫持垂直触摸以允许垂直滚动。
如何仅在我的水平滚动视图内禁用scrollview的触摸事件,但仍允许在scrollview的其他位置进行正常的垂直滚动?
以下是我的代码示例:
   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的HorizontalListView库。 - The Nomad
这里有一篇文章包含一些类似的代码(HomeFeatureLayout extends HorizontalScrollView):http://www.velir.com/blog/index.php/2010/11/17/android-snapping-horizontal-scroll/。在自定义滚动类被组合时,还有一些关于正在发生的事情的额外注释。 - CJBS
9个回答

285

更新:我解决了这个问题。在我的ScrollView上,我需要重写onInterceptTouchEvent方法,只有当Y轴移动大于X轴移动时才拦截触摸事件。似乎ScrollView的默认行为是在任何Y轴移动时拦截触摸事件。因此,通过这个修复,ScrollView只会在用户有意向纵向滚动并且在该情况下将ACTION_CANCEL传递给子项时拦截事件。

以下是包含HorizontalScrollView的Scroll View类的代码:

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

3
我刚遇到了一个值得一提的小缺陷。我认为onInterceptTouchEvent中的代码应该将两个boolean调用分开,以保证mGestureDetector.onTouchEvent(ev)被调用。现在如果 super.onInterceptTouchEvent(ev)是false,它就不会被调用。我刚刚遇到了这样一种情况:可点击的滚动视图中的子元素可以抓取触摸事件,而onScroll根本不会被调用。否则,非常感谢,回答很棒! - GLee
完美运作!在从视图页开始拖动时仍能滚动列表视图。伟大的解决方案!简单而且实用! - tbraun
你能帮我解决这个问题吗?https://dev59.com/o4vda4cB1Zd3GeqPWDGp#30417535 - user2800040
添加了垂直滚动后,工作正常,但是水平滚动视图不太流畅...我的情况是在ScrollView中有4个ViewPager。 - NareshRavva
我使用了类似的东西,与SwipeRefreshLayout相似。 - Shubham Chaudhary
显示剩余6条评论

179

非常感谢Joel给了我解决这个问题的线索。

我简化了代码(不需要使用GestureDetector),以达到同样的效果:

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

1
谢谢!还使用了自定义的ListView,在其中使用了ViewPager。 - Sharief Shaik
2
刚刚用这个替换了被接受的答案,现在对我来说工作得更好了。谢谢! - David Scott
1
@VipinSahu,要判断触摸移动的方向,可以取当前X坐标和lastX的差值,如果大于0,则表示触摸从左到右移动,否则从右到左。然后将当前X保存为下一次计算的lastX。 - neevek
1
水平滚动视图怎么处理? - Zin Win Htet
添加了垂直滚动后,工作正常,但是水平滚动视图不太流畅...我的情况是在滚动视图中有4个ViewPager。 - NareshRavva
显示剩余11条评论

60
我认为我找到了一个更简单的解决方案,只需要使用ViewPager的子类而不是(其父类)ScrollView。
更新于2013-07-16:我也添加了对onTouchEvent的覆盖。它可能有助于解决评论中提到的问题,但效果因人而异。
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;
    }
}

这类似于android.widget.Gallery的onScroll()中使用的技术。Google I/O 2013演示文稿《为Android编写自定义视图》对此进行了进一步解释。
更新于2013-12-10:Kirill Grouchnikov在一篇文章中也描述了类似的方法,该文章是关于(当时的)Android Market应用程序的。

boolean ret = super.onInterceptTouchEvent(ev); 对我来说始终返回false。在ScrollView中使用UninterceptableViewPager。 - scottyab
这对我不起作用,尽管我喜欢它的简洁性。我使用一个包含 UninterceptableViewPagerLinearLayoutScrollView。事实上,ret 总是为 false...有什么线索可以解决这个问题吗? - Peterdk
@scottyab和@Peterdk:我的代码在ScrollView中,其中包含一个TableLayout,而TableRow又在TableLayout中(是的,我知道...),并且它按预期工作。也许你可以尝试重写onScroll而不是onInterceptTouchEvent,就像Google这样做(第1010行)。 - Giorgos Kylafas
我刚刚将ret硬编码为真,并且它运行正常。之前它返回false,但我相信那是因为scrollview有一个线性布局,它容纳了所有的子元素。 - Amanni

14

我发现有时候一个ScrollView会获得焦点,而另一个则失去焦点。您可以通过只授权其中一个scrollView获得焦点来防止这种情况:

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

你传递了什么适配器? - Harsha M V
我再次检查后发现,实际上我使用的不是 ScrollView,而是 ViewPager,我正在传递给它一个 FragmentStatePagerAdapter,其中包含整个图库的所有图像。 - Marius Hilarious

8

这个东西对我来说一直不太好用。我进行了更改,现在它运行得很顺畅。如果有人感兴趣。

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

这是我的项目答案。我有一个视图翻页器,它充当一个可以在滚动视图中单击的画廊。我使用上面提供的解决方案,在水平滚动上运行良好,但是在我单击翻页器的图像启动新活动并返回后,翻页器无法滚动。这很有效,谢谢! - longkai
对我来说完美运作。我在滚动视图中有一个自定义的“滑动解锁”视图,给了我同样的麻烦。这个解决方案解决了这个问题。 - hybrid

6

感谢Neevek的答案,它对我有用,但当用户开始在水平方向上滚动水平视图(ViewPager)并且没有抬起手指垂直滚动时,它不会锁定垂直滚动,并且开始滚动底层容器视图(ScrollView)。我通过对Neevak代码进行轻微更改来解决这个问题:

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

}

5
这最终成为了 support v4 库的一部分,NestedScrollView。所以我猜大多数情况下不再需要本地 hack。

1
Neevek的解决方案在运行3.2及以上版本的设备上比Joel的更有效。在Android中存在一个错误,如果在scollview中使用手势检测器,将会导致java.lang.IllegalArgumentException: pointerIndex超出范围。要复制该问题,请按照Joel建议实现自定义scollview并将视图分页器放置在其中。如果你向一个方向(左/右)拖动(不要抬起手指),然后转向相反方向,你会看到崩溃。此外,在Joel的解决方案中,如果你用手指对视图分页器进行对角线移动,一旦手指离开视图分页器的内容视图区域,分页器就会弹回到先前的位置。所有这些问题更多是与Android内部设计或缺乏相关,而不是Joel的实现本身,它本身是一段聪明而简洁的代码。

http://code.google.com/p/android/issues/detail?id=18990


0

日期:2021年5月12日

看起来像乱码,但是相信我,如果你想在垂直滚动视图中水平滚动任何视图而且非常流畅,这个方法值得一试!

在Jetpack Compose中也可以通过创建一个自定义视图并扩展要水平滚动的视图;将其放置在垂直滚动视图中,并在AndroidView组合中使用该自定义视图(现在,“Jetpack Compose处于1.0.0-beta06”)。

如果您想自由水平和垂直滚动而不被垂直滚动条拦截触摸事件,只有当您通过水平滚动视图垂直滚动时才允许垂直滚动条拦截触摸事件,那么这是最优解决方案:

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

在这里,ViewThatYouWannaScrollHorizontally 是您想要水平滚动的视图,当您水平滚动时,您不希望垂直滚动条捕获触摸并认为"哦!用户正在垂直滚动",因此 parent.requestDisallowInterceptTouchEvent(true) 基本上会告诉垂直滚动条"嘿你!不要捕获任何触摸,因为用户正在水平滚动"

当用户完成水平滚动并尝试通过放置在垂直滚动条内部的水平滚动条垂直滚动时,它将看到Y轴中的触摸速度大于X轴,这表明用户没有水平滚动,水平滚动内容将说"嘿你!父级,你听到我了吗?..用户正在通过我垂直滚动,现在你可以拦截触摸并显示我下面的内容在垂直滚动中"


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接