【朝花夕拾】Android自定义View篇之(七)Android事件分发机制(下)滑动冲突解决方案总结

前言

       转载请声明,转自【https://www.cnblogs.com/andy-songwei/p/11072989.html】,谢谢!

       前面两篇文章,花了很大篇幅讲解了Android的事件分发机制的原理性知识。然而,“纸上得来终觉浅,绝知此事要躬行”,前面讲的那些原理,也都是为解决实际问题而服务的。本文将结合实际工作中经常遇到的滑动冲突案例,总结滑动冲突的场景以及解决方案。本文的主要内容如下:

 

一、滑动冲突简介

       滑动组合在平时的UI开发中非常常见,比如下图中某App界面(图片来源:https://www.jb51.net/article/90032.htm),该页面上半部分显示商品列表,而下半部分显示页面导航。当滑动上面的列表时,列表部分滑动;当列表滑动到底或者滑动下半部分时,整个页面一起滑动。

       但是在平时的开发中,可能会经常遇到这样的场景,滑动列表部分时,整个页面一起滑动,而不是只滑动列表内容。或者一会儿是列表滑动,一会儿是整个页面滑动,而不是按照预期的要求来滑动。这就是我们常说的滑动冲突问题。滑动冲突的问题,经常让开发者们头痛不已。因为经常很多滑动相关的控件,如ScrollView、ListView等,在单独使用的时候酷炫不已,但将他们组合在一起使用,就失灵了。比如上图中,手指在屏幕上上下滑动,列表和整个页面都有滑动功能,此时如果处理不当,就会导致系统也不知道要让谁来消费这个滑动事件,这就是滑动冲突产生的原因。

 

二、滑动冲突的三种场景

       尽管实际工作中滑动冲突的场景看似各种各样,但最终可以归纳为三种,如下图所示:1)图一:外部滑动和内部滑动方向不一致;2)图二:外部滑动和内部滑动方向不一致;3)图三:多层滑动叠加。

 

  1、外部滑动和内部滑动方向不一致

       图一中只示意了外部为左右滑动,内部为上下滑动的场景。显然,内外滑动不一致,还包括外部为上下滑动,内部为左右滑动的场景。对于这种场景,平时工作中最常见的使用大概是外层为PageView,内层为一个Fragment+ListView/RecyclerView了。庆幸的是,控件PageView和RecyclerView对事件冲突做了处理的,所以平时使用这两个控件的时候不会感受到滑动冲突的存在。如果是ScrollView+GridView等这类组合,就需要解决冲突了。

  2、外部滑动和内部滑动方向一致

       同样,这种场景除了图二中的内外都是上下滑动的情况外,还包括内外到时左右滑动的场景了。ScollView(垂直滚动)+ListView的组合就是比较常见的场景。第一节中的动态图就是一个外部滑动和内部滑动方向一致的例子。

  3、多层滑动嵌套

       这种场景一般就是前面两种场景的嵌套。“腾讯新闻”客户端就是典型的多层滑动嵌套的使用案例,如下图中,图一的左边是主页向右滑动时才出现的滑动侧边栏,图二是主页界面,顶部导航栏在主页左右滑动时可以切换,整个“要闻”界面可以上下滑动,“热点精选”是一个可以左右滑动的横向列表,下方还有竖直方向的列表......可见这其中嵌套层数不少。

           

 

三、滑动冲突三种场景的处理思路

       尽管滑动冲突看起来比较复杂,但是上述将它们分为三类场景后,就可以根据这三类场景来分别找出对应的分析思路。

  1、内外滑动方向不一致时处理思路

       这一类场景其实比较容易分析,因为外层和内层滑动的方向不一致,所以根据手势的动向来确定把事件给谁。我们前面两篇文章中分析过,默认情况下,当点击内层控件时,事件会先一层层从外层传到内层,由内层来处理。这里以外层为左右滑动,内层为上下滑动为例。当判定手势的滑动为左右时,需要外层来消费事件,所以外层将事件拦截,即在外层的onInterceptTouchEvent中检测为ACTION_MOVE时返回true;而如果判定手势的滑动为上下时,需要内层来消费事件,外层不需要拦截,事件会传递到内层来处理(具体的代码实现,在后面会详细列出)。这样就通过判断滑动的方向来决定事件的处理对象,从而解决滑动冲突的问题。

       那么,如何来判定手势的滑动方向呢?最常用的办法就是比较水平和竖直方向上的位移值来判断。 MotionEvent事件包含了事件的坐标,只要记录一次移动事件的起点和终点坐标,如下图所示,通过比较在水平方向的位移|dx|和|dy|的大小,来决定滑动的方向:|dy|>|dx|,本次移动的方向认为是竖直方向;反之,则认为是水平方向。当然,还可以通过夹角α的大小、斜率、速率等方式来作为判断条件。

  2、内外滑动方向一致时处理思路

       这种场景要比上面一种复杂一些,因为滑动方向一致,所以无法通过上述的方式来判断将事件交给谁处理。在这种情况下,往往需要根据业务的需要来判定谁来处理事件。比如竖直方向的ScrollView嵌套ListView的场景下,手指在ListView上上下滑动时:当ListView滑动到顶部且手势向下时,显然ListView不能再向下滑动了,这种情况下事件需要被外层控件拦截,由ScrollView来消费;当ListView滑动到底部且手势向上时,显然ListView也不能再向上滑动了,这种情况下事件也需要被外层控件拦截,由ScrollView来消费;其它情况下,ScrollView就不能再拦截了,滑动事件就需要由ListView来消费了,即此时上下滑动时,滑动的是ListView,而不是ScrollView。后面会以这为案例进行编码实现。

  3、多层滑动嵌套时处理思路

       场景3看起来比较复杂,但前面也说过了,也是由前面两种场景嵌套形成的。所以在处理场景的处理方式,就是将其拆分为简单的场景,然后按照前面的场景分析方式来处理。

 

四、滑动冲突的两种解决套路

       前面我们将滑动冲突分为了3种场景,并根据每一种场景提供了解决冲突的思路。但是这些思路解决的是判断条件问题,即什么情况下事件交给谁的问题。这一节将抛开前面的场景分类,介绍对所有场景适用的两种通用解决方法,可以通俗地理解为处理滑动冲突的“套路”。这两种解决滑动冲突的方式为:外部拦截法和内部拦截法。

  1、外部拦截法

       顾名思义,就是在外部滑动控件中处理拦截逻辑。这需要外部控件重写父类的onInterceptTouchEvent方法,在其中判断什么时候需要拦截事件由自身处理,什么时候需要放行将事件传给内层控件处理,内部控件不需要做任何处理。这个“套路”的伪代码表示所示:

 1 @Override
 2 public boolean onInterceptTouchEvent(MotionEvent ev) {
 3     boolean intercepted = false;
 4     switch (ev.getAction()){
 5         case MotionEvent.ACTION_DOWN:
 6             intercepted = false;
 7             break;
 8         case MotionEvent.ACTION_MOVE:
 9             if(父容器需要自己处理改事件){
10                 intercepted = true;
11             }else {
12                 intercepted = false;
13             }
14             break;
15         case MotionEvent.ACTION_UP:
16             intercepted = false;
17             break;
18             default:
19             break;
20     }
21     return intercepted;
22 }

前面对滑动处理的场景分类,并对不同场景给了分析思路,它们的作用就是在这里的第9行来做判断条件的。所以,不论什么场景,都可以在这个套路的基础上,修改判断是否拦截事件的条件语句即可。另外,需要说明一下的是,第6行和第16行,这里都赋值为false,因为ACTION_DOWN如果被拦截了,该动作序列的其它事件就都无法传递到子View中了,ListView也就永远不能滑动了;而ACTION_UP如果被拦截,那子View就无法被点击了,这两点我们前面的文章都讲过,这里再强调一下。

 

  2、内部拦截法

       顾名思义,就是将事件是否需要拦截的逻辑,放到内层控件中来处理。这种方式需要结合requestDisllowInterceptTouchEvent(boolean),在内层控件的重写方法dispatchTouchEvent中,根据逻辑来决定外层控件何时需要拦截事件,何时需要放行。伪代码如下:

 1 @Override
 2 public boolean dispatchTouchEvent(MotionEvent ev) {
 3     switch (ev.getAction()){
 4         case MotionEvent.ACTION_DOWN:
 5             getParent().requestDisallowInterceptTouchEvent(true);
 6             break;
 7         case MotionEvent.ACTION_MOVE:
 8             if (父容器需要处理改事件) {
 9                 //允许外层控件拦截事件
10                 getParent().requestDisallowInterceptTouchEvent(false);
11             } else {
12                 //需要内部控件处理该事件,不允许上层viewGroup拦截
13                 getParent().requestDisallowInterceptTouchEvent(true);
14             }
15             break;
16         case MotionEvent.ACTION_UP:
17             break;
18         default:
19             break;
20     }
21     return super.dispatchTouchEvent(ev);
22 }

除此之外,还需要外层控件在onInterceptTouchEvent中做一点处理:

1 @Override
2 public boolean onInterceptTouchEvent(MotionEvent ev) {
3     if (ev.getAction() == MotionEvent.ACTION_DOWN) {
4         return false;
5     } else {
6         return true;
7     }
8 }

ACTION_DOWN事件仍然不能拦截,上一篇文章分析源码的时候讲过,ACTION_DOWN时会初始化一些状态和标志位等变量,requestDisllowInterceptTouchEvent(boolean)作用会失效。这里再顺便强调一下,不明白的可以去上一篇文章中阅读这部分内容。 

       这种方式比“外部拦截法”稍微复杂一些,所以一般推荐使用前者。同前者一样,这也是一个套路用法,无论是之前提到的何种场景,只要根据实际判断条件修改上述if语句即可。对于requestDisllowInterceptTouchEvent(boolean)的相关信息,在前面的文章中介绍过,这里不再赘述了。

 

 五、代码示例

       前面通过文字描述和伪代码,对滑动冲突进行了介绍,并提供了一些对应的解决方案。本节将通过一个具体的实例,分别使用上述的套路来解决一个滑动冲突,从而具体演示前面“套路”的使用。

  1、未解决冲突前的示例情况

       本示例外层为一个ScrollView,内层为TextView+ListView+TextView,这两个TextView分别为“Tittle”和"Bottom",显示在ListView的顶部和底部,添加它们是为了方便观察ScrollView的滑动效果。最终的布局效果如下所示:

在手机上的显示效果为:

     

在没有解决冲突前,如果滑动中间的ListView部分,会出现ListView中的列表内容不会滑动,而是整个ScrollView滑动的现象,或者一会儿ListView滑动,一会儿ScrollView滑动。显然,这不是我们希望看到的结果。我们希望的是,如果ListView滑到顶部时,而且手势继续下滑时,整个页面下滑,即ScrollView滑动;如果ListView滑到底部了,而且手势继续上滑时,希望整个页面上滑,即也是ScrollView向上滑动。

 

  2、用外部拦截法解决滑动冲突的示例

       前面说过了,这种方式需要外层的控件在重写的onInterceptTouchEvent时进行拦截判断,所以需要自定义一个ScrollView控件。

 1 public class CustomScrollView extends ScrollView {
 2 
 3     ListView listView;
 4     private float mLastY;
 5     public CustomScrollView(Context context, AttributeSet attrs) {
 6         super(context, attrs);
 7     }
 8 
 9     @Override
10     public boolean onInterceptTouchEvent(MotionEvent ev) {
11         super.onInterceptTouchEvent(ev);
12         boolean intercept = false;
13         switch (ev.getAction()){
14             case MotionEvent.ACTION_DOWN:
15                 intercept = false;
16                 break;
17             case MotionEvent.ACTION_MOVE:
18                 listView = (ListView) ((ViewGroup)getChildAt(0)).getChildAt(1);
19                    //ListView滑动到顶部,且继续下滑,让scrollView拦截事件
20                 if (listView.getFirstVisiblePosition() == 0 && (ev.getY() - mLastY) > 0) {
21                     //scrollView拦截事件
22                     intercept = true;
23                 }
24                 //listView滑动到底部,如果继续上滑,就让scrollView拦截事件
25                 else if (listView.getLastVisiblePosition() ==listView.getCount() - 1 && (ev.getY() - mLastY) < 0) {
26                     //scrollView拦截事件
27                     intercept = true;
28                 } else {
29                     //不允许scrollView拦截事件
30                     intercept = false;
31                 }
32                 break;
33             case MotionEvent.ACTION_UP:
34                 intercept = false;
35                 break;
36             default:
37                 break;
38         }
39         mLastY = ev.getY();
40         return intercept;
41     }
42 }

       相比于前面的伪代码,这里需要注意一点的是多了第12行。因为本控件是继承自ScrollView,而ScrollView中的onInterceptTouchEvent做了很多的工作,这里需要使用ScrollView中的处理逻辑,才需要加上这一句。如果是完全自绘的控件,即直接继承自ViewGroup,那就无需这一句了,因为控件需要自己完成自己的特色功能。第18行是获取子控件ListView的实例,这个是参照后面的布局文件activity_event_examples来定位的,也可以通过其它的方式来获取实例。另外就是ListView的实例可以通过其它方式一次性赋值,而不用这里每次ACTION_MOVE都获取一次实例,从性能上考虑会更好,这里为了便于演示,先忽略这一点。其它要点在注释中也说得比较明确了,这里不赘述。

       使用CustomScrollView控件,界面的布局如下:

 1 //==============activity_event_examples=============
 2 <?xml version="1.0" encoding="utf-8"?>
 3 <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
 4     android:layout_width="match_parent"
 5     android:layout_height="match_parent"
 6     android:orientation="vertical">
 7 
 8     <com.example.demos.customviewdemo.CustomScrollView
 9         android:id="@+id/demo_scrollview"
10         android:layout_width="match_parent"
11         android:layout_height="match_parent">
12 
13         <LinearLayout
14             android:layout_width="match_parent"
15             android:layout_height="match_parent"
16             android:orientation="vertical">
17 
18             <TextView
19                 android:id="@+id/tv_title"
20                 android:layout_width="match_parent"
21                 android:layout_height="100dp"
22                 android:background="@android:color/darker_gray"
23                 android:gravity="center"
24                 android:text="Title"
25                 android:textSize="50dp" />
26 
27             <ListView
28                 android:id="@+id/demo_lv"
29                 android:layout_width="match_parent"
30                 android:layout_height="600dp" />
31 
32             <TextView
33                 android:layout_width="match_parent"
34                 android:layout_height="100dp"
35                 android:background="@android:color/darker_gray"
36                 android:gravity="center"
37                 android:text="Bottom"
38                 android:textSize="50dp" />
39         </LinearLayout>
40     </com.example.demos.customviewdemo.CustomScrollView>
41 </LinearLayout>

这里需要注意的是,在ScrollView中嵌套ListView时,ListView的高度需要特别处理,如果设置为match_parent或者wrap_content,都会一次只能看到一条item,所以上面给了固定的高度600dp来演示效果。平时工作中,往往还需要对ListView的高度做一些特殊的处理,这不是本文的重点,这里不细讲,读者可以自行去研究。

       最后就是给ListView填充足够的数据:

 1 public class EventExmaplesActivity extends AppCompatActivity {
 2 
 3     private String[] data = {"Apple", "Banana", "Orange", "Watermelon",
 4             "Pear", "Grape", "Pineapple", "Strawberry", "Cherry", "Mango",
 5             "Apple", "Banana", "Orange", "Watermelon",
 6             "Pear", "Grape", "Pineapple", "Strawberry", "Cherry", "Mango"};
 7 
 8     @Override
 9     protected void onCreate(Bundle savedInstanceState) {
10         super.onCreate(savedInstanceState);
11         setContentView(R.layout.activity_event_exmaples);
12         showList();
13     }
14 
15     private void showList() {
16         ArrayAdapter<String> adapter = new ArrayAdapter<String>(
17                 EventExmaplesActivity.this, android.R.layout.simple_list_item_1, data);
18         ListView listView = findViewById(R.id.demo_lv);
19         listView.setAdapter(adapter);
20     }
21 }

 

  3、用内部拦截法解决滑动冲突的示例

       同样,前面的伪代码中也讲过,这里需要在内层控件中重写的dispatchTouchEvent方法处判断外层控件的拦截逻辑,所以首先需要自定义ListView。

 1 public class CustomListView extends ListView {
 2 
 3     public CustomListView(Context context, AttributeSet attrs) {
 4         super(context, attrs);
 5     }
 6 
 7     //为listview/Y,设置初始值,默认为0.0(ListView条目一位置)
 8     private float mLastY;
 9 
10     @Override
11     public boolean dispatchTouchEvent(MotionEvent ev) {
12         int action = ev.getAction();
13         switch (action) {
14             case MotionEvent.ACTION_DOWN:
15                 //不允许上层的ScrollView拦截事件.
16                 getParent().requestDisallowInterceptTouchEvent(true);
17                 break;
18             case MotionEvent.ACTION_MOVE:
19                 //满足listView滑动到顶部,如果继续下滑,那就允许scrollView拦截事件
20                 if (getFirstVisiblePosition() == 0 && (ev.getY() - mLastY) > 0) {
21                     //允许ScrollView拦截事件
22                     getParent().requestDisallowInterceptTouchEvent(false);
23                 }
24                 //满足listView滑动到底部,如果继续上滑,允许scrollView拦截事件
25                 else if (getLastVisiblePosition() == getCount() - 1 && (ev.getY() - mLastY) < 0) {
26                     //允许ScrollView拦截事件
27                     getParent().requestDisallowInterceptTouchEvent(false);
28                 } else {
29                     //其它情形时不允ScrollView拦截事件
30                     getParent().requestDisallowInterceptTouchEvent(true);
31                 }
32                 break;
33             case MotionEvent.ACTION_UP:
34                 break;
35         }
36 
37         mLastY = ev.getY();
38         return super.dispatchTouchEvent(ev);
39     }
40 }

可能有读者会有些疑惑,从布局结构上看,listView和ScrollView之间还隔了一层LinearLayout,getParent().requestDisallowInterceptTouchEvent(boolean)方法会奏效吗?实际上这个方法是针对所有的父布局的,而不是只针对直接父布局,这一点需要注意。

       参照伪代码的套路,这里还需要对外层的ScrollView做一些逻辑处理:

 1 public class CustomScrollView extends ScrollView {
 2     public CustomScrollView(Context context, AttributeSet attrs) {
 3         super(context, attrs);
 4     }
 5 
 6     @Override
 7     public boolean onInterceptTouchEvent(MotionEvent ev) {
 8         if (ev.getAction() == MotionEvent.ACTION_DOWN) {
 9             return false;
10         } else {
11             return true;
12         }
13     }
14 }

       在布局文件中使用CustomListView,将前面activity_event_examples.xml布局文件中的第27行的ListView替换为com.example.demos.customviewdemo.CustomListView即可。其它的和前面外部拦截法示例一样,这里不赘述。

 

结语

       关于滑动冲突的内容就讲完了。实际工作中的场景可能比这里demo中要复杂一些,笔者为了突出重点,所举的例子选得比较简单,但原理都一样的,所以希望读者能够好好理解,重要的地方,甚至需要记下来。同样,Android事件分发机制系列的知识点,要讲的也讲完了,三篇文章侧重于三个方面:1)第一篇重点总结了Touch相关的三个重要方法对事件的处理逻辑;2)第二篇重点分析源码,从源码的角度来分析第一篇文章中的逻辑;3)第三篇重点在实践,侧重解决实际工作中经常遇到的事件冲突问题——滑动冲突。当然,事件分发相关的问题远不是这3篇文章能说清楚的,文中若有描述错误或者不妥的地方,欢迎读者来拍砖!!!

 

参考资料

       任玉刚《Android开发艺术探索》

posted @ 2019-06-24 13:49  宋者为王  阅读(3811)  评论(6编辑  收藏  举报