滑动冲突场景

Android中有许多控件支持用户进行拖拽,滑动等操作,比如SeekBar,ViewPager,ScrollView,RecyclerView等等。随着业务的展现的需求变化,UI越来越复杂,不可避免的就会出现嵌套的多个可滑动View的情况,比如ViewPager中套ViewPager,ScrollView中套ViewPager,ViewPager中套RecyclerView,还有一些开发者自行开发的,可接受滑动手势的控件与标准控件的嵌套。

当ViewTree中从根到某一叶子节点的路径上,存在多个可接受滑动手势的控件时,就有可能发生滑动冲突。

滑动冲突原因

一般而言,产生滑动冲突的时候,一定有一个可以滑动的父控件作为容器,包裹着一个可以滑动的子控件。

Android的touch事件分发的方向是从父控件到子控件,而事件消费方向则是从子控件到父控件,对于一个可滑动的ViewGroup,假如他有一个子View是一个按钮,那么当用户手触摸该按钮时,该按钮默认会消费掉这一个touch事件序列中的所有的touch事件,直到用户抬手。

理论上父控件没有机会处理滑动事件,因为父控件的onTouchEvent并不会收到touch事件。

此时通常的做法是重写​​ViewGroup#onInterceptTouchEvent​​​,在其中判断用户的手指在该控件上滑动的距离,如果距离超过一个阈值,则认为用户是在滑动而不是点击,此时​​ViewGroup#onInterceptTouchEvent​​返回true,所有事件均直接传给该ViewGroup的onTouchEvent,由拦截事件的控件自身进行处理。

当然重写​​ViewGroup#dispatchTouchEvent​​也可以做到,只不过一般不重写它,重写它的要么是经验丰富的人,要么就是略懂的新人。

一般而言,Android官方控件,包括support包中的控件,对滑动冲突都有一定的避免能力,天然就能互相嵌套,且滑动效果符合开发者预期,这是多种手段互相配合的结果。

滑动冲突的解决思想

由于事件一定是通过父控件派发,因此父控件可以监听触摸事件,识别滑动手势,在需要处理滑动时让​​ViewGroup#onInterceptTouchEvent​​​返回true。但这并不足够,因为父控件并不知道自己内部的子控件到底是什么业务逻辑,可滑动的子控件也不知道自己的父控件到底知不知道什么时候能拦截,什么时候不能拦截,因此父控件提供了​​ViewGroup#requestDisallowInterceptTouchEvent​​方法给子控件调用,让子控件能及时通知父控件,什么时候可以拦截,什么时候不能拦截。

一般的,如果有嵌套的可滑动控件,一定是子控件优先滑动,父控件在适当时机拦截事件,自行处理滑动事件。对于父控件如何识别滑动手势,并识别是否可以拦截,也有两种常见的方案。

滑动阈值

事件流经父控件时,父控件不对事件做拦截操作,但时刻计算用户的滑动方向和距离,一旦用户的滑动方向与自己可滑动的方向夹角小于一定程度,并且滑动距离超过一个阈值,同时子控件没有禁止父控件拦截的情况下,父控件在​​ViewGroup#onInterceptTouchEvent​​​中返回true,以拦截事件,之后交由​​ViewGroup#onTouchEvent​​处理滑动的具体事务。如果子控件禁止父控件拦截事件,则父控件不拦截事件,也不需要识别滑动手势。

与此同时,如果子控件识别到自己可滑动,将会通过​​requestDisallowInterceptTouchEvent​​来禁止父控件对自己可能的拦截行为,并在合适的时机重新允许父控件拦截事件。

从原理上讲,滑动阈值本身并不是为了处理滑动冲突,因为一个正常的可滑动容器,必须要能做到识别滑动手势并拦截,如果不拦截,一旦内部有任何控件吃一切事件,它就滑不动了,不要觉得吃一切事件的控件是极端情况,一个clickable的View,默认就会吃全部事件,也就是说,如果父控件不拦截滑动事件,那么当用户手指落在按钮上开始滑动时,父控件永远收不到事件。

只不过很多人在写一些自定义的可滑动容器时,第一反应就是做阈值判断拦截事件,因此也算时处理滑动冲突的方案。

是不是只要有滑动阈值判断就高枕无忧了呢?

并不是。

一个有滑动阈值的父控件,我们可以说它对子控件自行处理滑动事件是宽容的,而子控件,一般而言没那么宽容,比如​​SeekBar​​只要收到DOWN事件,就会请求父控件不拦截事件,相当于当可滑动的子控件完全不给父控件机会拦截自己,当然也就不会有冲突。这种场景下,当用户滑动子控件时,父控件是无论如何不会滑动的。

但假如子控件也是一个有滑动阈值的控件,也就是说两个宽容的控件凑一块了,会怎么样呢?

原生Android事件分发体系里面,涉及到滑动事件的处理,要么是父控件拦截掉事件并处理滑动手势;要么是子控件自行处理滑动,禁止父控件拦截,无论如何,只有一方会处理滑动事件。

而我们知道事件是从父控件派发到子控件的,父控件拦截发生在子控件收到事件之前,假如父控件的阈值是10,子控件的阈值是20,那么一旦达到阈值

最先判断需要处理滑动事件的一定是父控件,因为父控件拦截在前,且阈值小于子控件,子控件根本没机会检测到滑动手势。

有人说将父控件的阈值调整到大于子控件就可以了,这样就能让子控件率先达到阈值,自行处理滑动了。

这种想法还是忽略了一个问题,用户滑动的距离并不是一个从0开始平滑增长的值,而是一系列离散的数,用户的两个touch时间之间的距离,是可能突然变得很大的,比如一上来距离就达到了40,假如父控件的阈值是30,子控件的阈值是10,由于父控件的拦截判断在先,还是父控件先拦截的事件,而不是我们想要的子控件来处理滑动。

所以遇到两个有滑动阈值的控件嵌套,且他们滑动的方向一致时,滑动冲突无法避免。

主动检测

既然滑动阈值这种纯靠父子控件自我感觉的方案在某些情况下行不通,那么就需要有主动检测的手段。

即父控件检测到滑动事件后,首先对子控件在该方向和距离上的可滑动性进行检测,如果子控件不可滑动,则事件由父控件拦截;如果子控件可以滑动,则正常放行,由子控件自行处理滑动事件并禁止父控件拦截。恰好有这么两个方法:

​View.canScrollHorizontally​​​和​​View.canScrollVertically​​。support包中还有兼容版本的实现。

最典型的例子就是ViewPager,我们知道多个ViewPager嵌套是不会有滑动冲突的,并且还能在子ViewPager无法滑动时,改为滑动父ViewPager,它的原理就是使用​​View.canScrollHorizontally​​对子控件的可滑动性进行检测。

大部分原生控件都正确实现了这两个方法。

嵌套滑动机制

嵌套滑动机制本身是为了解决可滑动父子View的联动问题,正如前面所说,一个滑动事件要么是父控件处理,要么是子控件处理,很难做到子控件处理一部分之后再交给父控件处理,或者父控件处理一部分之后再交给子控件处理。

嵌套滑动机制可以解决可滑动View的联动问题,天然就是解决滑动冲突的方案,只是嵌套滑动机制,对于早期版本的支持有限,我并没有深入了解过,这里就不讨论了。

实践思路

如果是写可滑动的父控件(即逻辑上的View容器,内部可能嵌套其他可滑动View)

一般使用滑动阈值的方法就可以正确实现,如果想实现实现更精确的控制,可以使用View.canScrollXXX来检测子控件的可滑动性。

如果是写可滑动的子控件(即逻辑上的子控件,内部不再嵌套其他可滑动View),务必不要通过阈值来判断是否需要禁止父控件拦截事件,而是在收到ACTION_DOWN的时候立即请求禁止拦截,在合适的时机再取消禁止。如果能准确知道自己的父控件会使用View.canScrollXXX来检测自己,也可以直接通过正确实现该方法来与父控件配合。

如果在实践中,遇到两个嵌套的可滑动View,均使用了滑动阈值来判断是否处理滑动,且这俩View的源码我们均不能修改,那么可以考虑给子控件设置一个​​OnTouchListener​​,遇到ACTION_DOWN直接请求禁止拦截,在合适的时候再取消禁止,虽然体验上会有些奇怪,至少能保证不出很明显的滑动冲突问题。