自定义单 View 的触摸反馈
onTouchEvent 的返回值
重写 onTouchEvent(),在⽅法内部定制触摸反馈算法。如下代码:
1 |
|
onTouchEvent 的返回值是一个所有权标志,表示这个事件序列所属,但是这个返回值只和 down 事件相关,只有在 down 的时候返回才表示自己的所有权,而在之后的返回没有意义。上面代码事实上是在 down 事件之后返回的 true ,为了简便直接返回。再比如如下代码:
1 |
|
上面代码和全部返回 true 是一样的,因为 down 之后的返回值没有意义。
所有事件序列都是以按下(ACTION_DOWN)事件为开始,以抬起(ACTION_UP)或者取消(ACTION_CANCEL)事件为结束,是否消费事件取决于 ACTION_DOWN 事件是否返回 true 。如果返回 true ,该事件不会再向下传递,同一个事件序列的后续事件(比如 ACTION_DOWN 后续的 ACTION_MOVE、ACTION_UP等)也会给当前 View 来执行,并且不会向下传递。
getActionMasked() 和 getAction()
早期 Android 版本并没有 getActionMasked() ,从多点触控支持开始出现,getActionMasked() 增加了多点触控的支持,如果使用“ACTION_POINTER_DOWN”“ACTION_POINTER_UP”,通过 getAction() 获取返回值是有问题的。因此一律使用 getActionMasked() 即可。
View.onTouchEvent()
进入点击逻辑之前的代码
View.onTouchEvent()的源码截取分析如下:
1 | final float x = event.getX(); |
这几行代码获取基本信息,像手指触摸之后的坐标(x,y),这个针对于单点触控,从“event.getAction()”也可知 View 源码中的触摸只考虑单点触摸。
1 | final boolean clickable = ((viewFlags & CLICKABLE) == CLICKABLE |
CONTEXT_CLICKABLE 指的是上下文点击,类似长按菜单、鼠标右键。这三类都会被 View 称作是可点击的,在点击判断时使用。
1 | if ((viewFlags & ENABLED_MASK) == DISABLED) { |
如果是不可用状态,返回 clickable 。为什么不是返回 false ?这是因为如果是一个可以点击的 View 这个事件就会被消费掉,即“虽然不可以点击,但我是一个本可以点击但被设为了禁用”的状态,被消费掉之后事件就不会向下传递。比如一个 Item 中,图片按钮和列表项都可以点击,给图片按钮设置为 DISABLED ,点击图片按钮不会导致后面的列表项被点击,Item 被阻拦掉,这是符合逻辑的。
1 | if (mTouchDelegate != null) { |
检查 TouchDelegate ,如果有的话就去消费,TouchDelegate 指的是点击代理,一般用于增大点击范围,如果 View 很小,希望用户点击到,扩大点击范围,设置 TouchDelegate 。目前一般通过设计解决了,用处较少。
1 | if (clickable || (viewFlags & TOOLTIP) == TOOLTIP) { |
进入具体点击逻辑之前,要判断是否可以点击,像 TextView 不设置点击监听,不可点击,是不会进入该方法内部的。但是该方法不只判断 clickable ,还有 TOOLTIP 。TOOLTIP 是 Android API 26 新增加的功能,用于对某个控件做文本提示,在控件上长按会出现提示信息,并且不松手提示信息就不会消失。类似如下代码:
1 | <TextView |
一个 View 虽然没有点击效果,但是想知道是什么 View ,即可以设置该属性,所以如果不是 clickable 的 View ,如果设置了 TOOLTIP ,也需要进入具体点击逻辑内部。
MotionEvent.ACTION_DOWN
MotionEvent.ACTION_DOWN 源码如下:
1 | case MotionEvent.ACTION_DOWN: |
当用户按下(ACTION_DOWN),判断用户手指是否触摸到了屏幕(event.getSource() == InputDevice.SOURCE_TOUCHSCREEN),因为除了触摸屏幕,实体按键(早期键盘手机、Android 电视等)也会触发“down”。
下面不可点击(!clickable)却也要执行长按检查(checkForLongClick),作用是为了实现之前的 TOOLTIP ,等时间到了提示文字。
performButtonActionOnTouchDown 用来检测鼠标右键点击,源码如下:
1 | protected boolean performButtonActionOnTouchDown(MotionEvent event) { |
如果是鼠标(InputDevice.SOURCE_MOUSE),并且是右键点击(BUTTON_SECONDARY),则显示上下文菜单(showContextMenu)。
isInScrollingContainer 代码判断是否在滑动控件里,源码如下:
1 | public boolean isInScrollingContainer() { |
该代码是一个循环,通过递归操作,不断地判断父 View ,是否延迟子 View 的按下状态(shouldDelayChildPressedState)这个方法是否返回为 true 。只要任何一个级别的父 View 的 shouldDelayChildPressedState 返回为 true ,则表明在一个滑动控件内。
如果不在滑动控件中,切换⾄按下状态(setPressed),并注册⻓按计时器(checkForLongClick);如果在滑动控件中,切换⾄预按下状态(mPrivateFlags |= PFLAG_PREPRESSED),并注册按下计时器(CheckForTap)。预按下等待100ms(postDelayed(mPendingCheckForTap, ViewConfiguration.getTapTimeout())代码中,getTapTimeout的值为100ms)后才触发按下。
预按下状态指的是准备显示为按下状态,但还没显示。例如微信的聊天列表,按下对话框不松手,聊天信息会变;如果只是拖动,则信息不会变色。给用户的感觉是知道自己是要拖动还是按下。当在一个滑动的控件中时,用户触摸的瞬间并不会立即显示为按下状态,因为有可能是拖动,此时设置为预按下状态。
CheckForTap 源码如下:
1 | private final class CheckForTap implements Runnable { |
通过位运算将预按下状态清空,设置按下状态和长按计时器。CheckForTap 事实上和不在滑动控件中执行的操作是一样的,二者的区别只是前者需要等一下再触发。
事实上自定义 ViewGroup 很多时候是需要重写 shouldDelayChildPressedState 方法的,该方法的默认返回值是 true ,默认所有的 ViewGroup 是可滑动的。这样 ViewGroup会对自己内部的子 View 触摸有预按下的延迟效果(postDelayed(mPendingCheckForTap, ViewConfiguration.getTapTimeout())),如果不是滑动控件,那这不是我们想要的,所以如果不是滑动控件最好重写该方法,这样就不会影响子 View 的按下状态了,代码如下:
1 |
|
MotionEvent.ACTION_MOVE
MotionEvent.ACTION_MOVE 源码如下:
1 | case MotionEvent.ACTION_MOVE: |
drawableHotspotChanged 热点改变,重绘 Ripple Effect。Android 5.0 之后界面有点击波纹效果,用户点击按钮移动,波纹的中心会随着用户手指的移动而改变,drawableHotspotChanged 实现了这种改变。pointInView 方法判断用户的手指是否移出了按键范围,如果移出,⾃我标记本次事件失效,忽略后续事件。
mTouchSlop 表示触摸溢出,指的是用户的手指具体移出去了多少,才表示用户手指移出了按键范围。该值是系统设置的,也可修改。removeTapCallback ,TapCallback 是预按下到按下等待其它的 Callback ;removeLongPressCallback , LongPressCallback 是长按监听器或者 TOOLTIP 中的长按的 Callback ,移除它们就不会再触发点击或者长按了。另外把按下状态置空,变成未按下状态。移出手指,认为 View 事件序列结束了。
MotionEvent.ACTION_UP
MotionEvent.ACTION_UP 源码如下:
1 | case MotionEvent.ACTION_UP: |
松手之后,消失 TOOLTIP 显示的信息,如果是不可点击的,则做各种状态的移除。如果是按下(PFLAG_PREPRESSED)或者预按下(prepressed),进入方法内部。
如果当前可以获取焦点、 InTouchMode 也可以获取焦点并且当前还没有获取焦点,则执行获取焦点方法。isFocusableInTouchMode 同样指的是实体按键下获取焦点。
如果是按下状态并且未触发⻓按,切换⾄抬起状态并触发点击事件,并清除⼀切状态;如果已经触发⻓按,切换⾄抬起状态并清除⼀切状态。
MotionEvent.ACTION_CANCEL
MotionEvent.ACTION_CANCEL 源码如下:
1 | case MotionEvent.ACTION_CANCEL: |
当事件意外结束,切换⾄抬起状态,并清除⼀切状态。
自定义 ViewGroup 的触摸反馈
onInterceptTouchEvent 拦截事件
除了重写 onTouchEvent() ,还需要重写 onInterceptTouchEvent()。onInterceptTouchEvent() 不⽤在第⼀时间返回 true,⽽是在任意事件需要拦截时返回
true 即可。在实际情况下,onInterceptTouchEvent() 开始不拦截事件,返回 false ,到达某个适当的时机(比如用户滑动了足够长的纵向距离),返回 true ,事件不再发送给子 View ,而发给父 View 的 onTouchEvent() ,并且后续事件都由自己处理,此时便不再调用父 View 的 onInterceptTouchEvent(),直接调用父 View 的 onTouchEvent()。
在未拦截时,父 View 的 onTouchEvent() 并没有之前相关事件的信息,所以 onInterceptTouchEvent() 除了判断拦截条件,还需要为拦截做后续的准备,比如记录“down”事件的位置。
onInterceptTouchEvent() 中,ACTION_DOWN 事件做的事和 onTouchEvent() 基本⼀致或完全⼀致。
原因:ACTION_DOWN 在多数⼿势中起到的是起始记录的作⽤(例如记录⼿指落点),⽽
onInterceptTouchEvent() 调⽤后,onTouchEvent() 未必会被调⽤,因此需要把这个记录
责任转交给 onInterceptTouchEvent()。有时 ACTION_DOWN 事件也会在经过 onInterceptTouchEvent() 之后再转交给⾃⼰的
onTouchEvent()(例如当触摸到的⼦ View 没有消费事件时。 注意,当没有子 View 时,直接调用父 View 的 onTouchEvent() 而不会调用它的 onInterceptTouchEvent() )。因此需要确认在 onInterceptTouchEvent() 和 onTouchEvent() 都被调⽤时,程序状态不会出问题。
onInterceptTouchEvent() 中,ACTION_MOVE ⼀般的作⽤是确认滑动,即当⽤户朝某⼀⽅向滑动⼀段距离(touch slop)后, ViewGroup 要向⾃⼰的⼦ View 和⽗ View 确认⾃⼰将消费事件。
确认滑动的⽅式: Math.abs(event.getX()-downX)>ViewConfiguration.getXxxSlop();
告知⼦ View 的⽅式:在 onInterceptTouchEvent() 中返回 true,⼦ View 会收到
ACTION_CANCEL 事件,并且后续事件不再发给子 View;告知⽗ View 的⽅式:调⽤ getParent().requestDisallowInterceptTouchEvent(true)。这个方法会递归通知每⼀级⽗ View,让它们在后续事件中不要再尝试通过 onInterceptTouchEvent() 拦截事件。这个通知仅在当前事件序列有效,即在这组事件结束后(即⽤户抬⼿后),⽗ View 会⾃动对后续的新事件序列启⽤拦截机制。
触摸反馈的流程
Activity.dispatchTouchEvent() 会调用根 ViewGroup(View) 的 ViewGroup(View).dispatchTouchEvent(),接着调用自己内部的 ViewGroup.onInterceptTouchEvent() 以及它子 View 的 child.dispatchTouchEvent()。子 View 的 dispatchTouchEvent() 调用自己的 View.onTouchEvent() 。前面说的调用 onTouchEvent() 事实上都是通过 dispatchTouchEvent() 来调用的。
View.dispatchTouchEvent() 主要调用了自己的 onTouchEvent() ,ViewGroup.dispatchTouchEvent() 中,如果拦截,则调用自己的 onTouchEvent() ,否则调用子 View 的 dispatchTouchEvent() 。
View.dispatchTouchEvent()
源码主要工作
如果设置了 OnTouchListener,调⽤ OnTouchListener.onTouch(),如果 OnTouchListener 消费了事件,返回 true ,如果 OnTouchListener 没有消费事件,继续调用自己的 onTouchEvent(),并返回和 onTouchEvent() 相同的结果。
如果没有设置 OnTouchListener,则调用 View.onTouchEvent()。
ViewGroup.dispatchTouchEvent()
源码主要工作
源码主要做了以下五块内容:
如果是⽤户初次按下(ACTION_DOWN),清空 TouchTargets 和 DISALLOW_INTERCEPT 标记。TouchTargets 与多点触控有关,表示已经触摸到的每一个子 View,DISALLOW_INTERCEPT 表示之前调用“getParent().requestDisallowInterceptTouchEvent()”方法被子 View 设置了不要拦截的要求,在新一次的点击中,清除该标志。
拦截处理,如果不是初次按下,并且没有 TouchTarget (子 View 放弃事件),直接拦截,不需要再调用 onInterceptTouchEvent() 做是否拦截的判断;如果是初次按下,或者有 TouchTarget ,则判断是否设置了 disallow intercept ,如果设置(子 View 调用 getParent().requestDisallowInterceptTouchEvent() 通知过自己)则不拦截;否则,调⽤ onInterceptTouchEvent(),如果返回 true 则拦截,返回 false 则不拦截。
如果不拦截并且不是 CANCEL 事件,并且是 DOWN 或者 POINTER_DOWN,尝试把 pointer(⼿指)通过 TouchTarget 分配给⼦ View;并且如果分配给了新的⼦ View,调⽤ child.dispatchTouchEvent() 把事件传给⼦ View 。
看有没有 TouchTarget ,“down”事件的所有子 View 都不需要,则调用自己的 dispatchTouchEvent() ,进一步调用自己的 onTouchEvent() ;如果有,调⽤ child.dispatchTouchEvent() 把事件传给对应的⼦ View 。
如果是 POINTER_UP,从 TouchTargets 中清除 POINTER 信息;如果是 UP 或 CANCEL,重置状态。
TouchTarget
一个 TouchTarget 会记录一个⼦ View 是被哪些 pointer(⼿指)按下的,并有一个指向下一个 TouchTarget 的指针。它是一个单向链表的结构。TouchTarget 还会处理一些事情,比如 View 可能会发生形变以及父 View 拦截子 View 的事件之后向子 View 发送 cancel 事件。
1 | TouchTarget 指的是有哪些子 View 要消费事件,一个 TouchTarget 表示有一个子 View 声明要消费事件。 |
参考资料:
腾讯课堂 HenCoder