事件基础
事件基础
事件流一致性保证
事件流一致性保证 (Consistency Guarantees):按下开始,中间可能伴随着移动,松开或者取消结束。ACTION_DOWN -> ACTION_MOVE(*) -> ACTION_UP/ACTION_CANCEL
。
简单来说,一条事件流就像一辆火车,车头和车尾是必须要有的,中间的车厢可有可无,有的话可以是任意节。DOWN 事件相当于火车头,UP 或 CANCEL 相当于火车尾,MOVE 事件相当于火车厢。我们所熟悉的 onClick 按键监听就是由完整事件流共同决定是否触发响应。
事件流火车模型如下图所示:
如果控件及其子孙控件都没有消费 DOWN 事件,则该控件不会收到接下来的事件流。
事件分类 InputEvent
Android 系统中将输入事件定义为 InputEvent
,而 InputEvent 根据输入事件的类型又分为了 KeyEvent 和 MotionEvent,前者对应键盘事件,后者则对应屏幕触摸事件。
1
2
3
4
5
6
// 输入事件的基类
public abstract class InputEvent implements Parcelable { }
public class KeyEvent extends InputEvent implements Parcelable { }
public final class MotionEvent extends InputEvent implements Parcelable { }
触摸事件 MotionEvent
MotionEvent 就是移动事件,鼠标、笔、手指、轨迹球等相关输入设备的事件都属于 MotionEvent
按键事件 KeyEvent
对于按键事件(KeyEvent),无非就是按下、弹起等。按键事件
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
// Activity
@Override
public boolean onKeyDown(int keyCode, KeyEvent event) {
switch(keyCode) {
case KeyEvent.KEYCODE_HOME:
system.out.print("Home down");
break;
case KeyEvent.KEYCODE_BACK:
system.out.print("Back down");
break;
case KeyEvent.KEYCODE_MENU:
system.out.print("Menu down");
break;
}
return super.onKeyDown(keyCode, event);
}
@Override
public boolean onKeyUp(int keyCode, KeyEvent event) {
switch(keyCode) {
case KeyEvent.KEYCODE_HOME:
system.out.print("Home up");
break;
case KeyEvent.KEYCODE_BACK:
system.out.print("Back up");
break;
case KeyEvent.KEYCODE_MENU:
system.out.print("Menu up");
break;
}
return super.onKeyUp(keyCode, event);
}
触摸事件基础
单指事件分类
事件 | 简介 |
---|---|
ACTION_DOWN | 手指 初次接触到屏幕 时触发 |
ACTION_MOVE | 手指 在屏幕上滑动时触发,会多次触发 |
ACTION_UP | 手指 离开屏幕 时触发 |
ACTION_CANCEL | 事件 被上层拦截 时触发 |
View 和 ViewGroup 事件分发和处理
- View 只能处理事件不能分发事件;ViewGroup 可以分发和处理事件
- 事件由 ViewGroup 分发事件到 View
- ViewGroup 先分发事件,如果没有 View 处理,自己处理
ViewGroup 中的事件传递和事件消费,隧道和冒泡方式?
- 事件传递(隧道方式,从上层到下层)
由 Activity→ViewGroup→View 进行事件的传递,在其中的某一层,拦截了事件,那么由该层进行事件的处理;没有拦截,看有没有 View 进行事件的消费,如果没有,那么会继续往上抛 - 事件消费(冒泡,从下层到上层)
View→ViewGroup→Activity 进行事件消费的抛,下层没有消费该事件,抛给上层的 ViewGroup,直到 Activity
mGroupFlags 的作用及思考
在 ViewGroup 中有一个 int 值,mGroupFlags
:
1
2
// ViewGroup
protected int mGroupFlags;
FLAG_DISALLOW_INTERCEPT
1
2
3
4
5
6
7
8
9
10
11
protected static final int FLAG_DISALLOW_INTERCEPT = 0x80000; // 有该flag表示不允许拦截子View的事件,需要传递事件给子View
FLAG_DISALLOW_INTERCEPT对应的二进制为:00000000 00001000 00000000 00000000
// 判断是否有FLAG_DISALLOW_INTERCEPT标记位
final boolean disallowIntercept = (mGroupFlags & FLAG_DISALLOW_INTERCEPT) != 0;
// mGroupFlags与FLAG_DISALLOW_INTERCEPT操作,可以确定mGroupFlags在12位是否是1,如果该位是1,那么不为0,如果该位为0,那么为0;true表示父ViewGroup不拦截事件子View自己处理事件,false表示拦截事件
// 标记位清零
mGroupFlags &= ~FLAG_DISALLOW_INTERCEPT;
~FLAG_DISALLOW_INTERCEPT对应的二进制为:11111111 11110111 11111111 11111111
mGroupFlags & (~FLAG_DISALLOW_INTERCEPT) 即起到了一个清除12号位的作用,将其置为0。
Activity 的事件分发流程
当一个事件发生时,事件最先传到 Activity 的 dispatchTouchEvent()
进行事件分发。
1
2
3
4
5
6
7
8
9
10
// Android 29
public boolean dispatchTouchEvent(MotionEvent ev) {
if (ev.getAction() == MotionEvent.ACTION_DOWN) {
onUserInteraction();
}
if (getWindow().superDispatchTouchEvent(ev)) {
return true;
}
return onTouchEvent(ev);
}
事件消费 getWindow().superDispatchTouchEvent(ev) 返回 true
调用了 Window(其实是 PhoneWindow)的 superDispatchTouchEvent() 来处理。
1
2
3
4
5
6
7
// PhoneWindow
// This is the top-level view of the window, containing the window decor.
private DecorView mDecor;
@Override
public boolean superDispatchTouchEvent(MotionEvent event) {
return mDecor.superDispatchTouchEvent(event);
}
发现调用的是 DecorView 的 superDispatchTouchEvent() 方法。
1
2
3
4
5
6
7
// DecorView
// DecorView#superDispatchTouchEvent
public class DecorView extends FrameLayout{}
public boolean superDispatchTouchEvent(MotionEvent event) {
return super.dispatchTouchEvent(event);
}
而 DecorView 调用的是 super.dispatchTouchEvent(),而 DecorView 的父类为 FrameLayout,最终调用的是 ViewGroup 的 dispatchTouchEvent() 方法。
DecorView 是视图的顶层 View,继承自 FrameLayout,是所有界面的父类。
事件未消费 getWindow().superDispatchTouchEvent(ev) 返回 false
上面是 Window 消费了事件返回 true。
如果 getWindow().superDispatchTouchEvent(ev) 返回的是 false,那么进入的调用了 Activity 自己的 onTouchEvent(ev) 方法:
1
2
3
4
5
6
7
8
// Activity
public boolean onTouchEvent(MotionEvent event) {
if (mWindow.shouldCloseOnTouch(this, event)) {
finish();
return true;
}
return false;
}
从注释里可以看到,该方法调用是在没有任何 View 处理了该事件的情况。一般是触摸发生在 Window 范围外,没有任何 View 响应该事件。返回 true 表示消费事件,false 表示不消费,默认为 false。
事件基础之 MotionEvent
MotionEvent 介绍
Android 将所有的输入事件都放在了 MotionEvent 中。主要包括单点触控、多点触控、鼠标事件以及 getAction() 和 getActionMasked() 的区别。
事件动作分类:
1
2
3
4
5
6
7
8
MotionEvent.ACTION_DOWN:当屏幕检测到第一个触点按下之后就会触发到这个事件。
MotionEvent.ACTION_MOVE:当触点在屏幕上移动时触发,触点在屏幕上停留也是会触发的,主要是由于它的灵敏度很高,而我们的手指又不可能完全静止(即使我们感觉不到移动,但其实我们的手指也在不停地抖动)。
MotionEvent.ACTION_POINTER_DOWN:当屏幕上已经有触点处于按下的状态的时候,再有新的触点被按下时触发。
MotionEvent.ACTION_POINTER_UP:当屏幕上有多个点被按住,松开其中一个点时触发(即非最后一个点被放开时)触发。
MotionEvent.ACTION_UP:当触点松开时被触发。
MotionEvent.ACTION_OUTSIDE: 表示用户触碰超出了正常的UI边界.
MotionEvent.ACTION_SCROLL:android3.1引入,非触摸滚动,主要是由鼠标、滚轮、轨迹球触发。
MotionEvent.ACTION_CANCEL:不是由用户直接触发,由系统在需要的时候触发,例如当父view通过使函数
单点触控
和以下的几个方法:
单点触控一次简单的交互流程是这样的:手指落下(ACTION_DOWN) -> 多次移动(ACTION_MOVE) -> 离开(ACTION_UP)
如果仅仅是单击 (手指按下再抬起),不会触发 ACTION_MOVE。其中有两个比较特殊的事件: ACTION_CANCEL 和 ACTION_OUTSIDE,因为它们是由程序触发而产生的,而且触发条件也非常特殊,通常情况下即便不处理这两个事件也没有什么问题
ACTION_CANCEL
ACTION_CANCEL 的触发条件是事件被上层拦截。当事件被上层 View 拦截的时候,ChildView 是收不到任何事件的,ChildView 收不到任何事件,自然也不会收到 ACTION_CANCEL 了,所以说这个 ACTION_CANCEL 的正确触发条件并不是这样,那么是什么呢?
事实上,只有上层 View 回收事件处理权的时候,ChildView 才会收到一个 ACTION_CANCEL 事件。
举个例子:
例如:上层 View 是一个 RecyclerView,它收到了一个 ACTION_DOWN 事件,由于这个可能是点击事件,所以它先传递给对应 ItemView,询问 ItemView 是否需要这个事件,然而接下来又传递过来了一个 ACTION_MOVE 事件,且移动的方向和 RecyclerView 的可滑动方向一致,所以 RecyclerView 判断这个事件是滚动事件,于是要收回事件处理权,这时候对应的 ItemView 会收到一个 ACTION_CANCEL ,并且不会再收到后续事件。
通俗一点?
RecyclerView:儿砸,这里有一个 ACTION_DOWN 你看你要不要。
ItemView :好嘞,我看看。
RecyclerView:噫?居然是移动事件 ACTION_MOVE,我要滚起来了,儿砸,我可能要把你送去你姑父家 (缓存区) 了,在这之前给你一个 ACTION_CANCEL,你要收好啊。
ItemView :……
这是实际开发中最有可能见到 ACTION_CANCEL 的场景了。
ACTION_OUTSIDE
ACTION_OUTSIDE 的触发条件更加奇葩,从字面上看,outside 意思不就是超出区域么?然而不论你如何滑动超出控件区域都不会触发 ACTION_OUTSIDE 这个事件
正常情况下,如果初始点击位置在该视图区域之外,该视图根本不可能会收到事件,然而,万事万物都不是绝对的,肯定还有一些特殊情况,你可曾还记得点击 Dialog 区域外关闭吗?Dialog 就是一个特殊的视图 (没有占满屏幕大小的窗口),能够接收到视图区域外的事件 (虽然在通常情况下你根本用不到这个事件),除了 Dialog 之外,你最可能看到这个事件的场景是悬浮窗,当然啦,想要接收到视图之外的事件需要一些特殊的设置。
设置视图的 WindowManager 布局参数的 flags 为 FLAG_WATCH_OUTSIDE_TOUCH,这样点击事件发生在这个视图之外时,该视图就可以接收到一个 ACTION_OUTSIDE 事件。
参见 StackOverflow:How to dismiss the dialog with click on outside of the dialog?
多点触控
Android 在 2.0 版本的时候开始支持多点触控。当手指第一次按下时产生一个唯一的号码,手指抬起或者事件被拦截就回收编号
第一次按下的手指特殊处理作为主指针,之后按下的手指作为辅助指针,然后随之衍生出来了以下事件 (注意增加的事件和事件简介的变化):
、
和以下方法:
getAction() 与 getActionMasked() 区别
当多个手指在屏幕上按下的时候,会产生大量的事件,如何在获取事件类型的同时区分这些事件就是一个大问题了。
一般来说我们可以通过为事件添加一个 int 类型的 index 属性来区分,为了添加一个通常数值不会超过 10 的 index 属性就浪费一个 int 大小的空间简直是不能忍受的,于是工程师们将这个 index属性
和 事件类型
直接合并了。
int 类型共 32 位 (0x00000000),他们用最低 8 位 (0x000000ff) 表示事件类型,再往前的 8 位 (0x0000ff00) 表示事件编号,以手指按下为例讲解数值是如何合成的:
ACTION_DOWN 的默认数值为 (0x00000000)
ACTION_POINTER_DOWN 的默认数值为 (0x00000005)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// MotionEvent
/**
* Bit mask of the parts of the action code that are the action itself.
*/
public static final int ACTION_MASK = 0xff; // 低8位(0-7bit)为action,android29 action有12种,00000000 00000000 00000000 11111111
public static final int ACTION_POINTER_INDEX_MASK = 0xff00; // 8-15bit为pointer index,00000000 00000000 11111111 00000000
/**
* Bit shift for the action bits holding the pointer index as
* defined by {@link #ACTION_POINTER_INDEX_MASK}.
*
* @see #getActionIndex
*/
public static final int ACTION_POINTER_INDEX_SHIFT = 8;
getAction() 和 getActionIndex() 源码
1
2
3
4
5
6
7
8
// MotionEvent
public static final int ACTION_MASK = 0xff // 00000000 00000000 11111111 11111111
public final int getAction() {
return nativeGetAction(mNativePtr);
}
public final int getActionMasked() {
return nativeGetAction(mNativePtr) & ACTION_MASK;
}
随着按下手指数量的增加,这个数值也是一直变化的,进而导致我们使用 getAction()
获取到的数值无法与标准的事件类型进行对比,为了解决这个问题,他们创建了一个 getActionMasked()
方法,这个方法可以清除 index 数值,让其变成一个标准的事件类型
- 多点触控时必须使用 getActionMasked() 来获取事件类型
- 单点触控时由于事件数值不变,使用 getAction() 和 getActionMasked() 两个方法都可以
- 使用 getActionIndex() 可以获取到这个 index 数值。不过请注意,getActionIndex() 只在 down 和 up 时有效,move 时是无效的。
- 全部使用 getActionIndex() 获取就行了
MotionEvent 源码解析
MotionEvent 是个单链表结构,有个 MotionEvent 回收池,最多保存 10 个
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
// MotionEvent
public final class MotionEvent extends InputEvent {
private static final int MAX_RECYCLED = 10;
private static final Object gRecyclerLock = new Object();
private static int gRecyclerUsed;
private static MotionEvent gRecyclerTop;
private MotionEvent mNext;
// Pointer to the native MotionEvent object that contains the actual data.
@UnsupportedAppUsage(maxTargetSdk = Build.VERSION_CODES.P)
private long mNativePtr;
static private MotionEvent obtain() {
final MotionEvent ev;
synchronized (gRecyclerLock) {
ev = gRecyclerTop;
if (ev == null) {
return new MotionEvent();
}
gRecyclerTop = ev.mNext;
gRecyclerUsed -= 1;
}
ev.mNext = null;
ev.prepareForReuse();
return ev;
}
public final void recycle() {
super.recycle();
synchronized (gRecyclerLock) {
if (gRecyclerUsed < MAX_RECYCLED) {
gRecyclerUsed++;
mNext = gRecyclerTop;
gRecyclerTop = this;
}
}
}
}
多指触控
相关 api
getActionIndex 获取当前按下手指的 index
1
2
3
4
public final int getActionIndex() {
return (nativeGetAction(mNativePtr) & ACTION_POINTER_INDEX_MASK)
>> ACTION_POINTER_INDEX_SHIFT;
}
getPointerId 通过 index 拿 pointer id
1
2
3
public final int getPointerId(int pointerIndex) {
return nativeGetPointerId(mNativePtr, pointerIndex);
}
findPointerIndex 通过 pointer id 拿 index
1
2
3
public final int findPointerIndex(int pointerId) {
return nativeFindPointerIndex(mNativePtr, pointerId);
}
getX(int pointerIndex)/getY(int pointerIndex) 根据当前 pointerIndex 拿 x/y
1
2
3
public final float getX(int pointerIndex) {
return nativeGetAxisValue(mNativePtr, AXIS_X, pointerIndex, HISTORY_CURRENT);
}
布局中同级 View 的事件传递优先级
处于同一个 ViewGroup 内的两个 View 重合时,ViewGroup 是如何决定传递到哪一个 View 的?
一般情况下:按照 xml 中的排列顺序,最后的优先触发
源码分析,ViewGroup#dispatchTouchEvent:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@Override
public boolean dispatchTouchEvent(MotionEvent ev) {
// ...
if (actionMasked == MotionEvent.ACTION_DOWN) {
// Find a child that can receive the event.
// Scan children from front to back.
final ArrayList<View> preorderedList = buildTouchDispatchChildList();
final boolean customOrder = preorderedList == null && isChildrenDrawingOrderEnabled();
final View[] children = mChildren;
for (int i = childrenCount - 1; i >= 0; i--) {
final int childIndex = getAndVerifyPreorderedIndex(childrenCount, i, customOrder);
final View child = getAndVerifyPreorderedView(preorderedList, children, childIndex);
// ...
if (dispatchTransformedTouchEvent(ev, false, child, idBitsToAssign)) {
newTouchTarget = addTouchTarget(child, idBitsToAssign);
}
}
}
}
先看看 buildTouchDispatchChildList()
方法:
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
public ArrayList<View> buildTouchDispatchChildList() {
return buildOrderedChildList();
}
ArrayList<View> buildOrderedChildList() {
final int childrenCount = mChildrenCount;
// 如果小于等于1就不用判断了;hasChildWithZ()熟悉布局文件的开发者应该能猜到这是查看是否有child设置了Z轴相关属性,取反意味着如果没有child设置Z轴就返回null。
if (childrenCount <= 1 || !hasChildWithZ()) return null;
if (mPreSortedChildren == null) {
mPreSortedChildren = new ArrayList<>(childrenCount);
} else {
// callers should clear, so clear shouldn't be necessary, but for safety...
mPreSortedChildren.clear();
mPreSortedChildren.ensureCapacity(childrenCount);
}
final boolean customOrder = isChildrenDrawingOrderEnabled();
for (int i = 0; i < childrenCount; i++) {
// add next child (in child order) to end of list
final int childIndex = getAndVerifyPreorderedIndex(childrenCount, i, customOrder);
final View nextChild = mChildren[childIndex];
final float currentZ = nextChild.getZ();
// insert ahead of any Views with greater Z
int insertIndex = i;
while (insertIndex > 0 && mPreSortedChildren.get(insertIndex - 1).getZ() > currentZ) {
insertIndex--;
}
mPreSortedChildren.add(insertIndex, nextChild); // Z轴越大的优先级越高
}
return mPreSortedChildren;
}
返回按照 Z 值 (Elevation+TranslationZ) 从大到小顺序排列的 View 的集合
接着看 getAndVerifyPreorderedIndex()
:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
private int getAndVerifyPreorderedIndex(int childrenCount, int i, boolean customOrder) {
final int childIndex;
if (customOrder) {
final int childIndex1 = getChildDrawingOrder(childrenCount, i);
if (childIndex1 >= childrenCount) {
throw new IndexOutOfBoundsException("getChildDrawingOrder() "
+ "returned invalid index " + childIndex1
+ " (child count is " + childrenCount + ")");
}
childIndex = childIndex1;
} else {
childIndex = i;
}
return childIndex;
}
- 变量 customOrder 顾名思义就是自定义顺序,如果为 false 就是 childIndex 取默认顺序,而默认顺序一般来讲就是 xml 中子控件的定义顺序了
- 主要的影响因素就是 Z 轴大小和 xml 中的定义顺序
接着看 getAndVerifyPreorderedView()
,这个方法决定了最终由哪个子控件来接收点击事件。测:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
private static View getAndVerifyPreorderedView(ArrayList<View> preorderedList, View[] children,
int childIndex) {
final View child;
if (preorderedList != null) { // 如果已经排好序了,直接从这里面取出来
child = preorderedList.get(childIndex);
if (child == null) {
throw new RuntimeException("Invalid preorderedList contained null child at index "
+ childIndex);
}
} else { // 默认按照addView倒序遍历(即xml中后定义的先遍历获取)
child = children[childIndex];
}
return child;
}
- preorderedList 不为 null 时优先看 preorderedList,否则直接看 childIndex,即设置了 Z 轴就 Z 轴大的优先,否则就是 xml 定义靠后的优先。
如果有已经按 Z 值排好序的 View 集合 preorderedList,就从其中取出来;没有的话,按照 XML 中后定义先取出来。
Button 默认 z 轴导致优先处理事件
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
<FrameLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<Button
android:id="@+id/button0"
android:layout_width="match_parent"
android:layout_height="200dp"
android:tag="我是Button(默认elevation)"
android:text="我是Button(默认elevation)" />
<FrameLayout
android:id="@+id/fl_event_test0"
android:layout_width="match_parent"
android:layout_height="200dp"
android:background="@color/green_100"
android:tag="FrameLayout 无elevation">
<TextView
android:id="@+id/tv_event_test0"
android:layout_width="200dp"
android:layout_height="match_parent"
android:layout_gravity="center_horizontal"
android:background="@color/blue_200"
android:tag="TextView 无elevation" />
</FrameLayout>
</FrameLayout>
此时点击 TextView 区域,谁响应事件?
Button
分析:
从 Android SDK 21(即 5.0)开始,Button 控件按下自带阴影效果,阴影效果相当于是在 Z 轴的一个分量,所以导致 Button 总是在最顶层显示,同层级 View 的事件分发都是先分发给 Button。
1
2
3
When the button is pressed, a z-translation (of 4dp) is applied, raising the button from 2dp to 6dp.
When the button isn’t pressed, the elevation is 2dp
When the button is disabled, the elevation becomes 0dp
frameworks/base/core/res/res/anim/button_state_list_anim_material.xml
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
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_pressed="true" android:state_enabled="true">
<set>
<objectAnimator android:propertyName="translationZ"
android:duration="@integer/button_pressed_animation_duration"
android:valueTo="@dimen/button_pressed_z_material"
android:valueType="floatType"/>
<objectAnimator android:propertyName="elevation"
android:duration="0"
android:valueTo="@dimen/button_elevation_material"
android:valueType="floatType"/>
</set>
</item>
<!-- base state -->
<item android:state_enabled="true">
<set>
<objectAnimator android:propertyName="translationZ"
android:duration="@integer/button_pressed_animation_duration"
android:valueTo="0"
android:startDelay="@integer/button_pressed_animation_delay"
android:valueType="floatType"/>
<objectAnimator android:propertyName="elevation"
android:duration="0"
android:valueTo="@dimen/button_elevation_material"
android:valueType="floatType" />
</set>
</item>
<item>
<set>
<objectAnimator android:propertyName="translationZ"
android:duration="0"
android:valueTo="0"
android:valueType="floatType"/>
<objectAnimator android:propertyName="elevation"
android:duration="0"
android:valueTo="0"
android:valueType="floatType"/>
</set>
</item>
</selector>
1
2
3
4
5
<!-- /Android/sdk/platforms/android-R/data/res/values/dimens_material.xml -->
<!-- Elevation when button is pressed -->
<dimen name="button_elevation_material">2dp</dimen>
<!-- Z translation to apply when button is pressed -->
<dimen name="button_pressed_z_material">4dp</dimen>
解决 1:给 Button 设置 android:stateListAnimator="@null"
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
<FrameLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<Button
android:id="@+id/button0"
android:layout_width="match_parent"
android:layout_height="200dp"
android:stateListAnimator="@null"
android:tag="我是Button(默认elevation)"
android:text="我是Button(默认elevation)" />
<FrameLayout
android:id="@+id/fl_event_test0"
android:layout_width="match_parent"
android:layout_height="200dp"
android:background="@color/green_100"
android:tag="FrameLayout 无elevation">
<TextView
android:id="@+id/tv_event_test0"
android:layout_width="200dp"
android:layout_height="match_parent"
android:layout_gravity="center_horizontal"
android:background="@color/blue_200"
android:tag="TextView 无elevation" />
</FrameLayout>
</FrameLayout>
解决 2:给 FrameLayout 设置 android:elevation
大于 2dp
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
<FrameLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<Button
android:id="@+id/button"
android:layout_width="match_parent"
android:layout_height="200dp"
android:tag="我是Button(默认elevation)"
android:text="我是Button(默认elevation)" />
<FrameLayout
android:id="@+id/fl_event_test"
android:layout_width="match_parent"
android:layout_height="200dp"
android:background="@color/green_100"
android:elevation="10dp"
android:tag="FrameLayout elevation=10dp">
<TextView
android:id="@+id/tv_event_test"
android:layout_width="200dp"
android:layout_height="match_parent"
android:layout_gravity="center_horizontal"
android:background="@color/blue_200"
android:elevation="9dp"
android:tag="TextView elevation=9dp" />
</FrameLayout>
</FrameLayout>z
小结
当父布局下有两个重合的子控件 A 和 B 时,点击事件的传递遵循:
- 如果子控件设置了 Z 轴 (elevation 或 translationZ),就 Z 轴大的优先。
- 如果没有设置 Z 轴或 Z 轴相同,则 xml 中定义靠后的优先。
- 当优先级最高的子控件为不可点击 (clickable 和 longClickable 属性都为 false) 时,事件会传递到优先级次高的控件上,否则会默认消耗掉事件。
Ref
- Android 布局中同级 View 的事件传递优先级
https://blog.csdn.net/jdsjlzx/article/details/107437671 - A different raised button behavior
https://rubensousa.github.io/2016/10/raiflatbutton https://github.com/rubensousa/RaiflatButton
事件分发之 TouchTarget
什么是 TouchTarget 及作用?
TouchTarget 的作用场景在事件派发流程中,用于记录派发目标,即消费了事件的子 view。
TouchTarget 源码分析
在 ViewGroup 中有一个成员变量 mFirstTouchTarget
,它会持有 TouchTarget,并且作为 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
// ViewGroup
private TouchTarget mFirstTouchTarget;
// TouchTarget
class TouchTarget {
private static final int MAX_RECYCLED = 32;
private static final Object sRecycleLock = new Object[0];
private static TouchTarget sRecycleBin;
private static int sRecycledCount;
public static final int ALL_POINTER_IDS = -1; // all ones
// The touched child view.
public View child; // 消费事件的子view
// The combined bit mask of pointer ids for all pointers captured by the target.
public int pointerIdBits; // child接收的触摸点的ID集合,最多32个
// The next target in the target list.
public TouchTarget next; // 指向链表下一个节点
}
触摸点 ID 存储 pointerIdBits
成员 pointerIdBits 用于存储多点触摸的这些触摸点的 ID。pointerIdBits 为 int 型,有 32bit 位,每一 bit 位可以表示一个触摸点 ID,最多可存储 32 个触摸点 ID。
- pointerIdBits 是如何做到在 bit 位上存储 ID 呢?
假设触摸点 ID 取值为 x(x 的范围可从 0~31),存储时先将 1 左移 x 位,然后 pointerIdBits 与之执行|=
操作,从而设置到 pointerIdBits 的对应 bit 位上。
pointerIdBits 的存在意义是记录 TouchTarget 接收的触摸点 ID,在这个 TouchTarget 上可能只落下一个触摸点,也可能同时落下多个。当所有触摸点都离开时,pointerIdBits 就已被清 0,那么 TouchTarget 自身也将被从 mFirstTouchTarget 中移除。
对象获取和回收
- 获取
TouchTarget 封装了一个对象缓存池sRecycleBin
,通过TouchTarget.obtain()
方法获取
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public static TouchTarget obtain(@NonNull View child, int pointerIdBits) {
if (child == null) {
throw new IllegalArgumentException("child must be non-null");
}
final TouchTarget target;
synchronized (sRecycleLock) {
if (sRecycleBin == null) {
target = new TouchTarget();
} else {
target = sRecycleBin; // 从缓存池sRecyclerBin取第一个
sRecycleBin = target.next; // sRecycleBin指向缓存池中下一个TouchTarget(由于上面target指向了sRecyclerBin第一个元素)
sRecycledCount--; // 数量减去1
target.next = null; // 从sRecycleBin新获取TouchTarget的next置为null
}
}
target.child = child; // 赋值child
target.pointerIdBits = pointerIdBits; // 赋值触摸点
return target;
}
- 回收
TouchTarget.recycle 方法回收。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public void recycle() {
if (child == null) {
throw new IllegalStateException("already recycled once");
}
synchronized (sRecycleLock) {
if (sRecycledCount < MAX_RECYCLED) { // 缓存数小于32
next = sRecycleBin; // 当前要回收的TouchTarget置于sRecyclerBin链表的头部,当前要回收的TouchTarget.next指向之前缓存的sRecyclerBin
sRecycleBin = this; // sRecycleBin链表头节点更改
sRecycledCount += 1; // 缓存数量加1
} else {
next = null; // 缓存数大于32了,不再缓存,清空next
}
child = null; // 清空child
}
}
什么时候获取 TouchTarget?TouchTarget.next 什么时候有值?
在 ViewGroup#dispatchTouchEvent 的 DOWN 事件到来,遍历子 View 时,
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
boolean dispatchTouchEvent() {
TouchTarget newTouchTarget = null;
if(!canceled && !intercepted) {
if (actionMasked == MotionEvent.ACTION_DOWN
|| (split && actionMasked == MotionEvent.ACTION_POINTER_DOWN)
|| actionMasked == MotionEvent.ACTION_HOVER_MOVE) {
final int actionIndex = ev.getActionIndex(); // always 0 for down
final int idBitsToAssign = split ? 1 << ev.getPointerId(actionIndex) : TouchTarget.ALL_POINTER_IDS;
// Clean up earlier touch targets for this pointer id in case they have become out of sync.
removePointersFromTouchTargets(idBitsToAssign);
for (int i = childrenCount - 1; i >= 0; i--) {
newTouchTarget = getTouchTarget(child); // 查找是否在链表中
if (newTouchTarget != null) { // (多点触控的DOWN事件)如果在的话(只有第2+次的ACTION_POINTER_DOWN才会走到这里,首次的ACTION_DOWN不会走到这里),直接break掉
// Child is already receiving touch within its bounds.
// Give it the new pointer in addition to the ones it is handling.
newTouchTarget.pointerIdBits |= idBitsToAssign;
break;
}
if (dispatchTransformedTouchEvent(ev, false, child, idBitsToAssign)) {
newTouchTarget = addTouchTarget(child, idBitsToAssign); // 找到能消费事件的child
}
}
}
}
}
查找指定的 child 是否在 TouchTarget 中
1
2
3
4
5
6
7
8
9
10
11
12
13
// ViewGroup
/**
* Gets the touch target for specified child view.
* Returns null if not found.
*/
private TouchTarget getTouchTarget(@NonNull View child) {
for (TouchTarget target = mFirstTouchTarget; target != null; target = target.next) {
if (target.child == child) {
return target;
}
}
return null;
}
在链表 mFirstTouchTarget 添加一个新的节点在头节点,只有 child 能消费事件才会调用添加到链表头节点
1
2
3
4
5
6
7
8
9
10
/**
* 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;
}
mFirstTouchTarget 说明
ViewGroup 不用单个 TouchTarget 保存消费了事件的 child,而是通过 mFirstTouchTarget 链表保存多个 TouchTarget,是因为存在多点触摸情况下,需要将事件拆分后派发给不同的 child。
- 当触摸点 1 落于 childA 时,产生事件 ACTION_DOWN,ViewGroup 会为 childA 生成一个 TouchTarget,后续滑动事件将派发给它。
- 当触摸点 2 落于 childA 时,产生 ACTION_POINTER_DOWN 事件,此时可以复用 TouchTarget,并给它添加触摸点 2 的 ID。
- 当触摸点 3 落于 childB 时,产生 ACTION_POINTER_DOWN 事件,ViewGroup 会再生成一个 TouchTarget,此时 ViewGroup 中有两个 TouchTarget,后续产生滑动事件,将根据触摸点信息对事件进行拆分,之后再将拆分事件派发给对应的 child。
事件杂项
1500ms 内 3 次点击事件检测
1
2
3
4
5
6
7
8
9
long[] myHits = new long[3];
// 1、拷贝
System.arraycopy(myHits, 1, myHits, 0, myHits.length - 1);
// 2、赋值系统时间给最后一个元素
myHits[myHits.length - 1] = SystemClock.currentThreadTimeMillis();
// 判断
if (myHits[0] >= (myHits[myHits.length - 1] - 1500)) {
// do something
}
TextView.setLinkMovementMethod 后拦截所有点击事件
问题
- 给一个 TextView 的文本的某些字符设置 ClickableSpan,点击 ClickableSpan 区域之外的文本时,TextView 将消费该事件,而不会将其传递给父 View
TextView 的某句话添加点击事件的时候,我们一般会使用 ClickableSpan 来进行富文本编辑
1
textView.setMovementMethod(LinkMovementMethod.getInstance());
方法才能使点击处理生效。但与此同时还会有一个问题:如果我们给父布局添加一个点击事件,需要在点击非链接的时候触发 (例如 RectclerView 自定义的 onItemClickListener 有一部分就是给 itemView 添加 onClick 事件),但是设置了 setMovementMethod 方法后整个 TextView 就无法触发父布局的点击事件了,无论点击的地方是否有链接。
- 和 onClick 冲突?
解决
- 糗百,不用 listview 的 itemclicklistener,设置在对应的 view 上
- 重写 LinkMovementMethod 方法,根据需要控制 super.ontouch() 的返回值。
- 在 setMovementMethod 之前保存一下 textView 之前的 xxxAble 属性,设置完之后对这些属性进行还原。
Ref
- TextView.SetLinkMovementMethod 后拦截所有点击事件的原因以及解决方法
https://blog.csdn.net/xypeng123/article/details/81436593 - 解决 ListView 里 TextView 设置 LinkMovementMethod 后导致其 ItemClick 失效的问题
https://gist.github.com/weiweimhy/fea776a06711678993d8