Android自定义控件从零开始-第三篇 深入解析View的事件和常用方法
目录
Android自定义控件从零开始-第一篇 View的绘制流程 中我们简单了解了View绘制最基础的一些流程和API。
Android自定义控件从零开始-第二篇 自定义TooBar、跑马灯 中我们根据最基础的绘制流程完成了两个简单的控件。
今天我们将深入解析View的事件和常用方法,学会了这些后,至少一些日常的自定义控件对你而言将不再是难点。
1、View的触摸事件
用户交互是除了页面显示之外的主要功能,比如用户点击一个按钮,滑动一个列表,左右切换页面,双指放大页面等操作都是对用户触摸事件的处理,实际中由于页面的复杂性,还可能造成事件冲突问题。所以在一段时期基本上每个面试Android岗位的都会问到事件分发,自定义控件有时候也会涉及到非常复杂的事件处理,所以需要对触摸事件的传递机制有深入的了解。
1.1 触摸事件的类型
触摸事件的类型主要在MotionEvent类中,从数值来说是从0到6。从7到12不是触摸事件,而是手机外设操作的事件,如鼠标、游戏操作杆等,我们只看触摸事件。下面是提取出来的类型和对应中文注释。
//手指按下屏幕动作
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_CANCEL = 3;
//手指离开View边界动作
public static final int ACTION_OUTSIDE = 4;
//多点触摸按下屏幕动作
public static final int ACTION_POINTER_DOWN = 5;
//多点触摸离开屏幕动作
public static final int ACTION_POINTER_UP = 6;
//非触摸事件,鼠标Hover移动动作
public static final int ACTION_HOVER_MOVE = 7;
//非触摸事件,比如鼠标滚轮移动操作
public static final int ACTION_SCROLL = 8;
//非触摸事件,鼠标Hover进入动作
public static final int ACTION_HOVER_ENTER = 9;
//非触摸事件,鼠标Hover离开动作
public static final int ACTION_HOVER_EXIT = 10;
//非触摸事件,外设按钮按下操作
public static final int ACTION_BUTTON_PRESS = 11;
//非触摸事件,外设按钮松开动作
public static final int ACTION_BUTTON_RELEASE = 12;
有兴趣可以自定义一个MyView,重写onTouchEvent方法,打印出所有的触摸事件log,这样对于事件类型和手指操作会更加直观的对应上,这里特别介绍几个不容易触发的事件。
- ACTION_CANCEL:取消当前手势动作。在View处理触摸事件过程中,父容器突然将事件劫持自己处理,就会给子View一个ACTION_CANCEL事件通知。比如说,TextView放入到ScrollView中,手指滑动TextView一段距离后,就能够收到一个ACTION_CANCEL事件,因为ScrollView要自己处理滚动事件了,后续事件就不会再传递给TextView了。
- ACTION_OUTSIDE:手指离开View边界动作。WindowManager类中定义了窗口标记FLAG_WATCH_OUTSIZE_TOUCH只有当View所在窗口Window开启了这个标记后,View才能收到用户点击窗口外面的事件。比如使用PopupWindow时,调用了setOutsideTouchable(true) 方法后,会设置 curFlags |= WindowManager.LayoutParams.FLAG_WATCH_OUTSIDE_TOUCH;开启PopupWindow外部点击事件,这个时候我们才能在代码内部处理窗口外部的事件来使PopupWindow消失,以下为重点代码:
@Override
public boolean onTouchEvent(MotionEvent event) {
....
if ((event.getAction() == MotionEvent.ACTION_DOWN)
&& ((x < 0) || (x >= getWidth()) || (y < 0) || (y >= getHeight()))) {
dismiss();
return true;
} else if (event.getAction() == MotionEvent.ACTION_OUTSIDE) {
//处理ACTION_OUTSIDE事件使PopupWindow消失
dismiss();
return true;
} else {
return super.onTouchEvent(event);
}
}
- ACTION_POINTER_DOWN:多点触摸动作。如果你重写onTouchEvent方法并想打印出对应log,你会发现没有对应的log,但事件确实发生了,而event.getAction()返回261。这里我们需要了解下event.getAction()返回值的含义,event.getAction()中的源码是 return nativeGetAction(mNativePtr),mNativePtr本质上一个32位的整型,低8位表示事件类型,即0到12,高8位表示触控点的索引,多点触摸时会给每个触摸点分配一个索引值,二进制显示261就是100000101,拆开后事件类型就是5,对应ACTION_POINTER_DOWN类型,所以应该使用event.getActionMasked()方法过滤掉触控点索引值。
- ACTION_POINTER_UP:多点触控时,一个触控点离开屏幕就会触发此事件,具体获取类型同上。
1.2 触摸事件的传递
事件传递过程涉及到几个API如下:
- dispatchTouchEvent:分发事件。如果返回true,表示事件分发下去后被处理了;如果返回fasle,则表示分发下去后没有被任何View处理。
- onInterceptTouchEvent:拦截事件。如果返回true,表示拦截事件;如果返回false,表示不拦截。这拦截的是本来要转给子View的事件,所以这个方法是ViewGroup独有的。
- onTouchEvent:处理事件。如果返回true,表示处理事件;如果返回false,表示不处理事件。
想要研究事件的传递,实质上就是研究上述三个API的调用顺序,传递过程如下图:
事件传递首先从ViewGroup开始,父容器调用dispatchTouchEvent分发事件,在该方法内会调用onInterceptTouchEvent判断是否为拦截事件,如果onInterceptTouchEvent返回true,表示拦截此事件,那么事件会直接传递给ViewGroup的onTouchEvent方法处理。如果onInterceptTouchEvent返回false,事件会传递给View的dispatchTouchEvent方法,View中没有onInterceptTouchEvent方法去判断是否拦截,所以会直接调用onTouchEvent方法处理事件。如果View的onTouchEvent方法返回true,表示事件被处理,事件传递结束,如果返回false,那么事件会传递给ViewGroup的onTouchEvent方法处理。
下面的小章节会很复杂很罗嗦,但我不是为了讲解功能而讲解,我们看源码除了为了看懂代码是干什么的,还要看懂别人为什么这么设计,我未来想创建一个好用的*该怎么做,能不能借鉴这些好的设计思路,这才是我们读源码的关键。
1.2.1 Activity的事件
当手指在屏幕上触发事件后,系统服务会将事件传递到当前显示的Activity,由Activity来继续分发事件。Activity中dispatchTouchEvent方法实现如下:
public boolean dispatchTouchEvent(MotionEvent ev) {
if (ev.getAction() == MotionEvent.ACTION_DOWN) {
onUserInteraction();
}
//获取到Activity的PhoneWindow,将事件传递给其中的DecorView,
//DecorView再将事件分发给它的子View
if (getWindow().superDispatchTouchEvent(ev)) {
//如果事件分发下去有子View消费,就直接返回
return true;
}
//如果DecorView将事件分发下去后没有任何View处理,就会交给Activity的onTouchEvent处理
return onTouchEvent(ev);
}
1.2.2 没有处理的事件
如果一个布局中的ViewGroup和View都不消费事件,即它们的onTouchEvent方法都返回一个false时,这个时候会发现只有ACTION_DOWN事件发生了传递。这是因为ACTION_DOWN相当于一个试探事件,如果这个事件传递下去,没有任何View想要处理,那么后续事件就没有继续往下传递的必要了。
用一个Activity为例子,当触摸一个Activity页面时,Activity还是能收到事件,并将事件传递给DecorView,具体的事件拦截是在DecorView中。先找到事件传递的源码,getWindow().superDispatchTouchEvent(ev)实际调用的PhoneWindow的superDispatchTouchEvent方法。
@Override
public boolean superDispatchTouchEvent(MotionEvent event) {
return mDecor.superDispatchTouchEvent(event);
}
PhoneWindow实际上调用的DecorView的superDispatchTouchEvent方法
public boolean superDispatcdishTouchEvent(MotionEvent event) {
return super.dispatchTouchEvent(event);
}
DecorView实际上调用的ViewGroup的dispatchTouchEvent方法,找到相关逻辑代码
// Check for interception.
final boolean intercepted;
if (actionMasked == MotionEvent.ACTION_DOWN
|| mFirstTouchTarget != null) {
final boolean disallowIntercept = (mGroupFlags & FLAG_DISALLOW_INTERCEPT) != 0;
if (!disallowIntercept) {
intercepted = onInterceptTouchEvent(ev);
ev.setAction(action); // restore action in case it was changed
} else {
intercepinted = false;
}
} else {
// There are no touch targets and this action is not an initial down
// so this view group continues to intercept touches.
intercepted = true;
}
这段代码是用来检测是否拦截子View的事件。当actionMasked == MotionEvent.ACTION_DOWN或者mFirstTouchTarget != null时,intercepinted等于true拦截事件。第一个条件很简单,主要看mFirstTouchTarget什么时候为null。mFirstTouchTarget时TouchTarget对象,用来保存处理触摸事件的View及触摸点id值,可以看到下方赋值方法,如果有子View处理事件,那么ViewGroup的dispatchTouchEvent方法给mFirstTouchTarget赋值,相反默认就为null,也就满足了拦截条件。
/**
* Adds a touch target for specified child to the beginning of the list.
* Assumes the target child is not already present.
*/
private TouchTarget addTouchTarget(@NonNull View child, int pointerIdBits) {
final TouchTarget target = TouchTarget.obtain(child, pointerIdBits);
target.next = mFirstTouchTarget;
mFirstTouchTarget = target;
return target;
}
综上所述,如果ACTION_DOWN分发下去没有被处理,会造成mFirstTouchTarget 为null,满足了拦截条件。其他后续事件又满足了actionMasked == MotionEvent.ACTION_DOWN事件,intercepinted等于true,这就是Activity视图根节点DecorView不会将ACTION_MOVE和ACTION_UP传递给子View的原理。
1.2.3 点击没有子View的位置
当点击ViewGroup但不在View范围内时,可以想得到View肯定不会触发事件,我们来研究具体的逻辑代码。
之前说过,父容器调用dispatchTouchEvent分发事件,我们直接在此方法中找判断触摸点不在子View的逻辑。
for (int i = childrenCount - 1; i >= 0; i--) {
...
final int childIndex = getAndVerifyPreorderedIndex(
childrenCount, i, customOrder);
final View child = getAndVerifyPreorderedView(
preorderedList, children, childIndex);
if (!child.canReceivePointerEvents()
|| !isTransformedTouchPointInView(x, y, child, null)) {
continue;
}
....
}
ViewGroup在分发事件时,会遍历所有的子View,这一步是为了检查子View的可见性和子View的位置,
如果子View不可见或者子View不在点击事件的位置,则会调用continue跳过这次循环,如果循环结束都没有找到子View,则把事件交给自己的onTouchEvent处理。
canReceivePointerEvents检查子View的可见:
protected boolean canReceivePointerEvents() {
//如果子View可见或者子View存在洞话,则返回true,表示子View可以接收触摸事件
return (mViewFlags & VISIBILITY_MASK) == VISIBLE || getAnimation() != null;
}
重点方法,很多自定义控件处理触摸都是套用此方法原理 isTransformedTouchPointInView方法将触摸事件的位置转为以子View为坐标系的位置,检查触摸点是否在子View范围内:
protected boolean isTransformedTouchPointInView(float x, float y, View child,
PointF outLocalPoint) {
//获取长度为2的临时数组,用来保存x,y
final float[] point = getTempLocationF();
point[0] = x;
point[1] = y;
//将触摸事件位置转换为以子View为坐标系的位置
transformPointToViewLocal(point, child);
//判断转换后的位置是否在子View内部
final boolean isInView = child.pointInView(point[0], point[1]);
if (isInView && outLocalPoint != null) {
outLocalPoint.set(point[0], point[1]);
}
return isInView;//true表示事件触摸点在子View的内容
}
坐标转换的代码很简单,但是需要理解坐标转换
public void transformPointToViewLocal(float[] point, View child) {
point[0] += mScrollX - child.mLeft;
point[1] += mScrollY - child.mTop;
...
}
mScrollX 和mScrollY 是当前ViewGroup的滚动偏移量,child.mLeft和 child.mTop是子View相对ViewGroup左边和顶部的位置。转换前point保存的坐标是以ViewGroup为坐标系,转换后point保存的坐标是以子View为坐标系。
1.2.4 点击多个子View的位置
多个子View重叠在一起,事件只会传递给最上层的View,这说明ViewGroup对自身子View的管理是有顺序的,看相关处理源码,也是在dispatchTouchEvent方法中
//对子View进行排序,Z值越大,绘制顺序靠前的排在数组后面,当子View个数小于等于1或者Z值都为0时,buildTouchDispatchChildList返回null
final ArrayList<View> preorderedList = buildTouchDispatchChildList();
//当preorderedList 等于空并且开启了可调整子View绘制顺序的标记时,customOrder 为true
final boolean customOrder = preorderedList == null
&& isChildrenDrawingOrderEnabled();
final View[] children = mChildren;
//从后往前遍历子View
for (int i = childrenCount - 1; i >= 0; i--) {
//如果customOrder为true,则返回调整了的下表;如果为false,返回i
final int childIndex = getAndVerifyPreorderedIndex(
childrenCount, i, customOrder);
//当customOrder 为true时,preorderedList 必为空,这时获取到的子View是绘制顺序最靠前的孩子
//当customOrder为false时,preorderedList 不为空,就获取到preorderedList最后面的子View
//如果preorderedList为空,则获取到的是默认子View数组中最后的子View
final View child = getAndVerifyPreorderedView(
preorderedList, children, childIndex);
......
}
通过上述源码可知,如果不指定Z值和绘制顺序的情况下,最后加入ViewGroup的子View最先获取到事件。具体的Z值和绘制顺序可以自行查看源码或者搜索资料,这里不在概述。
1.2.5 拦截子View的事件
这里是对 1.2.2 的拦截代码补充说明,也是对事件分发机制更深入的详解。
//是否拦截标志
final boolean intercepted;
//检查是否是ACTION_DOWN事件或者mFirstTouchTarget不为空
if (actionMasked == MotionEvent.ACTION_DOWN
|| mFirstTouchTarget != null) {
//检查是否不允许拦截子View的事件
final boolean disallowIntercept = (mGroupFlags & FLAG_DISALLOW_INTERCEPT) != 0;
if (!disallowIntercept) {//如果允许拦截
//调用onInterceptTouchEvent,决定是否拦截
intercepted = onInterceptTouchEvent(ev);
ev.setAction(action);
} else {
//如果不允许拦截,则标记为false
intercepinted = false;
}
} else {
// 如果事件不是ACTION_DOWN并且mFirstTouchTarget为空(没有子View消费事件),则拦截事件
intercepted = true;
}
......
}
由上述源码可知,如果在处理ACTION_DOWN事件时,ViewGroup选中拦截,也就是onInterceptTouchEvent返回true,表示拦截事件,最后将事件交给ViewGroup自己的onTouchEvent处理。
1.2.6 事件坐标系
在一个View和ViewGoup中,可通过MotionEvent的getX()和getY()获取触摸事件在当前控件中的位置,这就表明同一个触摸事件的坐标在ViewGroup和View中获到的值是不一样的。这里存在两个坐标系。
具体的转换方法在1.2.3已经说过,这里不在重复列出。
1.2.7 onTouch和onTouchEvent的关系
onTouch是控件设置触摸事件监听器中的方法,和onTouchEvent方法都是处理事件的,那两者有什么区别呢?我们从View类中dispatchTouchEvent方法中找到区别
if (li != null && li.mOnTouchListener != null
&& (mViewFlags & ENABLED_MASK) == ENABLED
&& li.mOnTouchListener.onTouch(this, event)) {//先回调onTouch方法
result = true;
}
//如果View是enable状态并且onTouch返回的是true,那么就不会调用onTouchEvent的方法
if (!result && onTouchEvent(event)) {
result = true;
}
}
View在分发事件时,在监听器不为null的情况下,会先调用监听器的onTouch方法,如果onTouch返回true,则表示事件在监听器消费了,resule标记为true,后面就不会调用onTouchEvent方法,反之则会调用。
2、自定义控件常用方法
之前面试过一个三年的安卓,问过自定义View有哪些方法是常用的,他就回答了测量布局绘制三个,所以还是需要多了解些。每个方法没有给出示例,只是简单描述了方法功能,后面会考虑单独拿出来做一篇文章,先了解下这些方法。
2.1 绘制相关方法
方法名 | 功能 |
---|---|
inflate | 用于解析一个XML布局文件,经常用于自定义组合式控件 |
onFinishInflate | 当所有的子View都解析完后调用,用于自定义ViewGroup中findViewByID,因为在构造方法中找子View的id会返回null,此时子View还没有解析好。 |
requestLayout | 触发measure过程和layout过程,不会调用draw重绘,用于控件的大小发生变化或是想要重置控件位置时。 |
onSizeChange | 控件的大小发生变化时调用,调用轨迹为layout——setFrame——sizeChange——onSizeChange。控件第一次布局时肯定会被调用,可用通过覆写此方法获取到控件的大小。常用语初始化与控件大小相关的成员变量 |
invalidate | 触发View的重新绘制,也就是调用onDraw方法,不会调用测量和布局过程。常用于动画,需要在主线程中使用。 |
postInvalidate | 和Invalidate一样,区别在于可以在子线程中更新UI。常用于触发事件后界面的修改。 |
setWillNotDraw | 用于实现ViewGroup本身的绘制,ViewGroup没有背景的情况下通常不会绘制自己,通过设置setWillNotDraw(false)可以绘制本身。 |
onAttachedToWindow | 当一个View绑定到Window上时的调用,肯定会在onDraw前调用。通常注册一些广播接收器、观察者或者开启一些任务。 |
onDetachedFromWindow | 从Window上移除一个View时调用,对应onAttachedToWindow,通常配合使用一个注册一个反注册。 |
ViewTreeObserver | 视图树的观察者,监听一些视图树的全局变化,包括整个视图树的布局、开始绘制、触摸模式的变化等,需要通过getViewTreeObserver()方法获取监听,常用于监听视图树将要发生绘制,可在View绘制之前做一些事件。 |
2.2 事件处理相关方法
方法名 | 功能 |
---|---|
ScrollTo和ScrollBy | ScrollBy表示滚动的最后位置,内部实现是通过ScrollTo的参数上加上已有的滚动偏移量实现的。ScrollTo表示滚动的偏移量,需要注意的是滚动并不是View的滚动,而是画布的滚动,所以如果想要滚到右边应该加上-号,此方法会触发invazlidate的调用,将View重新绘制一遍,但不会走测量布局过程,也就是View的大小和位置不会变化,变化的事View绘制的内容。 |
Scroller | 实现一个View平滑滚动的工具类,原理为设置一段滚动时间,每消耗一点时间,就重新计算一次新的滚动偏移量,触发View的重绘让View的内容滚动一次,经过多次绘制就会看到平滑滚动的效果。 |
setTranslationX和setTranslationY | 设置View在水平竖直方法相对left和top位置的偏移量,和scrollTo的区别在于不会触发View的重新绘制,只是将原来的绘制内容搬移了位置。 |
requestDisallowInterceptTouchEvent | 子View用于请求父容器是否拦截自己的时间,true时表示不允许父容器拦截自己的时间,通常在子View的dispatchTouchEvent方法或onTouchEvent中调用,常用于解决一些触摸事件冲突的问题。 |
GestureDectector | 用于自定义控件中的手势识别,能够是被短按、长按、滚动、单击、双击、快速滑动,极大简化了对用户触摸事件的处理,通常使用其内部类SimpleOnGestureListener接受识别的事件。 |
VelocityTracker | 用于对用户触摸事件速度的跟踪,GestureDector中就是用它来识别快速滑动的。常用于识别快速滑动或者其他手势 |
ViewDragHelper | 是V4包中的工具类,用于自定义ViewGroup中处理子View的拖动,基本要做一些拖动操作都需要用到它,可以极大简化拖动的处理 |
2.3
其他方法名 | 功能 |
---|---|
TypedValue | 用于完成单纯的数值到对应数据类型数值的转换,常用于自定义控件中做一些变量的初始化,如转px、dp或者sp单位。 |
本文地址:https://blog.csdn.net/Web311/article/details/109592154
上一篇: 网易考拉海购微商城店主项目可行性深度分析
下一篇: 王通:如何写一个吸引VC的商业计划书