View 事件体系
1. View 的基础知识
View 的基础知识有:View 的位置参数、MotionEvent 和 TouchSlop 对象、VelocityTracker、GestureD etector 和 Scroller 对象。
1.1 View 的位置参数
View 的位置主要由它的四个顶点来决定,分别对应于View的四个属性:top、left、right、bottom,其中top、left为左上角坐标,right和bottom为右下角坐标。
1 | left = getLeft(); |
View 中的其他参数如 x、y、transitionX 和 transitionY,其中x,y为View相对于父容器左上角的坐标,transitionX和transitionY为相对于View左上角坐标的偏移量,初始值为0。几个参数的换算关系为:
1 | x = left+transitionX ; |
如果为当前View执行补间动画,View的实际位置不会发生变化即left等四个属性不会改变,此时改变的是x、y、transitionX和transitionY四个变量。注意事项:
- Event#getRawX() :获取的是相对于屏幕的坐标
- Event#getX():获取相对于当前View左上角的坐标
- View#getX():获取的是相对于父容器的坐标。
1.2 MotionEvent 和 TouchSlop
MotionEvent 产生的典型事件类型如下:
- ACTION_DOWN 手指接触屏幕
- ACTION_MOVE 手指在屏幕上移动
- ACTION_UP 手指抬起
每一次点击事件都会触发ACTION_DOWN和ACTION_UP事件,如果有滑动的话也会触发ACTION_MOVE。从ACTION_DOWN 到 ACTION_UP 事件是一次完整的点击的事件流程,当然其中也会有事件分发。
TouchSlop 是系统所能识别的被认为是滑动的最小距离,如果滑动的距离小于这个常量,系统就不会下发ACTION_MOVE 这个事件,可以通过 ViewConfiguration.get(this).getScaledTouchSlop()
方法获取这个常量。
1.3 VelocityTracker、GestureDetector 和 Scroller
VelocatiyTracker
速度追踪,用于追踪手指在滑动过程中的速度,包括水平速度和垂直速度,速度是可能为负值的。
1
2
3
4
5
6
7
8VelocityTracker velocityTracker = VelocityTracker.obtain();
velocityTracker.addMovement(event);
// 设置事件间隔
velocityTracker.computeCurrentVelocity(1000);
int xVelocity = (int) velocityTracker.getXVelocity();
int yVelocity = (int) velocityTracker.getYVelocity();
velocityTracker.clear();
velocityTracker.recycle();GestureDetector
手势检测,用于辅助检测用户的单机、滑动、长按、双击等行为。
1
2
3
4GestureDetector gestureDetector = new GestureDetector(this);
// 解决长按无法滑动问题
gestureDetector.setIsLongpressEnabled(false);
return gestureDetector.onTouchEvent(event);Scroller
主要用来实现View的滑动,当使用 View 的 scrollTo/scrollBy 方法来进行滑动时,其过程是瞬间完成的,没有过渡效果这样用户体验不好。使用Scroller可以实现View滚动时候的过渡效果,其过程不是瞬间完成的,而是在设置的时间间隔内完成。Scroller 本身无法让View实现弹性滑动,需要配合 View#computerScroll 方法。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16private Scroller scroller;
public void smoothScrollTo(int destX, int destY) {
int scrollX = getScrollX();
int delta = destX - scrollX;
scroller.startScroll(scrollX, 0, delta, 0, 1000);
invalidate();
}
@Override
public void computeScroll() {
if (scroller.computeScrollOffset()) {
scrollTo(scroller.getCurrX(), scroller.getCurrY());
postInvalidate();
}
}
2. View绘制的整体流程
Android 中 Activity 是作为应用程序的载体存在的,它代表一个完整的用户界面,提供了一个窗口来绘制各种视图,PhoneWindow 是 Activity 和 View 交互的窗口。DecorView 本质上是 FrameLayout ,包含 TitleView 和ContentView,TitleView是我们设置的ActionBar,ContentView是我们在 onCreate 方法中调用 setContentView 时候定义的。
2.1 绘制的整体流程
当Activity启动时,绘制会从根视图 ViewRootImpl 的 performTraversals() 方法开始,从上到下遍历整个视图,每个View 控件负责绘制自己,而 ViewGroup 还需要负责通知自己的子 View 进行绘制操作。视图的绘制过程分为三个步骤,分别是测量(Measure)、布局(Layout)和绘制(Draw)。performTraversals() 方法的核心代码如下:
1 | private void performTraversals() { |
2.2 Measure
MeasureSpec 表示的是一个32位的整形值,它的高2位表示测量模式SpecMode,低30位表示某种测量模式下的规格大小 SpecSize。MeasureSpec 是View的一个静态内部类,用来说明应该如何测量这个View。
MeasureSpec 的三种测量模式:
- UNSPECIFIED:不指定测量模式,父视图没有限制子视图的大小,子视图可以是想要的任何尺寸,通常用于系统内部,应用开发中很少使用到。
- EXACTLY:精确测量模式,当前视图的 layout_width 或者 layout_height 指定为具体数值或者match_parent 时生效,表示父视图已经决定了子视图的精确大小,这种模式下View 的测量值就是SpecSize的值。
- AT_MOST:最大值模式,当该视图的 layout_width 或 layout_height 指定为wrap_content时生效,此时子视图的尺寸可以是不超过父视图允许的最大尺寸的任何尺寸。
对于普通View,其MeasureSpec由父容器的测量模式和自身的LayoutParam 决定。
1 | // ViewGroup.java |
2.3 Layout
Layout 过程用来确定View在父容器中的布局位置,它是由父容器获取子View的位置参数后,调用子View 的layout 方法并将位置参数传入实现的。
2.4. Draw
2.4.1 View 绘制顺序
super.onDraw() 方法
1
2
3
4
5
6
7
8public class AppImageView extends ImageView {
protected void onDraw(Canvas canvas) {
// 如果有背景图片,绘制在背景图片之下
super.onDraw(canvas);
// 绘制在背景图片之上
}
}super.dispatchDraw() 方法,只存在于 ViewGroup 对象中。每一个 ViewGroup 在绘制时会先调用 onDraw 方法绘制完自己的主体之后才去绘制它的子View。
1
2
3
4
5
6
7
8public class SpottedLinearLayout extends LinearLayout {
...
// 把 onDraw() 换成了 dispatchDraw()
protected void dispatchDraw(Canvas canvas) {
super.dispatchDraw(canvas);
//绘制在完成ViewGroup中子View绘制之前
}
}super.onDrawForeground() 绘制前景方法,要求 minSdk 版本达到23,不然低版本手机没有效果。
1
2
3
4
5
6
7
8
9public class AppImageView extends ImageView {
...
public void onDrawForeground(Canvas canvas) {
// 会被xml设置的 foreground 属性遮住
super.onDrawForeground(canvas);
... // 绘制「New」标签
}
}super.draw() 方法
1
2
3
4
5
6
7
8
9
10
11public AppEditText extends EditText {
...
public void draw(Canvas canvas) {
// 绘制在所有绘制发生之前,包括背景
canvas.drawColor(Color.parseColor("#66BB6A")); // 涂上绿色
super.draw(canvas); // View 绘制总调度方法
// 绘制在所有绘制之后
}
}
draw() 方法是绘制过程的调度方法。一个View的绘制过程都发生在 draw() 方法里,包括绘制背景、主体、子View、滑动相关以及前景的绘制。
1 | // View.java 的 draw() 方法的简化版大致结构(是大致结构,不是源码哦): |
关于绘制方法,有两点需要注意一下:
- 出于效率的考虑,
ViewGroup
默认会绕过draw()
方法,换而直接执行dispatchDraw()
,以此来简化绘制流程。所以如果你自定义了某个ViewGroup
的子类(比如LinearLayout
)并且需要在它的除dispatchDraw()
以外的任何一个绘制方法内绘制内容,你可能会需要调用View.setWillNotDraw(false)
这行代码来切换到完整的绘制流程(是「可能」而不是「必须」的原因是,有些 ViewGroup 是已经调用过setWillNotDraw(false)
了的,例如ScrollView
)。 - 有的时候,一段绘制代码写在不同的绘制方法中效果是一样的,这时你可以选一个自己喜欢或者习惯的绘制方法来重写。但有一个例外:如果绘制代码既可以写在
onDraw()
里,也可以写在其他绘制方法里,那么优先写在onDraw()
,因为 Android 有相关的优化,可以在不需要重绘的时候自动跳过onDraw()
的重复执行,以提升开发效率。享受这种优化的只有onDraw()
一个方法。
3. View 的事件分发机制
事件分发机制即 MotionEvent 对象在Activity、ViewGroup、View之间的传递,分发过程主要由三个方法共同完成:dispatchTouchEvent、onInterceptTouchEvent 和 onTouchEvent。
1 | public boolean diapatchTouchEvent(MotionEvent event) |
用来进行事件的分发。如果事件能够传递给当前View,那么此方法一定会被调用,返回结果受当前View 的 onTouchEvent 和下级 View 的 dispatchTouchEvent 方法的影响,表示是否消耗当前事件。
1 | public boolean onIterceptTouchEvent(MotionEvent event) |
只存在于 ViewGroup中,用于判断是否拦截某个事件,如果当前 ViewGroup 拦截了某个事件,那么在同一个事件序列当中,此方法不会被再次调用,返回结果表示是否拦截当前事件。
1 | public boolean onTouchEvent(MotionEvent event) |
用来处理点击事件,返回结果表示是否消耗当前事件,如果不消耗,则在同一个事件序列中,当前View无法再次收到事件。
当一个点击事件产生后,它的传递过程遵循如下顺序:Activity -> Window (PhoneWindow -> DecorView ) -> ViewGroup ->View,实际上的传递方式是 ViewGroup -> View。这里我们先由ViewGroup 的 dispatchTouchEvent 方法开始分析,ViewGroup#dispatchTouchEvent() 的部分代码逻辑如下所示:
1 | // ViewGroup.java |
这里可以看到ViewGroup会在如下两种情况下会判断是否拦截当前事件:事件类型为 ACTION_DOWN
或者mFirstTouchTarget != null
。从后续的代码中,我可以看到当 ACTION_DOWN 事件被ViewGroup的子元素成功处理时,mFirstTouchTarget 会被赋值指向并指向子元素。所以当ViewGroup中拦截当前事件,此时mFirstTouchTarget参数为空,此时的 ACTION_MOVE 和 ACTION_UP 也无法向下传递且不会再调用ViewGroup#onInterceptTouchEvent 方法。对于参数 disallowIntercept(不允许拦截) 指定true时,intercept为false,这个参数可由ViewGroup某些方法设置,解决ViewGroup和View的事件冲突。
总结:
- 注意 Activity#dispatchTouchEvent() 方法在返回值为true或false,事件不会继续向下传递且不会触发Activity#onTouchEvent() 方法和View#onClick()方法。
- 当ACTION_DOWN事件被在ViewGroup#onInterceptTouchEvent() 拦截后,ACTION_MOVE和ACTION_UP事件无法继续传递,且onInterceptTouchEvent() 不再被调用。
- 某个View如果不消耗ACTION_DOWN事件,那么后续的事件不会交给它处理,并将事件交与它的父View处理。
- 正常处理顺序优先级 View#onTouchListener > View#onTouchEvent > View#onClickListener。
- 可以通过 requestDisallowInterceptTouchEvent 方法可以在子元素中干涉父元素的事件分发过程,但是ACTION_DOWN事件触发。例如调用此方法可以处理EditText内容滚动和ScrollView滚动冲突的问题。