【朝花夕拾】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开发艺术探索》