美文网首页Android开发精选Let's AndroidAndroid
Android实践之ScrollView中滑动冲突处理

Android实践之ScrollView中滑动冲突处理

作者: 王三的猫阿德 | 来源:发表于2016-06-23 14:10 被阅读15675次

    转载注明出处:http://www.jianshu.com/p/87a41b8c0dd0

    前言

    在Android开发中,如果是一些简单的布局,都很容易搞定,但是一旦涉及到复杂的页面,特别是为了兼容小屏手机而使用了ScrollView以后,就会出现很多点击事件的冲突,最经典的就是ScrollView中嵌套了ListView。我想大部分刚开始接触Android的同学们都踩到过这个坑,这一篇文章就从最近做的一个项目讲起,然后在过程中提供一些解决冲突的思路。

    项目起始

    项目有一个页面,涉及到了ViewPager,MapView,ListView,也就是说在一个页面中,会有这三个View,很明显,屏幕无法完全显示,需要ScrollView来做一下支援,就引入了ScrollView作为外层的容器。但是由于这个页面的数据展示需要做到用户手动下拉刷新,于是又引入了官方的SwipeRefreshLayout。

    于是这个页面的布局就成了这样子。如下图(细节布局忽略)。


    布局图.png

    加入了ScrollView和SwipeRefreshLayout之后引入了新的问题,就是各个控件之间的事件冲突,嵌套在ScrollView中的ViewPager、MapView、ListView都需要能够正确的处理点击事件,特别是ListView,需求要求它在ScrollView中可以滑动,两种滑动混淆在一起,不是特别好处理。

    问题提出来了,下面直接看解决思路。

    解决滑动冲突的思路

    在ViewGroup中有个方法叫requestDisallowInterceptTouchEvent(boolean disallowIntercept),这个方法可以用来控制该ViewGroup是否截断点击事件。我们解决滑动冲突的时候,其实就是在某个时机去调用这个方法,让父布局不截断点击事件,将点击事件传递到子View,让相关的子View去处理。

    下面就是关于在项目中处理各种点击事件冲突的一些例子和思考。处理的方法只是提供一种思路,可能并不是最优的方法,肯定存在其他思路的解决方案。

    以下处理滑动冲突的方案都是在子View的OnTouchListener里面进行处理,并没有去复写控件的点击事件处理过程,在使用中还是比较方便的。

    MapView地图页面滑动冲突

    MapView与ScrollView的冲突主要在于,当用户点击到MapView地图并且滑动的时候,希望由地图Map去处理点击事件,并包括后续的滑动事件、双手指缩放地图等等。

    在ScrollView中,是会默认截断点击事件的,导致用户点击到地图后,地图基本是没有反应,更别谈双手指缩放地图了。

    用户手指点击到地图,并且滑动的时候,很难确定用户是要ScrollView上下滑动还是操控地图内容滑动,所以我简单的认为,只要用户手指点击到地图,就是要对地图进行操作;当用户手指抬起,就认为用户不需要操作地图了。

    解决思路也很简单,就是在用户点击到地图或者滑动地图时候,让ScrollView不截断点击事件,并传递给子View处理,也就是地图去处理点击事件;当用户手指抬起的时候,将ScrollView的状态恢复至之前的状态,也就是ScrollView可以截断点击事件。

    我使用的是百度地图,直接上代码,更容易理解。

    mMapView.getChildAt(0).setOnTouchListener(new View.OnTouchListener() {
                @Override
                public boolean onTouch(View v, MotionEvent event) {
                    if(event.getAction() == MotionEvent.ACTION_UP){
                        //允许ScrollView截断点击事件,ScrollView可滑动
                        mScrollView.requestDisallowInterceptTouchEvent(false);
                    }else{
                        //不允许ScrollView截断点击事件,点击事件由子View处理
                        mScrollView.requestDisallowInterceptTouchEvent(true);
                    }
                    return false;
                }
            });
    
    ViewPager滑动冲突解决

    在这个项目中,ViewPager在页面最顶层,如果只是ScrollView里面嵌套了ViewPager,因为这两个控件是不同方向的滑动事件,所以基本不会出现冲突。

    但是由于引入了SwipeRefreshLayout,我发现在滑动ViewPager的时候,很容易就触发了SwipeRefreshLayout的下来刷新,进而有可能阻断了ViewPager的左右滑动效果,体验很不好。而且在滑动ViewPager的过程中,用户滑动肯定不是一直水平的,会有一定程度向上向下的滑动。

    ViewPager处理冲突和地图处理冲突有些不同,因为当用户点击到ViewPager,在滑动过程中,基本就可以猜测到用户是想左右滑动ViewPager还是上下滑动ScrollView(或者下拉刷新),这就不能像地图一样,在点击到ViewPager就禁止ScrollView截断点击事件(或者SwipeRefreshLayout下拉刷新功能),需要在滑动过程中做出判断。

    解决思路就是,设定一个阈值,一旦用户在X轴也就是横向滑动距离超过这个阈值,我就认为用户是要左右滑动ViewPager,就禁止ScrollView截断点击事件同时设置SwipeRefreshLayout不能下拉刷新。当用户抬起手指,就认为用户对ViewPager的操作已经完毕,将ScrollView和SwipeRefreshLayout状态恢复。

    mViewPager.setOnTouchListener(new View.OnTouchListener() {
        @Override
        public boolean onTouch(View v, MotionEvent event) {
            int action = event.getAction();
    
            if(action == MotionEvent.ACTION_DOWN) {
                // 记录点击到ViewPager时候,手指的X坐标
                mLastX = event.getX();
            }
            if(action == MotionEvent.ACTION_MOVE) {
                // 超过阈值
                if(Math.abs(event.getX() - mLastX) > 60f) {
                    mRefreshLayout.setEnabled(false);
                    mScrollView.requestDisallowInterceptTouchEvent(true);
                }
            }
            if(action == MotionEvent.ACTION_UP) {
                // 用户抬起手指,恢复父布局状态
                mScrollView.requestDisallowInterceptTouchEvent(false);
                mRefreshLayout.setEnabled(true);
            }
            return false;
        }
    });
    

    用户点击到ViewPager时候,记录下点击位置的X坐标,当用户滑动过程中,如果在X轴上面的滑动超过阈值(我写的是60f,这个可以在实际使用中自行设置最佳的阈值),就禁止ScrollView截断点击事件,同时设置不可下拉刷新。当用户手指离开屏幕,将ScrollView和SwipeRefreshLayout的状态恢复。

    ListView滑动冲突解决

    在ScrollView中嵌套ListView,会出现各种各样奇怪的问题。比如说ListView显示有问题,可能才一两个Item那么高,并没有完全的展开。网上流传解决这种问题的方法会有两种。

    • 根据展示数据的个数乘以每一个Item的高度,计算出ListView的总体高度,然后动态的设置ListView的高度
    • 复写ListView的onMeasure(int widthMeasureSpec, int heightMeasureSpec)方法,让ListView完全展开

    这两种方法都可以解决ListView展示不完全的问题,而且也可以滑动(其实是使用ScrollView的滑动效果),但是有一个最大的遗憾,就是ListView里面的View不能复用了。因为这两种方法都是算出了ListView的全部高度,然后将ListView控件的高度设置成这个高度,这样的话,ListView就相当于一个LinearLayout的布局了,失去了复用View的优势,而且在某些场景可能还没有LinearLayout好用,更甚的是,如果有大量图片的话,很容易就OOM了,这是在研发过程中最不希望看见的。

    可以参考一下美团,美团的首页,就是一个ScrollView,下滑的时候会发现,并不能无限向下滑动,到了底部会提醒跳转到一个二级页面去查看全部的团购信息。这是处理ScrollView里面嵌套类似ListView列表布局的时候的一种解决方案。

    但是在我遇见的这个项目里面,并不能这样处理。

    上面的提到的两种解决思路很明确,如果想要ListView正常展示就需要确定ListView的高度,这个很重要。

    所以首先,我需要在布局文件中设置ListView的高度,是一个明确的数值。设置高度之后,如果ListView中的数据的Item总高度超过ListView所设置的高度,就可以复用View了。但是这只是解决了ListView的显示问题,ListView与ScrollView的滑动冲突,并没有解决。

    要解决滑动的冲突,最主要的是确定禁止ScrollView截断点击事件的时机,然后来分析有哪些时机。

    • ScrollView在未滑动到底部时候,如果点击并滑动ListView时候,ListView是不能滑动的,不禁止。
    • 如果ScrollView滑动到底部,且ListView已经到顶部,继续下拉ListView,其实会拉动ScrollView,不禁止。
    • 如果ScrollView滑动到底部,用户向上滑,ListView滑动,禁止ScrollView截断点击事件能力

    很明显,在判断禁止ScrollView截断点击事件时机的时候,需要知道ScrollView是否滑动到了底部。于是,重写了ScrollView的ScrollChanged()方法,来判断ScrollView是否滑动到底部(SDK API 23版本中ScrollView可以设置setOnScrollChangeListener()来监听滑动的变化,但是之前版本不支持,为了兼容,自己需要重写)。

    @Override
    protected void onScrollChanged(int l, int t, int oldl, int oldt){
        super.onScrollChanged(l,t,oldl,oldt);
        // 滑动的距离加上本身的高度与子View的高度对比
        if(t + getHeight() >=  getChildAt(0).getMeasuredHeight()){
            // ScrollView滑动到底部
            if(mOnScrollToBottomListener != null) {
                mOnScrollToBottomListener.onScrollToBottom();
            }
        } else {
            if(mOnScrollToBottomListener != null) {
                mOnScrollToBottomListener.onNotScrollToBottom();
            }
        }
    }
    
    public void setScrollToBottomListener(OnScrollToBottomListener listener) {
        this.mOnScrollToBottomListener = listener;
    }
    
    public interface OnScrollToBottomListener {
        void onScrollToBottom();
        void onNotScrollToBottom();
    }
    

    有了思路,而且ScrollView滑动到底部的标识也可以拿到,下面就可以直接来解决滑动冲突了,直接看代码。

    mScrollView.setScrollToBottomListener(new BottomScrollView.OnScrollToBottomListener() {
        @Override
        public void onScrollToBottom() {
            isSvToBottom = true;
        }
    
        @Override
        public void onNotScrollToBottom() {
            isSvToBottom = false;
        }
    });
    
    mListView.setOnTouchListener(new View.OnTouchListener() {
        @Override
        public boolean onTouch(View v, MotionEvent event) {
            int action = event.getAction();
    
            if(action == MotionEvent.ACTION_DOWN) {
                mLastY = event.getY();
            }
            if(action == MotionEvent.ACTION_MOVE) {
                int top = mListView.getChildAt(0).getTop();
                float nowY = event.getY();
                if(!isSvToBottom) {
                    // 允许scrollview拦截点击事件, scrollView滑动
                    mScrollView.requestDisallowInterceptTouchEvent(false);
                } else if(top == 0 && nowY - mLastY > THRESHOLD_Y_LIST_VIEW) {
                    // 允许scrollview拦截点击事件, scrollView滑动
                    mScrollView.requestDisallowInterceptTouchEvent(false);
                } else {
                    // 不允许scrollview拦截点击事件, listView滑动
                    mScrollView.requestDisallowInterceptTouchEvent(true);
                }
            }
            return false;
        }
    });
    

    相对于其他的控件来说,ListView和ScrollView之间的滑动冲突更难解决,但其实在实际使用中并不推荐ScrollView里面嵌套ListView,一旦业务复杂,很容易出现各种UI和业务逻辑冲突的错误。

    运行效果

    由于地图加入比较麻烦,所以在Demo中并没有引入地图。看一下运行效果。


    运行效果

    总结

    本篇文章只是提供一种解决方法的思路,在具体的场景下,交互往往是贴合具体业务需求的。但是不管怎么样,找出点击事件截断和处理的时机是最重要的,围绕这个关键点,总能找出相应的解决方法。

    附上Demo工程地址:Demo工程地址链接

    相关文章

      网友评论

      • 这条鱼有点甜:想问楼主GIF怎么录制的呢
        王三的猫阿德:因为手边有个锤子,直接用锤子录制的视频,然后用photoshop转成了gif
      • 549c0e6ffcff:你好, 请问如果listview和Viewgroup (比如Scrollview和Coordinaterlayout) 不在同一个布局文件中, 要怎么处理滑动冲突呢? 谢谢! 比如Scrollview在Activity中, 而listview在Activity中的Fragment中.
        王三的猫阿德:因为能知道两个控件的状态,用回调或者其他消息通知方式,通知ScrollView更改状态即可
      • 米奇小林:有个疑问,事件默认是不是都能传递到具体的子View,看楼主的方法都是在子View的touch事件里处理的,一般还是重写父容器的onInterceptTouchEvent吧
        王三的猫阿德:这个场景是要根据listview状态去查看scrollview是否需要拦截事件,是需要根据子View的滑动状态来判断,如果直接写父容器的onInterceptTouchEvent,不好判断子View的状态
      • 鬼雅:大神,如果想同时添加下拉刷新和上拉加载,利用NestedScrollView做出滑动到顶部悬浮的效果,如何判断滑动的方向,只禁止单方向的滑动呢?(需要上拉加载的时候,禁止listview的下滑动,保留NestedScrollView的下滑动;需要下拉刷新的时候,禁止listview的上滑动,保留NestedScrollView的上滑动),层级关系NestedScrollView→ViewPager→listview
        王三的猫阿德:在NestedScrollView的dispatchTouchEvent里面判断用户,然后对事件进行分发
      • 捡淑:从楼主的这片文章,我可以推断出,楼主一定很帅~
        王三的猫阿德:@捡淑 哈哈哈,少年你很有眼光。
      • 捡淑:老司机醍醐灌顶
      • 一团小猫猫:给个赞👍
      • 天蝎無銘栺:大神,listview里边 嵌套webview 上下滑动冲突 解决,能给个提示吗?感谢!
        天蝎無銘栺:@王三的猫阿德 已解决,感谢!
        王三的猫阿德:用户操作webview时候,调用listview的requestDisallowInterceptTouchEvent,禁止listview拦截事件,让事件传递到webview中,当手指离开屏幕,恢复listview状态。
      • Dylanqing:博主..文章中的scrollview嵌套listview,你使用的解决的方法是将listview的高度写死,我想问的就是你代码里面@dimen/lv_height的这个高度的大小你是怎样确定是这个数值的?
        王三的猫阿德:@Dylanqing 这个值在demo中是随便写的,在项目中是和设计师讨论最终确定的一个值。
      • StandByMeSun:大牛,能否给个例子?
        王三的猫阿德:@jksfood 文章末尾有demo工程的链接,你可以把工程下载下来看看
      • Answer_yzpppp:mListView.setOnTouchListener(new View.OnTouchListener() {
        @Override
        public boolean onTouch(View v, MotionEvent event) {
        int action = event.getAction();

        if(action == MotionEvent.ACTION_DOWN) {
        mLastY = event.getY();
        }
        if(action == MotionEvent.ACTION_MOVE) {
        int top = mListView.getChildAt(0).getTop();
        float nowY = event.getY();
        if(!isSvToBottom) {
        // 允许scrollview拦截点击事件, scrollView滑动
        mScrollView.requestDisallowInterceptTouchEvent(false);
        } else if(top == 0 && nowY - mLastY > THRESHOLD_Y_LIST_VIEW) {
        // 允许scrollview拦截点击事件, scrollView滑动
        mScrollView.requestDisallowInterceptTouchEvent(false);
        } else {
        // 不允许scrollview拦截点击事件, listView滑动
        mScrollView.requestDisallowInterceptTouchEvent(true);
        }
        }
        这里不太明白为什么可以把mScrollView.requestDisallowInterceptTouchEvent写在setOnTouchListener的ACTION_MOVE里面,ScrollView的onInterceptTouchEvent的action_move不是会在listview的setOnTouchListener的action_move之前执行吗?那不就会直接将事件拦截吗?为什么还会执行到setOnTouchListener呢?求大神解答!!
        王三的猫阿德:@Answer_yzpppp 写的有点多,看一下这篇文章,http://www.jianshu.com/p/a009d7415af0。事件传递这块本来就有些复杂,看起来可能有点乱。
        Answer_yzpppp:@王三的猫阿德 好的 3Q
        王三的猫阿德:@Answer_yzpppp 不好意思,10月末到11月10号都在日本休假,这段时间没有时间回复。你说的这个问题我也考虑过,从逻辑上面讲是个悖论,我也抽空看过几次源代码,有一些思路,等我回国在具体回复你一下。
      • 5c472d820f4a:如果是scrillview嵌套listview,重写listview onmeasure,应该会复用的吧
        王三的猫阿德:@飞向苍穹的乌龟 onmeasure过程是得到listview大小的,解决冲突主要是点击事件的处理,这两者联系在一起是为什么
        5c472d820f4a:scrollview是父布局,为了解决冲突,才重写listview onmeasure
        王三的猫阿德:@飞向苍穹的乌龟 你的意思是listview是父布局是吗?会被复用是指?
      • 最讨厌取昵称了:大侠你好,我照着你的方法做了之后,如果滑动的慢的话确实没有冲突了,但是如果滑的快的话还是会被scrollview拦截,请问这是什么原因呢?是否有解决办法?
        王三的猫阿德:@最讨厌取昵称了 滑动的时候在ScrollView的onInterceptTouchEvent方法里面判断一下滑动速度,如果过快就不然scrollview拦截事件
        最讨厌取昵称了:@王三的猫阿德 在哪里面判断?我通过打印日志发现滑的快的时候事件无法传递到listview,它的ontouch方法不会执行,而scrollview的ontouch会执行,滑的慢的时候scrollview的不会执行,listview的会执行
        王三的猫阿德:@83babad450ac 对滑动速度进行判断,快于某一个值就不允许scrollview滑动。
      • 作为一个初学者:你好,问一下THRESHOLD_Y_LIST_VIEW 这个变量是在哪定义的
        王三的猫阿德:@作为一个初学者 demo中定义的这个值是20个像素
        王三的猫阿德:@作为一个初学者 这个是自定义的一个变量,根据具体展示效果自定义的一个数值。
      • kenan:从楼主的这片文章,我可以推断出,楼主一定很帅~
      • 天蝎猫吃鱼:如果我在ScrollView中设置拦截,则只能滑动ScrollView,无法进入ListView,最后重写ScrollView的OnTouchEvent方法后,每次当ListView的第一项滑到顶部时,总要先松开再上滑才能进入ListView ,我看你的效果图上好像是一直滑上去没有松开手指直接进入ListView的,所以请问怎么弄???谢谢啦!
        天蝎猫吃鱼:@王三的猫阿德 nowY - mLastY > THRESHOLD_Y_LIST_VIEW 难道不是在判断 是否手势为向下滑动,缓冲距离这块我没搞懂,当ListView滑动到顶部时再向下滑就直接滑动ScrollView,没有缓冲距离啊,缓冲距离很小察觉不到吗?既然可以这样在ListView中传递滑动事件给ScrollView,那我在ScrollView中以同样的方式传递滑动事件给ListView却不行,为什么呢?效果就是 在滑动ScrollView到底部时再向上滑不松开手指直接能进入ListView却是不行的,是不是因为触摸事件是往上传递的,直接被外层消费掉或者没有处理吗?
        天蝎猫吃鱼:nowY - mLastY > THRESHOLD_Y_LIST_VIEW 难道不是在判断 是否手势为向下滑动,缓冲距离这块我没搞懂,当ListView滑动到顶部时再向下滑就直接滑动ScrollView,没有缓冲距离啊,缓冲距离很小察觉不到吗?既然可以这样在ListView中传递滑动事件给ScrollView,那我在ScrollView中以同样的方式传递滑动事件给ListView却不行,为什么呢?效果就是 在滑动ScrollView到底部时再向上滑不松开手指直接能进入ListView却是不行的,是不是因为触摸事件是往上传递的,直接被外层消费掉或者没有处理吗?
        王三的猫阿德:@天蝎猫吃鱼 int top = mListView.getChildAt(0).getTop();
        float nowY = event.getY();
        if(!isSvToBottom) {
        mScrollView.requestDisallowInterceptTouchEvent(false);
        } else if(top == 0 && nowY - mLastY > THRESHOLD_Y_LIST_VIEW) {
        mScrollView.requestDisallowInterceptTouchEvent(false);
        } else {
        mScrollView.requestDisallowInterceptTouchEvent(true);
        }
        看第二个条件的判断,在这里我会有一个nowY - mLastY > THRESHOLD_Y_LIST_VIEW的判断,就是让用户滑动ListView到顶部,手指需要继续向下滑动一段距离,才让ScrollView滑动,中间有一个缓冲距离,在这个距离内,ListView就算是滑动了顶部依旧可以再向下滑动。
      • 天蝎猫吃鱼:- ScrollView
        ---- ViewPager
        ------- Fragment
        ----------- ListView
        ------- Fragment
        ----------- ListView
        是这种结构,每个Fragment中的ListView都不能滑动
      • 天蝎猫吃鱼:你好,我用你提供的方法试过之后发现还是行不通,上下滑动依然被ScrollView拦截了。
        我的结构如下:
        - ScrollView
        - ViewPager
        - Fragment
        - ListView
        - Fragment
        - ListView
        左右滑动是没有问题的,但是上下滑动依然被拦截了。
        王三的猫阿德:@天蝎猫吃鱼 里面有两个listview,是哪个listview不能上下滑动

      本文标题:Android实践之ScrollView中滑动冲突处理

      本文链接:https://www.haomeiwen.com/subject/miondttx.html