多重嵌套布局下滑动冲突解决方案和思路

前言

本文主要介绍在开发仿网易新闻客户端遇到的多层控件滑动冲突问题,以及如何控制事件传递的方法和解决问题的过程。

问题复现

一、局部

以下是界面布局的简图,问题分析将根据以下展开。 一共有三层:

  1. 最外层是一个Viewpager,如图所示在Viewpager中有两个页面,分别是“博客”和“旅游”。

  2. 中间层垂直的RecyclerView。

  3. 最内层是水平的RecyclerView。为了方便下文介绍,命名为MyRecyclerView。在其中,有一系列的item,将item命名为ImageViewHolder,并为其增加点击事件(OnClickListener)。如下图蓝色方块所示。

二、问题代码:

item_image.xml

<com.hhxplaying.neteasedemo.netease.widget.HorizontalRecyclerView
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="@dimen/list_big_image_item_height"
    android:id="@+id/rv_subrecycleview"
    android:layout_below="@+id/tv_title"
    android:layout_centerVertical="true"
    android:layout_centerHorizontal="true"
    />

HorizontalRecyclerView.java

public class HorizontalRecyclerView extends RecyclerView {
public HorizontalRecyclerView(Context context, AttributeSet attrs) {
    super(context, attrs);
}

public HorizontalRecyclerView(Context context) {
    super(context);
}

public HorizontalRecyclerView(Context context, AttributeSet attrs, int defStyle) {
    super(context, attrs, defStyle);
}

@Override
public boolean onInterceptTouchEvent(MotionEvent event) {
    Log.d("HorizontalImage", "parent onIntercept " + event);
    boolean b = super.onInterceptTouchEvent(event);
    Log.d("HorizontalImage", "parent onIntercept " + b);
    return b;
}

@Override
public boolean onTouchEvent(MotionEvent event) {
    boolean t =  super.onTouchEvent(event);
    Log.d("HorizontalRecyclerView", "parent onTouchEvent " + t);
    return t;
}

HorizontalImageRecyclerViewAdapter.java

ImageViewHolder holder = new ImageViewHolder();
holder.imageView.setOnClickListener(this);          

三、情境描述:

以上两种情况的log基本相同(不同的是正常情况下onTouch会一直调用),一开始是false的原因是水平列表的item的内部设置了OnClickListen,有可能触发。所以滑动了一段距离后,不可能再触发click,所以返回true。 Log如下所示。

D/HorizontalRecyclerView: parent onIntercept MotionEvent { action=ACTION_MOVE
D/HorizontalRecyclerView: parent onIntercept false                           
D/HorizontalRecyclerView: parent onIntercept MotionEvent { action=ACTION_MOVE
D/HorizontalRecyclerView: parent onIntercept false                           
D/HorizontalRecyclerView: parent onIntercept MotionEvent { action=ACTION_MOVE
D/HorizontalRecyclerView: parent onIntercept false                           
D/HorizontalRecyclerView: parent onIntercept MotionEvent { action=ACTION_MOVE
D/HorizontalRecyclerView: parent onIntercept false                           
D/HorizontalRecyclerView: parent onIntercept MotionEvent { action=ACTION_MOVE
D/HorizontalRecyclerView: parent onIntercept true                            
D/HorizontalRecyclerView: parent onTouchEvent true                           
D/HorizontalRecyclerView: parent onTouchEvent true                            

换而言之,如果去掉holder.imageView.setOnClickListener(this),那么只会调用一次onIntercept并返回false,而后onTouchEvent会在第一时间触发。Log如下所示。

D/HorizontalRecyclerView: parent onIntercept MotionEvent { action=ACTION_DOWN
D/HorizontalRecyclerView: parent onIntercept false                           
D/HorizontalRecyclerView: parent onTouchEvent true                           
D/HorizontalRecyclerView: parent onTouchEvent true                           
D/HorizontalRecyclerView: parent onTouchEvent true                                                                                                 

所以,如果去掉内部点击事件的话,这个问题处理会简单很多。但是在这里,我们需要内部item响应点击事件。

当HorizontalRecyclerView到尽头时,此时用户水平滑动会导致Viewpager的切换。造成用户的不方便,需要改善。在这种情况下,理想的交互应该是Viewpager不响应此时的水平滑动。

解决思路

第一次尝试解决:

为什么内部item是否设置了OnClickListner会影响快速滑动的表现,原因在于HorizontalRecyclerView中的onTouchEvent的语句:

getParent().requestDisallowInterceptTouchEvent(true)

能否及时调用。如果调用晚了,viewpagr已经有了偏移则此语句不会产生效果。

第二次尝试解决:

以上代码有一个问题:用户快速滑动HorizontalRecyclerView的结果不理想(内部设置点击事件),还是和原来一样。 原因上文已经说明了HorizontalRecyclerView 中的onTouchEvent方法调用不及时。

针对这个不及时的问题:考虑在HorizontalRecyclerView的onInterceptTouchEvent进行拦截。代码如下:即用户按下时就阻止外部控件响应。

HorizontalRecyclerView.java

 @Override
public boolean onInterceptTouchEvent(MotionEvent event) {
    switch (event.getAction()) {
        case MotionEvent.ACTION_DOWN:
            getParent().requestDisallowInterceptTouchEvent(true);
            firstTime = true;
            x = event.getX();
            y = event.getY();
            break;
    }

    return super.onInterceptTouchEvent(event);;
}

当时经过测试,发现增加以上代码后,会导致无法通过竖直滑动外部的垂直RecyclerView。因为统统都被getParent().requestDisallowInterceptTouchEvent(true)拦截了(可以查看这个方法的源代码,会一直向上递归调用)。

所以,我们希望当判断不能出再出发点击事件时,应该不再拦截外部空间响应垂直滑动的事件。 其中isIntercept变量是来自与水平列表中的Item的onTouch方法中,检测该事件能否发生点击(判断时间和滑动距离)。如果能则变量为false。如果不能满足点击事件则为true,表示外部应该拦截后续事件。

HorizontalRecyclerView.java

@Override
public boolean onInterceptTouchEvent(MotionEvent event) {
    boolean temp = super.onInterceptTouchEvent(event);
    switch (event.getAction()) {
        case MotionEvent.ACTION_DOWN:
            getParent().requestDisallowInterceptTouchEvent(true);
            firstTime = true;
            x = event.getX();
            y = event.getY();
            break;
    }

    boolean isIntercept = ((HorizontalImageRecyclerViewAdapter)getAdapter()).isIntercept;

    Log.d("HorizontalImage", "parent onIntercept isIntercept:" + isIntercept);

    return temp || isIntercept;
}

最后方案:

根据以上的尝试,我们现在理一下思路:

最终代码

见项目的https://github.com/freedomofme/NeteaseHorizontalRecyclerView.javaHorizontalImageRecyclerViewAdapter

文本记录了这个问题探索的正确轨迹,没有记录在实际开发中所走的弯路。写的不正确和含糊不清之处,还望斧正。

附录

ViewConfiguration.java

点击时间间隔

 /**
 * Defines the duration in milliseconds we will wait to see if a touch event * is a tap or a scroll. If the user does not move within this interval, it is * considered to be a tap.
 */
private static final int TAP_TIMEOUT = 100;
/**
 * Defines the duration in milliseconds we will wait to see if a touch event * is a jump tap. If the user does not complete the jump tap within this interval, it is * considered to be a tap.
 */
private static final int JUMP_TAP_TIMEOUT = 500;

点击距离间隔

/**
 * @return Distance in dips a touch can wander before we think the user is scrolling * * @deprecated Use {@link #getScaledTouchSlop()} instead.
 */
@Deprecated
public static int getTouchSlop() {
    return TOUCH_SLOP;
} 

//这是默认值 8dp。 
private static final int TOUCH_SLOP = 8; 

/** * @return Distance in pixels a touch can wander before we think the user is scrolling */ 
public int getScaledTouchSlop() { return mTouchSlop;  }