View 事件体系

1. View 的基础知识

View 的基础知识有:View 的位置参数、MotionEvent 和 TouchSlop 对象、VelocityTracker、GestureD etector 和 Scroller 对象。

1.1 View 的位置参数

View 的位置主要由它的四个顶点来决定,分别对应于View的四个属性:top、left、right、bottom,其中top、left为左上角坐标,right和bottom为右下角坐标。截屏2019-12-04上午10.04.27

1
2
3
4
left = getLeft();
top = getTop();
right = getRight();
bottom = getBottom();

View 中的其他参数如 x、y、transitionX 和 transitionY,其中x,y为View相对于父容器左上角的坐标,transitionX和transitionY为相对于View左上角坐标的偏移量,初始值为0。几个参数的换算关系为:

1
2
x = left+transitionX ;
y = top+transitionY

如果为当前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
    8
    VelocityTracker 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
    4
    GestureDetector 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
    16
    private 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
private void performTraversals() {
...
int childWidthMeasureSpec = getRootMeasureSpec(mWidth, lp.width);
int childHeightMeasureSpec = getRootMeasureSpec(mHeight, lp.height);
...
// 执行测量流程
// Ask host how big it wants to be
performMeasure(childWidthMeasureSpec, childHeightMeasureSpec);
...
//执行布局流程
performLayout(lp, mWidth, mHeight);
...
//执行绘制流程
performDraw();

}

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
// ViewGroup.java
public static int getChildMeasureSpec(int spec, int padding, int childDimension) {
int specMode = MeasureSpec.getMode(spec);
int specSize = MeasureSpec.getSize(spec);

int size = Math.max(0, specSize - padding);

int resultSize = 0;
int resultMode = 0;

switch (specMode) {
// Parent has imposed an exact size on us
case MeasureSpec.EXACTLY:
if (childDimension >= 0) {
resultSize = childDimension;
resultMode = MeasureSpec.EXACTLY;
} else if (childDimension == LayoutParams.MATCH_PARENT) {
// Child wants to be our size. So be it.
resultSize = size;
resultMode = MeasureSpec.EXACTLY;
} else if (childDimension == LayoutParams.WRAP_CONTENT) {
// Child wants to determine its own size. It can't be
// bigger than us.
resultSize = size;
resultMode = MeasureSpec.AT_MOST;
}
break;

// Parent has imposed a maximum size on us
case MeasureSpec.AT_MOST:
if (childDimension >= 0) {
// Child wants a specific size... so be it
resultSize = childDimension;
resultMode = MeasureSpec.EXACTLY;
} else if (childDimension == LayoutParams.MATCH_PARENT) {
// Child wants to be our size, but our size is not fixed.
// Constrain child to not be bigger than us.
resultSize = size;
resultMode = MeasureSpec.AT_MOST;
} else if (childDimension == LayoutParams.WRAP_CONTENT) {
// Child wants to determine its own size. It can't be
// bigger than us.
resultSize = size;
resultMode = MeasureSpec.AT_MOST;
}
break;

// Parent asked to see how big we want to be
case MeasureSpec.UNSPECIFIED:
if (childDimension >= 0) {
// Child wants a specific size... let him have it
resultSize = childDimension;
resultMode = MeasureSpec.EXACTLY;
} else if (childDimension == LayoutParams.MATCH_PARENT) {
// Child wants to be our size... find out how big it should
// be
resultSize = View.sUseZeroUnspecifiedMeasureSpec ? 0 : size;
resultMode = MeasureSpec.UNSPECIFIED;
} else if (childDimension == LayoutParams.WRAP_CONTENT) {
// Child wants to determine its own size.... find out how
// big it should be
resultSize = View.sUseZeroUnspecifiedMeasureSpec ? 0 : size;
resultMode = MeasureSpec.UNSPECIFIED;
}
break;
}
//noinspection ResourceType
return MeasureSpec.makeMeasureSpec(resultSize, resultMode);
}

2.3 Layout

Layout 过程用来确定View在父容器中的布局位置,它是由父容器获取子View的位置参数后,调用子View 的layout 方法并将位置参数传入实现的。

2.4. Draw

2.4.1 View 绘制顺序
  • super.onDraw() 方法

    1
    2
    3
    4
    5
    6
    7
    8
    public 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
    8
    public 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
    9
    public 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
    11
    public AppEditText extends EditText {
    ...

    public void draw(Canvas canvas) {
    // 绘制在所有绘制发生之前,包括背景
    canvas.drawColor(Color.parseColor("#66BB6A")); // 涂上绿色

    super.draw(canvas); // View 绘制总调度方法
    // 绘制在所有绘制之后
    }
    }

draw() 方法是绘制过程的调度方法。一个View的绘制过程都发生在 draw() 方法里,包括绘制背景、主体、子View、滑动相关以及前景的绘制。

1
2
3
4
5
6
7
8
9
10
11
12
// View.java 的 draw() 方法的简化版大致结构(是大致结构,不是源码哦):

public void draw(Canvas canvas) {
...

drawBackground(Canvas); // 绘制背景(不能重写)
onDraw(Canvas); // 绘制主体
dispatchDraw(Canvas); // 绘制子 View
onDrawForeground(Canvas); // 绘制滑动相关和前景

...
}

关于绘制方法,有两点需要注意一下:

  1. 出于效率的考虑,ViewGroup 默认会绕过 draw() 方法,换而直接执行 dispatchDraw(),以此来简化绘制流程。所以如果你自定义了某个 ViewGroup 的子类(比如 LinearLayout)并且需要在它的除 dispatchDraw() 以外的任何一个绘制方法内绘制内容,你可能会需要调用 View.setWillNotDraw(false) 这行代码来切换到完整的绘制流程(是「可能」而不是「必须」的原因是,有些 ViewGroup 是已经调用过 setWillNotDraw(false) 了的,例如 ScrollView)。
  2. 有的时候,一段绘制代码写在不同的绘制方法中效果是一样的,这时你可以选一个自己喜欢或者习惯的绘制方法来重写。但有一个例外:如果绘制代码既可以写在 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// ViewGroup.java
@Override
public boolean dispatchTouchEvent(MotionEvent ev) {
...
// 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 {
intercepted = 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;
}
...
}

这里可以看到ViewGroup会在如下两种情况下会判断是否拦截当前事件:事件类型为 ACTION_DOWN 或者mFirstTouchTarget != null。从后续的代码中,我可以看到当 ACTION_DOWN 事件被ViewGroup的子元素成功处理时,mFirstTouchTarget 会被赋值指向并指向子元素。所以当ViewGroup中拦截当前事件,此时mFirstTouchTarget参数为空,此时的 ACTION_MOVE 和 ACTION_UP 也无法向下传递且不会再调用ViewGroup#onInterceptTouchEvent 方法。对于参数 disallowIntercept(不允许拦截) 指定true时,intercept为false,这个参数可由ViewGroup某些方法设置,解决ViewGroup和View的事件冲突。

总结

  1. 注意 Activity#dispatchTouchEvent() 方法在返回值为true或false,事件不会继续向下传递且不会触发Activity#onTouchEvent() 方法和View#onClick()方法。
  2. 当ACTION_DOWN事件被在ViewGroup#onInterceptTouchEvent() 拦截后,ACTION_MOVE和ACTION_UP事件无法继续传递,且onInterceptTouchEvent() 不再被调用。
  3. 某个View如果不消耗ACTION_DOWN事件,那么后续的事件不会交给它处理,并将事件交与它的父View处理。
  4. 正常处理顺序优先级 View#onTouchListener > View#onTouchEvent > View#onClickListener。
  5. 可以通过 requestDisallowInterceptTouchEvent 方法可以在子元素中干涉父元素的事件分发过程,但是ACTION_DOWN事件触发。例如调用此方法可以处理EditText内容滚动和ScrollView滚动冲突的问题。
# View
Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×