【View系列】手把手教你解决ViewPager2滑动冲突
常见场景浅析
为了方便大家更好的吸收本篇博客的知识,先描述一个常见场景帮大家理一理事件分发流程。这个场景大家肯定有见过:给RecyclerView的Item设置一个点击事件,点击这个Item通常会有两种情形:
- 快速点击,直接触发Item的点击事件
- 手指按到这个Item然后开始滑动,这个时候RecyclerView跟着手指开始滑动了
场景1 我们可以简单理解为:快速点击的时候,由于Item设置有点击事件,导致View的onTouchEvent默认返回true,然后滑动距离又小于设备的 scaledTouchSlop(最小滑动距离) ,所以触发了点击事件。
场景2 我们手指按下去的时候,明明ItemView在ACTION_DOWN的时候已经把事件消费了(返回了true),后面的事件流程又是什么样的呢?这个时候我们猜测: 刚开始ACTION_DOWN事件是传递给了ItemView,后续的ACTION_MOVE事件,由于滑动距离大于了scaledTouchSlop(最小滑动距离), RecyelerView又将事件拦截下来了,接着后续事件就交由RecyclerView来处理了 。为了印证我们的猜测,老规矩还是从源码中找答案,首先我们分析下RecyclerView的 onInterceptTouchEvent 方法
//RecyclerView.java
private int mScrollState = SCROLL_STATE_IDLE;
@Override
public boolean onInterceptTouchEvent(MotionEvent e) {
final boolean canScrollHorizontally = mLayout.canScrollHorizontally();
final boolean canScrollVertically = mLayout.canScrollVertically();
switch (action) {
case MotionEvent.ACTION_MOVE: {
final int x = (int) (e.getX(index) + 0.5f);
final int y = (int) (e.getY(index) + 0.5f);
if (mScrollState != SCROLL_STATE_DRAGGING) {
final int dx = x - mInitialTouchX;
final int dy = y - mInitialTouchY;
boolean startScroll = false;
if (canScrollHorizontally && Math.abs(dx) > mTouchSlop) {
mLastTouchX = x;
startScroll = true;
if (canScrollVertically && Math.abs(dy) > mTouchSlop) {
mLastTouchY = y;
startScroll = true;
if (startScroll) {
setScrollState(SCROLL_STATE_DRAGGING);
} break;
return mScrollState == SCROLL_STATE_DRAGGING;
复制代码
下面我们整理一下上面代码的逻辑:
- 标注5 处的代码是onInterceptTouchEvent的返回值,由于 mScrollState 初始值是SCROLL_STATE_IDLE,所以我们可以得知RecyclerView在接受到 ACTION_DOWN事件 的时候并 没有拦截事件 (这是一句废话,如果ACTION_DOWN事件都拦截的话,那么所有自己的子view都拿不到触摸事件了),这也是为啥ACTION_DOWN的时候ItemView能够拿到对应事件。
- 标注1 处的两个变量是用来标识RecyclerView是竖直滑动还是水平滑动
- 标注2 处的代码是如果RecyclerView是水平滑动同时滑动值超过了 mTouchSlop ,则将 startScroll 置为true,接着下面 标注4 处的代码就会将scrollState设置成 SCROLL_STATE_DRAGGING ,紧接着会导致onInterceptTouchEvent方法返回true。 标注3 处的代码同理。
onInterceptTouchEvent小结
RecyclerView会在 手指滑动距离大于最小滑动距离 的时候 拦截触摸事件 。
dispatchTouchEvent方法分析
了解了RecyclerView的 onInterceptTouchEvent方法 的逻辑后, 我们接着分析下RecyclerView的 dispatchTouchEvent 方法的流程,由于RecyclerView并没有重写 dispatchTouchEvent 方法,所以我们直接分析ViewGroup的dispatchTouchEvent方法即可, 相关核心源码如下:
//ViewGroup.java
@Override
public boolean dispatchTouchEvent(MotionEvent ev) {
// Check for interception.
final boolean intercepted;
if (actionMasked == MotionEvent.ACTION_DOWN
|| mFirstTouchTarget != null) {
//如果child调用了requestDisallowInterceptTouchEvent(true)则disallowIntercept为true
final boolean disallowIntercept = (mGroupFlags & FLAG_DISALLOW_INTERCEPT) != 0;
if (!disallowIntercept) {
intercepted = onInterceptTouchEvent(ev);
ev.setAction(action); // restore action in case it was changed
} else {
intercepted = false;
TouchTarget target = mFirstTouchTarget;
while (target != null) {
final TouchTarget next = target.next;
final boolean cancelChild = resetCancelNextUpFlag(target.child)
|| intercepted;
if (dispatchTransformedTouchEvent(ev, cancelChild,
target.child, target.pointerIdBits)) {
handled = true;
//如果cancelChild为true 则将mFirstTouchTarget的头部去掉并回收
if (cancelChild) {
if (predecessor == null) {
mFirstTouchTarget = next;
} else {
predecessor.next = next;
target.recycle();
target = next;
continue;
predecessor = target;
target = next;
//View.java
private boolean dispatchTransformedTouchEvent(MotionEvent event, boolean cancel,
View child, int desiredPointerIdBits) {
final boolean handled;
final int oldAction = event.getAction();
//如果cancel为true进入代码块
if (cancel || oldAction == MotionEvent.ACTION_CANCEL) {
//重置event为MotionEvent.ACTION_CANCEL 触发了child的ACTION_CANCEL
event.setAction(MotionEvent.ACTION_CANCEL);
if (child == null) {
handled = super.dispatchTouchEvent(event);
} else {
handled = child.dispatchTouchEvent(event);
event.setAction(oldAction);
return handled;
复制代码
如果看过我之前发的View事件分发源码解析文章,对上面这些代码应该不陌生,没看过的推荐先去看一下再来看这一块内容。这里我重新再简略捋一遍当前场景的流程:
- ACTION_DOWN事件过来,走入RecyclerView的onInterceptTouchEvent方法,RecyclerView没有拦截,交由ItemView处理,然后ItemView由于可以点击,所以默认消费了down事件,从而也导致了 mFirstTouchTarget不为空 ,down事件流程结束。
- 紧接着一系列ACTION_MOVE事件过来,由于这个时候 mFirstTouchTarget不为空 ,所以还是走进去了 标注2 处RecyclerView的onInterceptTouchEvent方法,这时候如果我们的滑动距离超过了最小滑动事件,那么RecyclerView内部会返回true,导致 intercepted为true
- 步骤2后,紧接着会导致 标注3 的 cancelChild 也为true,进入到 标注4 处的dispatchTransformedTouchEvent方法,导致了 ItemView的ACTION_CANCEL的触发
- 标注5 处代码,如果cancelChild为true会导致当前 mFirstTouchTarget为空 ,并回收掉之前的 mFirstTouchTarget ,然后后续事件再次传递过来的时候,发现mFisrtTouchTarget==null,直接RecyclerView自己就开始消费后续事件系列了。
到这我们基本弄清楚了 场景2 的事件传递的整体流程。这个时候有人可能会问讲这个场景的流程有啥用呢?实际上这个场景梳理明白了,我们自己就会对整体事件分发流程比较清晰了,而且中间是不是也涉及到了事件流方向的更改,算是一个为咱们解决滑动冲突做个铺垫吧。
小结:
从上面的分析我们可以知道:
- 子view把事件消费的同时,父View仍然可以通过onInterceptTouchEvent来在合适的时候再把事件给拦截住,并交给自己处理
- 如果子view拿到事件后,不想让父view再拦截,可以通过调用requestDisallowInterceptTouchEvent(true)来禁用父view拦截事件(父View不会再调用自己的onInterceptTouchEvent方法了)
ViewPager2嵌套RecyclerView滑动冲突分析及解决
ViewPager2是谷歌近两年新出的用来替代ViewPager的一个控件,ViewPager支持的功能ViewPager2都支持,并且ViewPager2还支持竖直方向。下面我们简单看下ViewPager2的大致结构:
//ViewPager2.java
//ViewPager2继承自ViewGroup
public final class ViewPager2 extends ViewGroup {
public ViewPager2(@NonNull Context context) {
super(context);
initialize(context, null);
public ViewPager2(@NonNull Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
initialize(context, attrs);
private void initialize(Context context, AttributeSet attrs) {
//创建一个RecyclerView
mRecyclerView = new RecyclerViewImpl(context);
//将recyclerView添加到ViewPager2中
attachViewToParent(mRecyclerView, 0, mRecyclerView.getLayoutParams());
复制代码
从上面的代码可以知道 ViewPager2继承自ViewGroup并添加了个RecyclerView ,因此我们基本可以 简单认为ViewPager2就是一个RecyclerView 。现在我们再想象一个场景,如果横向滑动的ViewPager2其中一个Page里面包裹了一个横向滑动的RecyclerView,横向滑动谁消费呢?通过文章开头我们对RecyclerView的分析,横向滑动距离超过 最小滑动距离的时候 , ViewPager2里面的RecyclerView 会将事件拦截下来,所以基本来说我们划不动 子Page的RecyclerView ,大家可以试试。既然我们知道了滑动冲突的原因,这个时候我们可以想想大概的解决思路。。。em。。。。好,十分钟过去了,大概解决方案我们可以按照以下几个步骤解决:
- 由于RecyclerView并没有拦截down事件,我们可以在接受到down事件的时候请求父View不要拦截事件
- 后续move事件到我们这并且水平滑动距离大于最小滑动距离的时候,再问我们的子RecyclerVIew能否在这个滑动方向滑动,如果能,继续禁止父类拦截事件。如果不能则允许父类拦截事件
想清楚解决问题的步骤了,那么我们就可以直接动手写了,先搞一个通用的官方方案,用NestedScrollableHost作为RecyclerView的容器可以解决滑动冲突,具体代码及注释如下:
class NestedScrollableHost : FrameLayout {
constructor(context: Context) : super(context)
constructor(context: Context, attrs: AttributeSet?) : super(context, attrs)
private var touchSlop = 0
private var initialX = 0f
private var initialY = 0f
//循环遍历找到viewPager2
private val parentViewPager: ViewPager2?
get() {
var v: View? = parent as? View
while (v != null && v !is ViewPager2) {
v = v.parent as? View
return v as? ViewPager2
//找到子RecyclerView
private val child: View? get() = if (childCount > 0) getChildAt(0) else null
init {
//最小滑动距离
touchSlop = ViewConfiguration.get(context).scaledTouchSlop
private fun canChildScroll(orientation: Int, delta: Float): Boolean {
val direction = -delta.sign.toInt()
return when (orientation) {
//判断子RecyclerView在水平方向是否可以滑动deltaX
0 -> child?.canScrollHorizontally(direction) ?: false
//判断子RecyclerView在竖直方向是否可以滑动deltaY
1 -> child?.canScrollVertically(direction) ?: false
else -> throw IllegalArgumentException()
override fun onInterceptTouchEvent(e: MotionEvent): Boolean {
handleInterceptTouchEvent(e)
return super.onInterceptTouchEvent(e)
private fun handleInterceptTouchEvent(e: MotionEvent) {
val orientation = parentViewPager?.orientation ?: return
//如果子RecyclerView在viewPager2的滑动方向上不能滑动直接返回
if (!canChildScroll(orientation, -1f) && !canChildScroll(orientation, 1f)) {
return
if (e.action == MotionEvent.ACTION_DOWN) {
initialX = e.x
initialY = e.y
//down事件直接强制禁止父view拦截事件,后续事件先交给子RecyclerView先判断是否能够消费
//如果这一块不强制禁止父view会导致后续事件可能直接没到子RecyclerView就被父view拦截了
//默认RecyclerView onTouchEvent返回true但是viewPager2会在onInterceptTouchEvent拦截住
parent.requestDisallowInterceptTouchEvent(true)
} else if (e.action == MotionEvent.ACTION_MOVE) {
//计算手指滑动距离
val dx = e.x - initialX
val dy = e.y - initialY
val isVpHorizontal = orientation == ORIENTATION_HORIZONTAL
val scaledDx = dx.absoluteValue * if (isVpHorizontal) .5f else 1f
val scaledDy = dy.absoluteValue * if (isVpHorizontal) 1f else .5f
//滑动距离超过最小滑动值
if (scaledDx > touchSlop || scaledDy > touchSlop) {
if (isVpHorizontal == (scaledDy > scaledDx)) {
//如果viewPager2是横向滑动但手势是竖直方向滑动,则允许所有父类拦截
parent.requestDisallowInterceptTouchEvent(false)
} else {
//手势滑动方向和viewPage2是同方向的,需要询问子RecyclerView是否在同方向能滑动
if (canChildScroll(orientation, if (isVpHorizontal) dx else dy)) {
//子RecyclerView能滑动直接禁止父view拦截事件
parent.requestDisallowInterceptTouchEvent(true)
} else {
//子RecyclerView不能滑动(划到第一个Item还往右滑或者划到最后面一个Item还往左划的时候)允许父view拦截
parent.requestDisallowInterceptTouchEvent(false)