View 的事件体系

View基础知识

一、View的相关坐标和位置

先来了解一个概念:

view动画不改变view的真实位置,就是肉眼看上去,view位置发生了变化,但是它的点击区域还是在原来的位置。

为了方便描述,下文中的的 真实View,表示真实位置的View看到的View,表示肉眼看到的View

属性动画和 setTranslationX / setTranslationY ,会改变显示位置和真实位置。

上图中view1是初始位置,通过执行setTranslation函数,得到的view2位置,其中lefttoprightbottom这几个值不会改变。

lefttoprightbottom

View的位置主要由它的四个顶点的位置来决定,分别对应 View 的四个属性:lefttoprightbottom。对应的函数是getLeftgetTopgetRightgetBottom

属性 含义
left View 左上顶点相对于父容器的横坐标
top View 左上顶点相对于父容器的纵坐标
right View 右下顶点相对于父容器的横坐标
bottom View 右下顶点相对于父容器的纵坐标

这几个值表示的是View的真身位置,它表示哪里,点击区域就在哪里。和肉眼在屏幕上看到的位置可能不一样。

XY

上面说到view动画不改变view的真实位置(也就是不改变viewlefttoprightbottom)。

看到的view相对于真实view,位置发生了变化,但是它的点击区域还是在真实view的位置。

从 Android 3.0 开始,View 增加了 x,y,translationXtranslationY

x,y 是看到的view的左上角相对父容器的坐标,但不同于 left 和 top ,这两个坐标点的值并一定都是相等的。

真实view 和 看到view的偏差用 translationXtranslationY 来表示

translationXtranslationY

android开发艺术探索中,关于translationX的讲解是错误的。真实情况是,translationX真实view 相对于看到view的 x 方向偏移量。

从上图可以得知:x = left + translationX

二、点击事件的XY坐标

三、MotionEvent

常见的触摸动作一共有三个:

  1. ACTION_DOWN手指按下动作
  2. ACTION_MOVE手指滑动动作
  3. ACTION_UP 手指抬起动作

四、GestureDetector

手势检测,检测用户的单击、双击、长按等操作。

通过创建GestureDetector对象并实现OnGestureListener接口(检测单击相关手势)或者OnDoubleTapListener接口(检测双击相关动作)。

五、TouchSlop

不同安卓设备认为的最小的滑动距离,低于此距离,将不认为用户在滑动,开发者可自行更改,默认为8dp

六、VelocityTracker

速度检测,可以计算出用户的手势的滑动速度。

注意,在VelocityTracker#computeCurrentVelocity(int time)中的time参数是我们计算速度的单位时间,相同的速度用不同的单位时间会有不同的表示方法。

七、Scroller

弹性滑动对象,由于View中的scrollTo/scrollBy都是瞬间完成位移的。对用户的使用体验不太友好,所以引入弹性滑动对象,使得View的滑动变得可视化。

View的滑动

一、scrollTo/scrollBy

View有专门的方法实现滑动,即scrollTo(), scrollBy();

这两个函数只能将View中间的内容进行位移,但是不能改变View本身的位置。

可以方便的实现滑动效果并且不影响内部元素的点击事件。

scrollTo(int destX, int destY)直接让View出现在目标位置,无中间动画。

参数变量的单位都是像素级。

二、使用动画

有两种动画的使用方法。

  1. View动画

    XML文件里面通过改变translationX/translationY的方式来实现View的移动效果。

  2. 属性动画

    Android3.0版本以上,可以使用ObjectAnimator类来实现属性动画的效果。

    Android3.0以下的版本中,我们需要自己加载开源动画库NineOldAndroids

XML文件中,有一个andoird:fillAfter="true|false"的选项。

当为true的时候,View在动画结束后,图像将停留在目标位置上。

当为false的时候,View会在动画结束后,从末位置消失,重新在初位置出现。

不论是true还是false,View的点击事件的坐标还是在原先的初始位置上,并不会随着View的移动而移动。

Android3.0以上使用属性动画可以解决这样的问题。

三、改变布局参数

通过修改ViewmarginLeft等等布局参数,以此来达到View滑动的效果。

弹性滑动

一、Scroller

Scroller#smoothScrollTo(int destX, int destY)目标位置与初始位置的偏移值delta计算后调用startScroller()方法存储相关参数,并开始使用invalidate()方法重绘View。重绘View时的draw()方法会调用computeScroll()方法,其中又会调用invalidate方法重绘View,不断的循环。由于scrollTo(int destX, int destY)直接让View出现在目标位置,所以我们在computeScroll()方法中采取微分法的做法,即将一段长距离拆分成许多微小的距离。不断通过scrollTo()的直接滑动以及computeScrollOffset()的重新计算下一个短距离的相关参数以及postInvalidate()的重绘View,最终达到弹性滑动的效果。

computeScrollOffset()方法通过计算目前已经位移的百分比来计算下一次的位移目标以及是否已经完成的滑动,不通过计时器等工具。

flowchart TD
A[startScroll]
B[invalidate]
C[computeScroll]
D[computeScrollOffset : boolean]
E[invalidate]

A --> B -->|draw| C --> D -->|not finish| E -->|draw| C
D -->|finish| F[finish]

二、通过动画

通过ObjectAnimator类我们可以直接的通过动画完成View的弹性滑动。

我们也可以通过onAnimatorUpdate方法还自定义自己想要的动画效果。

三、使用延时策略

可以通过Thread#sleep或者Handler#postDelayed方法来达到间隔一定的时间就改变一次微小位置变化的操作。

在此次的scrollTo方法执行结束后,通过sleep|postDelayed方法暂停一定的时间,然后重复执行scrollTo的方法,以此来达到弹性滑动的效果。

注意:无法在精准的时间内移动固定的位移,因为系统的消息调度(sleep|postDelayed)也是需要时间的。

View的事件分发机制

一、点击事件的传递规则

点击事件分发过程,即将一系列的MotionEvent事件序列(由ACTION_DOWN开始到ACTION_UP结束的一连串操作)交给一个View执行的过程。

点击事件的分发主要由三个重要方法构成:dispatchTouchEvent, onInterceptTouchEvent,onTouchEvent

三个方法的执行顺序如下:

1
2
3
4
5
6
7
8
9
public boolean dispatchTouchEvent(MotionEvent ev) { // 对当前的View进行事件的分发
boolean consume = false; // 判断是否可以消耗这个事件序列
if (onInterceptTouchEvent(ev)) { // 如果准备拦截此次事件序列
consume = onTouchEvent(ev); // 事件由该View执行,并返回结果
} else {
consume = child.dispatchTouchEvent(ev); // 若不准备拦截,则交给子View进行判断
}
return consume; // 向父级返回结果
}

其中执行事件序列的不同方法中同样由优先顺序:onTouchListener > onTouchEvent > onClickListener

当点击事件发生后,它的传递过程会遵循以下的顺序:Activity -> Window -> DecorView -> View -> ...

同时,如果子级发现无法执行这个事件的时候,那么它的父容器的onTouchEvent就会重新调用,直到Activity

其中,ViewGroup默认不拦截任何事件,View没有onInterceptTouchEvent方法。

二、事件分发解析

Activity对点击事件的分发

1
2
3
4
5
boolean dispatchTouchEvent(MotionEvent ev) {
if (getWindow().superDispatchTouchEvent(ev)) // 如果Window可以处理点击事件
return true;
return onTouchEvent(ev); // 返回Activity自己处理点击事件的结果(true|false)
}

Window对点击事件的分发

1
2
3
boolean PhoneWindow#superDispatchTouchEvent(MotionEvent ev) {
return mDecor.superDispatchTouchEvent(ev); // 返回DecorView的处理结果
}

顶级View对点击事件的分发

由此开始,将执行(一)所讲述的事件传递规则。其主要的部分是ViewGroup的事件传递。顶级View一般来说都是ViewGroup

判断当前ViewGroup是否拦截点击事件

1
2
3
4
5
6
7
8
9
10
11
12
final boolean intercepted;
if (actionMasked == MotionEvent.ACTION.DOWN || mFirstTouchTarget != NULL) { // 意思见下方文字
final boolean disallowIntercept = (mGroupFlags & FLAG_DISALLOW_INTERCEPT) != 0; // 判断是否允许屏蔽自身的onInterceptTouchEvent方法
if (!disallowIntercept) { // 如果允许拦截
intercepted = onInterceptTouchEvent(ev); // 询问能否拦截并赋值
ev.setAction(action); // 防止事件被修改,存储事件的动作
} else {
intercepted = false; // 由于不允许拦截,则直接赋值
}
} else {
intercepted = true; // 由于直接拦截,则不用询问
}

代码中的几个变量的作用:

  1. 当事件由ViewGroup的子元素处理成功时,mFirstTouchTarget就会被赋值并指向子元素,此时的mFirstTouchTarget != NULL

  2. FLAG_DISALLOW_INTERCEPT一旦被设置后,那么当前的ViewGroup就无法拦截ACTION_MOVE以及ACTION_UP

    由于事件为ACTION_DOWN的时候,ViewGroup会重置FLAG_DISALLOW_INTERCEPT,所以每次事件为ACTION_DOWN的时候,都会执行一次onInterceptTouchEvent方法。

第3行的判断语句的意思为:

  1. 如果当前的事件为ACTION_DOWN时,作为一个点击事件的开始,需要向子元素传递,返回true。允许向子元素传递。
  2. 如果当前的事件为ACTION_MOVE或者ACTION_UP的时候,如果ACTION_DOWN已经被子元素处理了(mFirstTouchTarget != NULL),那么由于一系列的点击事件都要有同一个View处理,则不能在此拦截,故返回true。反之,如果mFirstTouchTarget == NULL,则代表ACTION_DOWN是由ViewGroup自身处理的,则不能向下传递,返回false

ViewGroupACTION_DOWN到来时的重置操作

1
2
3
4
5
6
// Handle an initial down.
if (actionMasked == MotionEvent.ACTION_DOWN) {
// Throw away all previous state when starting a new touch gesture.
cancelAndClearTouchTargets(ev);
resetTouchState();
}

resetTouchState方法中会对FLAG_DISALLOW_INTERCEPT进行重置。

因此子ViewrequestDisallowInterceptTouchEvent方法并不能影响ViewGroupACTION_DOWN事件的处理。

ViewGroup不拦截事件时,对点击事件的分发

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
final View[] = mChildren;
for (int i = childrenCount - 1; i >= 0; i--) {
// 获取每一个子View的位置以及其他信息
if (子元素正在播放动画 || 点击事件的坐标落在当前子元素的区域外) {
continue;
}
newTouchTarget = getTouchTarget(child);
if (newTouchTarget != NULL) { // 如果不是NULL的话(该child之前初始化过),就直接向其中添加元素就行了
newTouchTarget.pointerIdBits |= idBitsToAssign;
break;
}
//如果 newTouchTarget == NULL, 那么就初始化 + 添加第一个可用子元素
resetCancelNextUpFlag(child);
if (dispatchTransformedTouchEvent(ev, false, child, idBitsToAssign)) {
...
newTouchTarget = addTouchTarget(child, idBitsToAssign);
alreadyDispatchedToNewTouchTarget = true;
break;
}
}

不断的遍历当前ViewGroup的所有子元素,如果子元素不在播放动画以及点击事件的坐标落在当前子元素的区域内,那么这个子元素就是一个可以传递的。

TouchTarget源码可知:

TouchTarget保存了响应触摸事件的子view和该子view上的触摸点ID集合,表示一个触摸事件派发目标。通过next成员可以看出,它支持作为一个链表节点储存。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
private static final class TouchTarget {
// ···

// The touched child view.
// 被触摸的子元素
@UnsupportedAppUsage
public View child;

// The combined bit mask of pointer ids for all pointers captured by the target.
// 指针 id 的位的掩码组合,用于目标捕获的所有指针
public int pointerIdBits;

// The next target in the target list.
// 目标列表中的下一个目标
public TouchTarget next;

// ···
}
————————————————
版权声明:本文为CSDN博主「分则能成」的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。
原文链接:https://blog.csdn.net/dehang0/article/details/104317611

如果是第一个可传递元素,那么就会进入初始化部分,其中的dispatchTransformedTouchEvent方法实际上就是调用的子元素的dispatchTouchEvent方法。在该方法中有如下的一段内容:

1
2
3
4
5
if (child == NULL) {
handled = super.dispatchTouchTarget(event);
} else {
handled = child.dispatchTouchTarget(event);
}

由于前面的代码中dispatchTransformedTouchEvent方法的第三个参数为child,所以会执行子元素的dispatchTouchEvent方法,点击事件交由子元素处理,从而完成了一轮事件的分发。

当子元素的dispatchTouchEvent方法返回true时,dispatchTransformedTouchEvent方法同样也会返回true,接着便执行if语句的代码块部分了。如果dispatchTouchEvent方法返回false的话,ViewGroup就会把事件向后遍历,寻找新的可传递的子元素。在if语句的代码块中,mFirstTouchTarget会被赋值同时跳出for循环

其中mFirstTouchTarget的赋值由addTouchTarget方法完成,代码如下:

1
2
3
4
5
6
public TouchTarget addTouchTarget(View child, int pointerIdBits) {
TouchTarget target = TouchTarget.obtain(child, pointerIdBits);
target.next = mFirstTouchTarget;
mFirstTouchTarget = target;
return target;
}

可以很容易的看出,mFirstTouchTarget其实是一个单链表结构mFirstTouchTarget是否被赋值,将直接影响ViewGroup对事件的拦截策略。

ViewGroup中没有合适的子元素

有两种情况,ViewGroup中会没有合适的子元素可以传递:

  1. ViewGroup没有子元素
  2. 子元素处理了点击事件,但是dispatchTouchEvent方法返回了false,这一般是因为子元素在onTouchEvent中返回了false

在这两种情况中,ViewGroup都会自己处理点击事件。代码如下:

1
2
3
4
if (mFirstTouchTarget == NULL) {
// 没有可分发子元素,就当其是一个普通的View
handled = dispatchTransformedTouchEvent(ev, canceled, null, TouchTarget.ALL_POINTER_IDS);
}

可以看到,代码中的dispatchTransformedTouchEvent方法的第三个参数将其设置为null,此时它就会调用super.dispatchTouchTarget方法了。

View对点击事件的处理过程

这里的View不包含ViewGroup,因为没有子元素,所以不用向下分发事件,只能自己处理事件。

先看它的dispatchTouchEvent方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public boolean dispatchTouchEvent(MotionEvent event) {
boolean result = false;
...
if (onFilterTouchEventForSecurity(event)) {
ListenerInfo li = mListenerInfo;
if (li != null && li.mOnTouchListener != null && li.mOnTouchListener.onTouch(this, event) && ...) {
result = true;
}

if (!result && onTouchEvent(event)) {
result = true;
}
}
...
return result;
}

由上面的代码可以看出,View对点击事件的处理流程中,首先会判断有没有设置onTouchListener,如果有且其中的onTouch方法返回true,则onTouchEvent方法就不会被调用。

从其他的代码中也可以得出的一些结论:

  1. 如果ViewCLICKABLE以及LONG_CLICKABLE中有一个为true,不论View是否为DISABLE,都会消耗事件(也就是即使View看起来没有任何的反应,但是也消耗了点击事件)。
  2. ViewLONG_CLICKABLE默认为false,而CLICKABLE是否为false与其View有关,如button的默认为truetextview的默认为false
  3. ACTION_UP事件发生时,会触发performClick方法,如果View设置了OnClickListener,那么performClick方法就会调用它的onClick方法。(即onClick的触发前提时View是可点击的,且收到了DOWNUP的点击事件。)
  4. setOnClickListener方法会自动将CLICKABLE设置为truesetOnLongClickListener方法会自动将LONG_CLICKABLE设置为true

View的滑动冲突

总共会出现三种滑动冲突的情况:

  1. 内部View外部View的滑动方向相反。
  2. 内部View外部View的滑动方向相同。
  3. 前两种情况的嵌套。

三种情况的处理思路:

  1. 通过手势滑动的角度判断滑动的方向。
  2. 通过当前处于的不同的页面状态来判断应该滑动的View
  3. 通过前两种的综合使用。

滑动冲突的解决方式

外部拦截法

通过重写父容器的onInterceptTouchEvent方法,所有的事件都先经过父容器的筛选,对其中父容器需要的事件进行拦截。

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
public boolean onInterceptTouchEvent(MotionEvent event) {
boolean intercepted = false;
int x = (int) event.getX();
int y = (int) event.getY();
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN : {
intercepted = false;
break;
}
case MotionEvent.ACTION_MOVE : {
if (父容器需要当前的点击事件)
intercepted = true;
else
intercepted = false;
break;
}
case MotionEvent.ACTION_UP : {
intercepted = false;
break;
}
default :
break;
}
mLastXIntercept = x;
mLastYIntercept = y;
return intercepted;
}

内部拦截法

父元素拦截除ACTION_DOWN以外的其他事件,当事件到达子元素后,由子元素判断是否需要这些事件,不需要的事件将重新交由父容器来处理。这种方法和Android的事件分发机制不一致,需要配合requestDisallowInterceptTouchEvent方法才能正常的工作。

相关阅读链接:Android TouchEvent之requestDisallowInterceptTouchEvent - 简书 (jianshu.com)

子元素的dispatchTouchEvent方法

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
public boolean dispatchTouchEvent(MotionEvent event) {
int x = (int) event.getX();
int y = (int) event.getY();

switch (event.getAction()) {
case MotionEvent.ACTION_DOWN : {
parent.requestDisallowInterceptTouchEvent(true); // 此子View的所有父ViewGroup会跳过onInterceptTouchEvent回调
break;
}
case MotionEvent.ACTION_MOVE : {
int deltaX = x - mLastX;
int deltaY = y - mLastY;
if (父容器需要当前的点击事件) {
parent.requestDisallowInterceptTouchEvent(false);
}
break;
}
case MotionEvent.ACTION_UP : {
break;
}
default :
break;
}
mLastX = x;
mLastY = y;
return super.dispatchTouchEvent(event);
}

父容器的onInterceptTouchEvent方法:

1
2
3
4
5
6
7
8
public boolean onInterceptTouchEvent(MotionEvent event) {
int action = event.getAction();
if (action == MotionEvent.ACTION_DOWN) {
return false;
} else {
return true;
}
}

View 的事件体系
https://luoyuy.top/posts/ca3478f63a25/
作者
LuoYu-Ying
发布于
2022年4月27日
许可协议