在项目中使用RecyclerView嵌套RecyclerView,其中内部RecyclerView使用到了GridLayoutManager,在遇到item个数不满足一行时,会在页面右侧透出空白位, 如下图所示.
目前点击空白位是没有点击响应事件的,我们想实现点击响应以扩大用户可以进入LandingPage的机会,在实现角度可以通过以下三种方式去实现:
- 重写RecyclerView的onMeasure实现宽度自适应
- 实现EmptyItem占位空白区域并给出点击事件
- 针对RecyclerView实现空白区域点击
本着最小改动的原则,我们采用了第三种方案进行探索。
一、前置知识
我们先来回顾一下Android控件事件转发流程:
-
点击事件自上而下传递,当点击事件产生后由Activity来处理,传递给PhoneWindows,再传递给DecorView,最后传给指定ViewGroup
-
boolean dispatchTouchEvent(event)实现了整个迭代回调过程,其中调用onInterceptTouchEvent、onTouchEvent和child.dispatchTouchEvent
- Down方式通过dispatchTouchEvent分发,分发的目的是为了找到真正需要处理完整Touch请求的View。当某个View或者ViewGroup的onTouchEvent事件返回true时,便表示它是真正要处理这次请求的View,之后的Aciton_UP和Action_MOVE将由它处理
-
ViewGroup#dispatchTouchEvent 实现 整个分发链和消费链的串联过程
- 事件分发链只触及点击位置穿透的控件,由父到子,由上到下. 具体的实现在于 Gropu#dispatchTouchEvent中会倒序遍历 Childrens, 遍历过程中会校验 触摸点位置是否在子View范围内或者子view是否在播放动画
- 消费链中一旦被消费(返回true)就终止整个事件分发流程
- ViewGroup 和 ChildView 同时注册了事件监听器(onClick等),事件优先给 ChildView,会被 ChildView消费掉,ViewGroup 不会响应。因为 ChildView位于消费链的前端
- onInterceptTouchEvent有两个作用:1.拦截Down事件的分发。2.中止Up和Move事件向目标View传递,使得目标View所在的ViewGroup捕获Up和Move事件
-
View#dispatchTouchEvent 处理单击事件(onClick)、长按事件(onLongClick)、触摸事件(onTouch),和View自身 onTouchEvent 方法的调度流程
- 调度顺序应该是 onTouchListener > onTouchEvent > onLongClickListener > onClickListener
- 给 View 注册 OnTouchListener 不会影响 View 的可点击状态。即使给 View 注册 OnTouchListener ,只要不返回 true 就不会消费事件
- 只要View是CLICKABLE,LONG_CLICKABLE,CONTEXT_CLICKABLE就会消费该点击事件。无论点击回调和长按回调中如何处理,都会消费点击事件(返回true)
- 点击包括很多种情况:譬如给View注册了 onClickListener、onLongClickListener、OnContextClickListener 其中的任何一个监听器或者设置了 android:clickable=”true”
- 某些 View 默认就是可点击的,例如,Button,CheckBox 等
- 调度顺序应该是 onTouchListener > onTouchEvent > onLongClickListener > onClickListener
更详细的内容可参看Android控件事件转发流程全解析
二、歧路一:给RecyclerView的父容器设置OnClickListener
第一个想法其实就是直接给父布局设置ClickListener,认为:在点击RecyclerView的空白区域时,没有子控件消费touch事件,RecyclerView也没消费触摸事件,那么自然就能回调给父容器的OnClickListener
然而,在实际尝试过程中,并没有触发父容器的OnClickListener。 我们先来看RecyclerView#onInterceptTouchEvent的源码:
@Override
public boolean onInterceptTouchEvent(MotionEvent e) {
if (mLayoutFrozen) {
// When layout is frozen, RV does not intercept the motion event.
// A child view e.g. a button may still get the click.
return false;
}
if (dispatchOnItemTouchIntercept(e)) {
cancelTouch();
return true;
}
if (mLayout == null) {
return false;
}
final int action = e.getActionMasked();
final int actionIndex = e.getActionIndex();
switch (action) {
case MotionEvent.ACTION_DOWN:
....
case MotionEvent.ACTION_UP: {
mVelocityTracker.clear();
stopNestedScroll(TYPE_TOUCH);
} break;
}
return mScrollState == SCROLL_STATE_DRAGGING;
}
从最后一行代码上我们可以看到,RecycleView并没有说强制拦截touch向子控件的传递,那么我们可以基本断定,之所以没有回调父布局的ClickListener,肯定是由于空白区域引发了:
- 没有子View消耗事件
- 事件被传回RecycleView#onTouchEvent函数,该函数必然消耗了该事件
我们把视线转向RecycleView#onTouchEvent的源码:
@Override
public boolean onTouchEvent(MotionEvent e) {
...
switch (action) {
case MotionEvent.ACTION_DOWN: {
mScrollPointerId = e.getPointerId(0);
mInitialTouchX = mLastTouchX = (int) (e.getX() + 0.5f);
mInitialTouchY = mLastTouchY = (int) (e.getY() + 0.5f);
int nestedScrollAxis = ViewCompat.SCROLL_AXIS_NONE;
if (canScrollHorizontally) {
nestedScrollAxis |= ViewCompat.SCROLL_AXIS_HORIZONTAL;
}
if (canScrollVertically) {
nestedScrollAxis |= ViewCompat.SCROLL_AXIS_VERTICAL;
}
startNestedScroll(nestedScrollAxis, TYPE_TOUCH);
} break;
....
}
....
if (!eventAddedToVelocityTracker) {
mVelocityTracker.addMovement(vtev);
}
vtev.recycle();
return true;
}
从最后一行代码可以看出,RecycleView应该是默认会消耗掉触摸事件的,这也是为什么我们设置父容器的点击事件不起作用
三、歧路二:给RecyclerView设置OnClickListener
仅接下来的想法肯定就是直接给RecyclerView设置ClickListener,认为:在点击RecyclerView的空白区域时,没有子控件消费touch事件,那么自然就能回调给自身的OnClickListener
然而,在实际尝试过程中,并也没有触发的OnClickListener。我们知道View的onTouchEvent是类似如下的结构:
public boolean onTouchEvent(MotionEvent event) {
...
final int action = event.getAction();
// 检查各种 clickable
if (((viewFlags & CLICKABLE) == CLICKABLE ||
(viewFlags & LONG_CLICKABLE) == LONG_CLICKABLE) ||
(viewFlags & CONTEXT_CLICKABLE) == CONTEXT_CLICKABLE) {
switch (action) {
case MotionEvent.ACTION_UP:
...
removeLongPressCallback(); // 移除长按
...
performClick(); // 检查单击
...
break;
case MotionEvent.ACTION_DOWN:
...
checkForLongClick(0); // 检测长按
...
break;
...
}
return true; // ◀︎表示事件被消费
}
return false;
}
在检查单击的过程中去触发点击事件,然而我们来看RecyclerView#onTouchEvent的源码:
@Override
public boolean onTouchEvent(MotionEvent e) {
...
switch (action) {
....
case MotionEvent.ACTION_UP: {
mVelocityTracker.addMovement(vtev);
eventAddedToVelocityTracker = true;
mVelocityTracker.computeCurrentVelocity(1000, mMaxFlingVelocity);
final float xvel = canScrollHorizontally
? -mVelocityTracker.getXVelocity(mScrollPointerId) : 0;
final float yvel = canScrollVertically
? -mVelocityTracker.getYVelocity(mScrollPointerId) : 0;
if (!((xvel != 0 || yvel != 0) && fling((int) xvel, (int) yvel))) {
setScrollState(SCROLL_STATE_IDLE);
}
resetTouch();
} break;
....
}
...
}
呐呢~ 我的ClickListener.onClick呢? 在这里我们就也找到了RecyclerView的OnClickListener没被触发的原因:RecyclerView重写了onTouchEvent其中根本没去管点击监听的触发
四、实现过程
我们继续探索RecyclerView的源码,我们可以发现其并没有重写dispatchTouchEven函数,这意味着什么?
我们可以通过setOnTouchListener去实现对触摸事件的自定义监听
4.1 如何区分触摸触空白区域和item区域
第一个想法其实就是拿到点击位置的xy坐标,然后遍历判断是否在RecyclerView某个childview中,然而每一次去进行遍历判断看上去很消耗性能。
庆幸的是,在操作过程中,我们发现在setOnTouchListener的onTouch(View view, MotionEvent motionEvent)
** 如果触摸的是空白区域,则View会回调为RecyclerView **
因此,我们可以通过如下的方式去判断空白区域的触摸事件:
recycleView.setOnTouchListener(new View.OnTouchListener() {
@Override
public boolean onTouch(View v, MotionEvent event) {
if (v instanceof RecyclerView){
//TODO 发现只有点击了空白处,v.getId,才能打印出东西
}
return false;
}
});
4.2 如何鉴别点击事件
我们目前是监听了onTouch事件,其中down move up都会触发该事件,我们不能全部都响应,而是应该做出来类似点击的效果,这时候我们就要借助另外一个工具GestureDetector.OnGestureListener
private class gesturelistener implements GestureDetector.OnGestureListener{
public boolean onDown(MotionEvent e) {
// TODO Auto-generated method stub
return false;
}
public void onShowPress(MotionEvent e) {
// TODO Auto-generated method stub
}
public boolean onSingleTapUp(MotionEvent e) {
// TODO Auto-generated method stub
return false;
}
public boolean onScroll(MotionEvent e1, MotionEvent e2,
float distanceX, float distanceY) {
// TODO Auto-generated method stub
return false;
}
public void onLongPress(MotionEvent e) {
// TODO Auto-generated method stub
}
public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX,
float velocityY) {
// TODO Auto-generated method stub
return false;
}
}
onSingleTapUp(MotionEvent e):从名子也可以看出,一次单独的轻击抬起操作,也就是轻击一下屏幕,立刻抬起来,才会有这个触发,当然,如果除了Down以外还有其它操作,那就不再算是Single操作了,所以也就不会触发这个事件。 触发顺序:
- 点击一下非常快的(不滑动)Touchup:
- onDown->onSingleTapUp->onSingleTapConfirmed
- 点击一下稍微慢点的(不滑动)Touchup:
- onDown->onShowPress->onSingleTapUp->onSingleTapConfirmed
五、最终方案
由于只针对内部recyclerView进行了onTouch监听,在性能上并不会有干扰。
public class RecyclerMarginClickHelper {
public static void setOnMarginClickListener(final RecyclerView recyclerView, final View.OnClickListener onClickListener){
if(recyclerView == null || onClickListener == null){
return;
}
final GestureDetector gestureDetector = new GestureDetector(recyclerView.getContext(), new GestureDetector.OnGestureListener() {
@Override
public boolean onDown(MotionEvent e) {
return false;
}
@Override
public void onShowPress(MotionEvent e) {
}
@Override
public boolean onSingleTapUp(MotionEvent e) {
if(onClickListener != null){
onClickListener.onClick(recyclerView);
}
return false;
}
@Override
public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) {
return false;
}
@Override
public void onLongPress(MotionEvent e) {
}
@Override
public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) {
return false;
}
});;
recyclerView.setOnTouchListener(new View.OnTouchListener() {
@Override
public boolean onTouch(View view, MotionEvent motionEvent) {
//发现只有点击了空白处,v是自身recyclerView
if (view instanceof RecyclerView){
return gestureDetector.onTouchEvent(motionEvent);
}
return false;
}
});
}
}