一篇文章搞定《Android嵌套滑动》
- 前言
- 嵌套滑动冲突种类
- 产生原因
- 1、外部与内部滑动方向不一致
- 2、外部与内部滑动方向一致
- 3、多种情况下的嵌套(电商首页)
- 解决嵌套滑动的方法
- 1、外部拦截法
- 2、内部拦截法
- 3、现有API框架
- 外部与内部滑动方向不一致
- 1、ViewPage和RecyclerView嵌套
- 2、ScrollView嵌套RecyclerView
- 3、ScrollView嵌套HorizontalScrollView
- 外部拦截法
- 内部拦截法
- 使用API解决拦截
- 外部与内部滑动方向相同
- ViewPage和RecyclerView
- ScrollView嵌套ScrollView
- 多种情况嵌套(电商首页)
- 总结
前言
大家需要保证上一篇《一篇文章搞定事件分发》都看完了。或者对事件分发都心知肚明了。
不然解决起嵌套滑动,很容易理解不了。如果了解了事件分发看起来会事半功倍。
说起嵌套滑动,那不得不说起一些电商的首页。
多层的列表滑动、左右滑动列表、上下滑动列表、吸顶Table。充斥着嵌套滑动。
所以本文也会模拟电商的首页去讲解我们的嵌套滑动
嵌套滑动冲突种类
产生原因
首先滑动冲突产生的原因是什么?
大家可以想象一下,事件的U型图。是U型的进行处理我们的事件。
那么当ViewGroup1中嵌套了ViewGroup2。那么事件会先到ViewGroup1,如果ViewGroup1就会到ViewGroup2进行消费。
那这个时候如果ViewGroup2把事件消费了,ViewGroup1岂不是不能消费到了?
对应函数就是onTouchEvent收不到了。那ViewGroup1的MotionEvent事件不都就收不到了是不。
那就导致事件的失效,比如我想往下滑动我的ScrollView1。但是我命中了子ViewGroup的ScrollView2。
那么这时候时间被ScrollView2消费了。这时候滑动的就变成了ScrollView2而不是ScrollView1。
下面看看都有哪些情况和处理方式:
1、外部与内部滑动方向不一致
也就是说可以左右进行滑动,也可以上下进行滑动。
可以是RecyclerView+ListView。也可以是ScrollView+RecyclerView的形式。我的意思是多种形式的组合。
当然你可以用ViewPage + RecyclerView的形式。并且你会发现哎呦喂,没有滑动冲突啊,因为RecyclerView帮我们做了。这里就不赘述RecyclerView的原理了,到头来都是利用事件分发原理去做的。
2、外部与内部滑动方向一致
也就是说两次的滑动View都是上下滑动、或者左右滑动的
可以是ScorllView+ScorllView。也可以是RecyclerView+ScorllView。我的意思是多种组合。
发现了吗?不管怎么组合都是ViewGroup的组合。
3、多种情况下的嵌套(电商首页)
直接我给你来个电商图。(后面会用MVVM去写一个首页楼层框架,开源给大家)
可以看到整体结构为:
1、顶部的ToolBar
2、多类型Item的List楼层
3、其中最后一个Item是:左右滑动的可吸顶Tab加上下滑动的瀑布流List的Page
很多电商的首页都是这中间结构的。我还发现其他App有一些带有评论的页面也有这种结构。
下面让我们逐个去讲解一下这三类的嵌套滑动冲突处理
解决嵌套滑动的方法
主要分为三种方式去解决此类的问题,下面也将会采用这三类方式去讲解
我先说一下拦截的思路,举例放到后面解决问题的场景中吧。要不重复代码过多。
1、外部拦截法
控制父View的onInterceptTouchEvent()方法,决定在什么时候拦截。
拦截时机:先判断手势的走向,然后根据子View的需求场景进行拦截。
这了解了事件分发的就一定知道onInterceptTouchEvent拦截了。不清楚的先看事件分发吧!!
2、内部拦截法
由子滑动View调用requestDisallowInterceptTouchEvent()决定父View是否可拦截。这个是拦截在dispatchTouchEvent这个阶段的。
在源码中,requestDisallowInterceptTouchEvent()方法定义在ViewParent接口中,子View可以通过getParent()方法获取到它的父View,并调用该方法来控制父View是否拦截事件。在ViewGroup中的dispatchTouchEvent()方法中会先检查是否可以拦截事件,如果子View调用了requestDisallowInterceptTouchEvent(true)方法,则父View不会拦截该事件。
3、现有API框架
NestedScrolling机制滑动控件:根据接口实现,动态分配事件。
比如嵌套滑动组件 NestedScrollingParent 和 NestedScrollingChild、CoordinatorLayout(也是通过NestedScrolling接口来实现的)
列举一下在View中使用了这些接口的组件:
实现 NestedScrollingParent 接口的 View 有:NestedScrollView、CoordinatorLayout、MotionLayout 等
实现 NestedScrollingChild 接口的 View 有:NestedScrollView、RecyclerView 等
NestedScrollView 是唯一同时实现两个接口的 View,这意味着它可以用作中介来实现多级嵌套滑动,后面会说到。
外部与内部滑动方向不一致
1、ViewPage和RecyclerView嵌套
这个单拿出来简单说一下,大家在开发时使用ViewPage和RecyclerView不同方向时发现他没有什么滑动冲突啊。
这是因为它的内部给我们处理了:
在ViewPager和RecyclerView嵌套使用时,它们在内部有一个默认的处理机制,以避免横向滑动冲突。具体来说,ViewPager会处理水平方向的滑动事件,而RecyclerView会处理垂直方向的滑动事件。
这种处理机制的实现依靠了ViewPager控件和RecyclerView控件内部的触摸事件拦截机制。在ViewPager中,重写onInterceptTouchEvent()方法,根据滑动方向,判断是否拦截触摸事件。在RecyclerView中,则重写dispatchTouchEvent()方法,在垂直或水平方向上优先处理滑动事件。
当然是上层看起来是继承NestedScrollingChild去处理的。
2、ScrollView嵌套RecyclerView
这个大家可以去试一下,大家会发现哎呀,也没有嵌套滑动的冲突啊
这个是因为什么?
这是因为RecyclerView实现了NestedScrollingChild接口,以便和父View协同处理滑动事件,从而避免了滑动冲突的问题。
注意:自从RecyclerView 22.2.0版本以后,它才默认实现了滑动冲突的处理。
AndroidX是在"com.android.support:recyclerview-v7"库升级到28.0.0版本之后推出的,所以AndroidX中的RecyclerView也是处理过的了。
3、ScrollView嵌套HorizontalScrollView
都做了处理,那找个没被处理过的给大家演示吧!!
小知识ScrollView没有横向的,需要用HorizontalScrollView
这里用ScrollView作为上下滑动父控件、HorizontalScrollView作为左右滑动子控件来给大家演示:
先看一下图做下铺垫,有的同学就喜欢图不喜欢文字。
ps:最外层为一个ScrollView上下滑动,中途有一个HorizontalScrollView左右滑动
一开始想用自己搭建布局给大家看,但是粘贴XML代码整篇文章又太乱了,我后面放到gitee上大家自取。
先说一下思路昂:首先按照上面布局搭建的话,就会发生中间的HorizontalScrollView不能进行左右滑动,也就是说被ScrollView拦截了。哎呦喂,滑动嵌套终于发生了。
步骤一:先要获取用户的滑动动作
那怎么先获取滑动动作呢:来个简单的数学,嘻嘻
我们可以得到滑动过程中的两个点的坐标。一般情况下根据水平和竖直方向滑动的距离差就可以判断方向。
也可以通过两个点的夹角去判断。
假设起点为(2 ,2)终点为(5,4)
这样我们通过(5-2)-(4-2)= 1可知横向的移动大于竖向的移动。那么我们就可以判断出来是横向移动
步骤二:去拦截不该移动的ScrollView
这里使用外部拦截和内部拦截分别去处理
外部拦截法
上面简述了,外部拦截要控制父View的onInterceptTouchEvent()方法
利用上面判断的方法判断滑动方向,之后通过onInterceptTouchEvent的返回值来决定是否拦截。
了解事件分发的兄弟都知道,返回true是拦截,false和super是传递给下层。
直接上代码:(代码中也有部分说明)
class MyScrollerView : ScrollView {
//构造函数省略了
private var mLastMoveX = 0
private var mLastMoveY = 0
override fun onInterceptTouchEvent(ev: MotionEvent): Boolean {
var intercept = false
val moveX = ev.x.toInt()
val moveY = ev.y.toInt()
when (ev.action) {
MotionEvent.ACTION_DOWN -> {
intercept = false;
//调用ViewPager的onInterceptTouchEvent方法初始化mActivePointerId
super.onInterceptTouchEvent(ev)
}
MotionEvent.ACTION_MOVE -> {
//横坐标的增量
val deltaX = moveX - mLastMoveX
//纵坐标的增量
val deltaY = moveY - mLastMoveY
//我的的内部是横向的,所以当横向移动距离大的时候
//那么我们外层的ScrollView就不要进行拦截。反之进行拦截
intercept = abs(deltaX) <= abs(deltaY)
Log.d("MyScrollerView", "intercept=$intercept")
}
MotionEvent.ACTION_UP -> {
intercept = false
}
}
mLastMoveX = moveX
mLastMoveY = moveY
return intercept
}
}
内部拦截法
上面说了要用子滑动View调用requestDisallowInterceptTouchEvent()决定父View是否可拦截。 当然这个事情是发生在dispatchTouchEvent这个阶段的。
下面我们来用代码实现一下:
class MyHorizontalScrollView : HorizontalScrollView {
//构造函数省略了
private var mLastMoveX = 0
private var mLastMoveY = 0
override fun dispatchTouchEvent(ev: MotionEvent): Boolean {
val moveX = ev.x.toInt()
val moveY = ev.y.toInt()
when (ev.action) {
MotionEvent.ACTION_DOWN -> {
parent.requestDisallowInterceptTouchEvent(true)
}
MotionEvent.ACTION_MOVE -> {
//横坐标的增量
val deltaX = moveX - mLastMoveX
//纵坐标的增量
val deltaY = moveY - mLastMoveY
//我的的内部是横向的,所以当横向移动距离大的时候, 就去通知父view不要拦截
if (abs(deltaX) > abs(deltaY)){
parent.requestDisallowInterceptTouchEvent(false)
}
}
}
mLastMoveX = moveX
mLastMoveY = moveY
return super.dispatchTouchEvent(ev)
}
}
使用API解决拦截
直接把外层的ScrollView换成NestedScrollView
<androidx.core.widget.NestedScrollView
android:id="@+id/scrollView_out"
.......>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
.......>
<ImageView
android:layout_width="match_parent"
android:layout_height="300dp"
android:background="@color/white"/>
<HorizontalScrollView
android:id="@+id/ScrollView_2"
之后再测试一下,发现哎呦喂!!!还真奏效呢。这是因为什么呢? 简单说一下
NestedScrolling是Android5.0推出的嵌套滑动机制。他有NestedScrollingChild、NestedScrollingParent两个接口和NestedScrollingChildHelper、NestedScrollingParentHelper两个辅助类来帮助控件实现嵌套滑动,CoordinatorLayout便是基于这个机制实现各种神奇的滑动效果。
具体的可以看这篇文章NestedScrolling嵌套滑动机制之基础篇
外部与内部滑动方向相同
ViewPage和RecyclerView
其实网上很多博文对同方向的滑动冲突有点错误的引导:
比如很多用ViewPage和RecyclerView来举例。大家可以试一试。
这种是没有问题的,它本身就会传递到子View去处理的。
他其实在业务场景中你ViewPage中有RecyclerView肯定是想内部的RecyclerView进行滑动的。所以这部分你处理个毛。
所以对于ViewPage和RecyclerView这种。你可以去尝试一下你会发现当子RecyclerView滑动到末尾的时候。
再次滑动就会触发ViewPage的滑动。
所以可能会碰到的需求场景是,即便子RecyclerView滑到末尾也不要触发ViewPage的滑动。这时候我们才需要去处理。
场景一:ViewPage和RecyclerView滑动到末尾后不触发ViewPage滑动
解决方案:大家估计想到了,没错就是重写内部的RecyclerView。只要是在RecyclerView就不让上层处理。(关于正式需求上的优化,后面自己在基础上加就行了。比如:滑动速度和距离上的问题)
class MyRecyclerView : RecyclerView {
//一些构造函数省略了
//.....
override fun dispatchTouchEvent(ev: MotionEvent): Boolean {
if (ev.action == MotionEvent.ACTION_DOWN || ev.action == MotionEvent.ACTION_MOVE) {
parent.requestDisallowInterceptTouchEvent(true)
} else {
parent.requestDisallowInterceptTouchEvent(false)
}
return super.dispatchTouchEvent(ev)
}
}
ScrollView嵌套ScrollView
场景二:当你使用ScrollView嵌套ScrollView,或者HorizontalScrollView嵌套HorizontalScrollView发生的。内部的ScrollView不能滑动:
原因:两个ScrollView嵌套时,滑动距离达到滑动手势判定阈值(mTouchSlop)的这个MOVE事件,会先经过父View 的onInterceptTouchEvent()方法,父View直接把事件拦截,子 View 的onTouchEvent()方法里虽然也会在判定滑动距离足够后调用requestDisallowInterceptTouchEvent(true)。
解决:大家肯定想到了,直接把外层的ScrollView进行自定义,重写onInterceptTouchEvent。让他传递给内部的ScrollView就OK了。确实如此!!!
override fun onInterceptTouchEvent(ev: MotionEvent): Boolean {
if(ev.action == MotionEvent.ACTION_MOVE || ev.action == MotionEvent.ACTION_DOWN){
super.onInterceptTouchEvent(ev);
return false
}
return true
}
多种情况嵌套(电商首页)
电商首页这个实例我就不在这说了,不然写的也很匆忙,文章也很长。
我在下一篇文章直接把CoordinatorLayout完整解析一下。
之后全篇把这个电商首页作为例子去写一下。
总结
嵌套滑动的冲突其实,经过API的版本迭代Google已经利用NestedScrolling 机制帮我处理了很多了。
有些比较特殊的场景需要我们去处理,但是万变不离其中,都是通过事件分发的机制去解决的。
所以大家要把事件分发搞清楚,再来理解嵌套滑动,去解决嵌套滑动的问题。
我也是看了好多次之后才对嵌套滑动有了一点理解。
希望读者们多多提意见!!!