文章目录
事件分发
事件分发就是用户触摸屏幕所产生的一系列事件的传递。
常见的事件类型
//MotionEvent.java
public static final int ACTION_DOWN = 0; //手指按下
public static final int ACTION_UP = 1; //手指松开
public static final int ACTION_MOVE = 2; //手指移动
public static final int ACTION_POINTER_DOWN = 5; //多指的按下(按下之前已经有手指在屏幕上)
public static final int ACTION_POINTER_UP = 6; //多指的松开(松开之后仍然有手指在屏幕上)
/*
ACTION_CANCEL:告诉当前View你再也不会收到事件了,事件已经被某父布局拦截了。
当按下事件传递给某View处理了,那么后续的事件也会交由该View处理(因为事件分发通过传递按下事件来确认处理者)。
如果该View的某父布局在onInterceptTouchEvent对事件进行拦截,该View就会收到ACTION_CANCEL事件。
后续的所有事件都会被那拦截的父布局偷走。
*/
public static final int ACTION_CANCEL = 3;
/*
ACTION_OUTSIDE:按下事件在View区域以外的地方发生就会收到这个事件,而且只有第一次点击才能收到。
注意:收到该事件是有前提的。必须设置两个LayoutParams flag
WindowManager.LayoutParams.FLAG_WATCH_OUTSIDE_TOUCH
WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL
PopupWindow点击其他区域,自动关闭的原理就是用这个实现的。
*/
public static final int ACTION_OUTSIDE = 4;
MotionEvent常用的方法
getAction() //获取事件类型。
getX() //获得触摸点在当前 View 的 X 轴坐标。
getY() //获得触摸点在当前 View 的 Y 轴坐标。
getRawX() //获得触摸点在整个屏幕的 X 轴坐标。
getRawY() //获得触摸点在整个屏幕的 Y 轴坐标。
//多指触控相关方法
getActionMasked() //与 getAction() 类似,多点触控必须使用这个方法获取事件类型。
getActionIndex() //获取产生事件的手指下标
getPointerCount() //获取在屏幕上手指的个数
getPointerId(int pointerIndex) //获取一个手指ID,在手指按下和抬起之间ID始终不变。
findPointerIndex(int pointerId) //通过手指ID获取手指下标,之后通过下标获取其他内容。
getX(int pointerIndex) //获取某一个手指的X坐标
getY(int pointerIndex) //获取某一个手指的Y坐标
/*
getAction() 与 getActionMasked() 的区别
多指触控下会产生大量事件,如何在获取事件类型的同时区分这些事件就是一个大问题。
谷歌工程师通过 int类型共32位(0x00000000),他们用最低8位(0x000000ff)表示事件类型,再往前的8位(0x0000ff00)表示事件编号
以手指按下为例:
ACTION_DOWN 的默认数值为 (0x00000000)
ACTION_POINTER_DOWN 的默认数值为 (0x00000005)
那么
第1个手指按下 ACTION_DOWN (0x00000000)
第2个手指按下 ACTION_POINTER_DOWN (0x00000105)
第3个手指按下 ACTION_POINTER_DOWN (0x00000205)
第4个手指按下 ACTION_POINTER_DOWN (0x00000305)
通过getActionMasked()方法除了能拿到事件外,还可以拿到手指下标
1、多点触控时必须使用 getActionMasked() 来获取事件类型。
2、单点触控时由于事件数值不变,使用 getAction() 和 getActionMasked() 两个方法都可以。
3、使用 getActionIndex() 可以获取到这个index数值。不过请注意,getActionIndex() 只在 down 和 up 时有效,move 时是无效的。
*/
详情文章:MotionEvent详解
requestDisallowInterceptTouchEvent() - 阻止父布局拦截事件
子View为了防止父布局在onInterceptTouchEvent()
中拦截掉事件。
可以通过在自己的dispatchTouchEvent()
内调用getParent().requestDisallowInterceptTouchEvent(true);
,来让父布局不再进入onInterceptTouchEvent()
方法,从而阻止事件被父布局拦截。
事件分发机制
事件分发相关方法
dispatchTouchEvent():用来进行事件的分发,如果MotionEvent(点击事件)能够传递给该View,那么该方法一定会被调用。返回值由 本身的onTouchEvent() 和 子View的dispatchTouchEvent()的返回值 共同决定。
返回值为true,则表示该点击事件被本身或者子View消耗。
返回值为false,则表示该ViewGroup没有子元素,或者子元素没有消耗该事件。
onInterceptTouchEvent():在dispatchTouchEvent()中调用,用来判断是否拦截某个事件,如果当前View拦截了某个事件,那么在同一个事件序列中不会再访问该方法。
onTouchEvent():在dispatchTouchEvent()中调用,返回结果表示是否消耗当前事件,如果不消耗(返回false),则在同一个事件序列中View不会再次接收到事件。
事件分发流向
事件分发从上往下依次是Activity、ViewGroup、View。如果事件不被中断整个事件流向是一个类U型图
消费事件的总结:在哪个View的onTouchEvent 返回true,那么ACTION_MOVE和ACTION_UP的事件从上往下传到这个View后就不再往下传递了,而直接传给自己的onTouchEvent 并结束本次事件传递过程。
ACTION_MOVE、ACTION_UP总结:ACTION_DOWN事件在哪个控件消费了(return true), 那么ACTION_MOVE和ACTION_UP就会从上往下(通过dispatchTouchEvent)做事件分发往下传,就只会传到这个控件,不会继续往下传,如果ACTION_DOWN事件是在dispatchTouchEvent消费,那么事件到此为止停止传递,如果ACTION_DOWN事件是在onTouchEvent消费的,那么会把ACTION_MOVE或ACTION_UP事件传给该控件的onTouchEvent处理并结束传递。
详情文章:图解 Android 事件分发机制
伪代码看事件分发方法的关系
// 点击事件产生后
// 步骤1:调用dispatchTouchEvent()
public boolean dispatchTouchEvent(MotionEvent ev) {
boolean consume = false; //代表 是否会消费事件
// 步骤2:判断是否拦截事件
if (onInterceptTouchEvent(ev)) {
// a. 若拦截,则将该事件交给当前View进行处理
// 即调用onTouchEvent()去处理点击事件
consume = onTouchEvent (ev) ;
} else {
// b. 若不拦截,则将该事件传递到下层
// 即 下层元素的dispatchTouchEvent()就会被调用,重复上述过程
// 直到点击事件被最终处理为止
consume = child.dispatchTouchEvent (ev) ;
}
// 步骤3:最终返回通知 该事件是否被消费(接收 & 处理)
return consume;
}
事件分发源码
直接看这文章简单易懂。大佬真的讲得很好,膜拜!!!
View的OnTouchListener解析
没用重写View或者ViewGroup的话,可以通过setOnTouchListener()
方法来监听触摸事件。
OnTouchListener
优先于内部onTouchEvent
执行,并且通过onTouch
回调用通过返回值来控制事件传递,让View的onTouchEvent
不执行。
//View#dispatchTouchEvent中那么一段逻辑
...
ListenerInfo li = mListenerInfo;
if (li != null && li.mOnTouchListener != null
&& (mViewFlags & ENABLED_MASK) == ENABLED
&& li.mOnTouchListener.onTouch(this, event)) { //1、当onTouch返回true的话
result = true;
}
if (!result && onTouchEvent(event)) { //2、那么View#onTouchEvent是不会执行的
result = true;
}
...
return result;
滑动冲突与解决
解决滑动冲突的方法只有两个,总结就是外敷或内服。
例子:父布局的左右滑动与子视图的左右滑动起冲突,最明显的现象是在子视图区域进行滑动,父布局跟着发生了滑动。
外敷 - 外部拦截
解决冲突,从父布局入手。核心:找出冲突区域按需分配
上面例子用外部拦截的方案解决的话。父布局中要重写onInterceptTouchEvent
、onTouchEvent
方法,在按下事件时获取子视图区域。如果手指在子视图区域内,那么就不拦截事件传给子视图并且禁止自己的滑动逻辑的执行。反之手指不在子视图区域直接拦截事件给自己。
private boolean isIntercept;
private Rect childViewRect;
private Rect getChildViewRectFromId(@IdRes int id){
View view = this.findViewById(id);
if (view != null){
Rect rect = new Rect();
view.getGlobalVisibleRect(rect); //返回该view的全局矩阵
return rect;
}
return null;
}
@Override
public boolean onInterceptTouchEvent(MotionEvent event) {
switch (event.getAction()){
case MotionEvent.ACTION_DOWN:
childViewRect = getChildViewRectFromId(R.id.a_view);
break;
case MotionEvent.ACTION_MOVE:
if (childViewRect != null){
//手指在子视图区域内,就不拦截
isIntercept = !childViewRect.contains((int) event.getRawX(), (int) event.getRawY());
}
break;
case MotionEvent.ACTION_UP:
case MotionEvent.ACTION_CANCEL:
childViewRect = null;
break;
}
return isIntercept;
}
@Override
public boolean onTouchEvent(MotionEvent ev) {
if (!isIntercept) return true; //防止子视图onTouchEvent没有消费掉事件,回传回父布局
//滑动逻辑...
}
内服 - 内部拦截
解决冲突,从子视图入手。核心:阻止事件拦截,让事件给我消费
上面例子用内部拦截的方案解决的话。当事件分发到给自己的时候,调用requestDisallowInterceptTouchEvent
方法,阻止父布局拦截事件,然后事件让自己消费掉。
@Override
public boolean dispatchTouchEvent(MotionEvent ev) {
super.dispatchTouchEvent(ev);
requestDisallowInterceptTouchEvent(true); //在dispatchTouchEvent中调用,阻止父布局拦截事件
return true;
}
@Override
public boolean onTouchEvent(MotionEvent event) {
super.onTouchEvent(event);
return true;
}
实战 - ViewPage2 + DrawerLayout + RecyclerView冲突惨案
上下翻页的全屏ViewPage2,ViewPage2的item内有个从右往左拉出的DrawerLayout,DrawerLayout内有个上下滑动的RecycleView。
冲突1:拉出DrawerLayout会经常触发到ViewPage2的翻页
冲突2:RecycleView上下滑也会经常触发ViewPage2的翻页
解决方法:因为ViewPage2是final的无法继承。所以引入中间布局,专门处理这三者的冲突。通过禁止ViewPage2的滚动来解决冲突。
public class ListenTouchLayout extends LinearLayout {
private float touchDownX, touchDownY;
private Rect childViewRect;
@Setter
private ViewPager2 viewPager;
public ListenTouchLayout(Context context) {this(context, null, -1);}
public ListenTouchLayout(Context context, @Nullable AttributeSet attrs) {this(context, attrs, -1);}
public ListenTouchLayout(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
}
//是否允许ViewPager滑动
private void setViewPagerEnable(boolean isEnable){
if (viewPager != null){
viewPager.setUserInputEnabled(isEnable);
}
}
private Rect getChildViewRectFromId(@IdRes int id){
View view = this.findViewById(id);
if (view != null){
Rect rect = new Rect();
view.getGlobalVisibleRect(rect); //返回该view的全局矩阵
return rect;
}
return null;
}
@Override
public boolean dispatchTouchEvent(MotionEvent event) {
switch (event.getAction()){
case MotionEvent.ACTION_DOWN:
touchDownX = event.getRawX();
touchDownY = event.getRawY();
childViewRect = getChildViewRectFromId(R.id.recycler_view);
break;
case MotionEvent.ACTION_MOVE:
float moveX = event.getRawX();
float moveY = event.getRawY();
//左右滑动趋势,禁止Viewpager滑动
if (Math.abs(touchDownX - moveX) > Math.abs(touchDownY - moveY)){ //左右滑动趋势
setViewPagerEnable(false);
}else { //上下滑动趋势
if (childViewRect != null){
//在RecyclerView区域内,也禁止Viewpager滑动
setViewPagerEnable(!childViewRect.contains((int)moveX, (int)moveY));
}else {
setViewPagerEnable(true);
}
}
break;
case MotionEvent.ACTION_UP:
case MotionEvent.ACTION_CANCEL:
setViewPagerEnable(true);
touchDownX = 0;
touchDownY = 0;
break;
}
return super.dispatchTouchEvent(event);
}
}
将这个ListenTouchLayout放到ViewPager2 item布局中作为根布局就好了。