Android关于Touch事件的相关研究

目录

  1. Touch Mode
  2. MotionEvent
  3. 常用手势
  4. View的滑动
    1. 1.使用scrollTo/scrollBy
    2. 2.使用动画
    3. 3.改变布局参数
    4. 4.使用ViewDragHelper
    5. 5.总结几个函数
  5. View的事件分发和滑动冲突解决
    1. 1.使用TouchDelegate扩大View点击事件范围
    2. 2.事件分发机制简述
    3. 3.常用滑动冲突解决方法
  6. 参考

Touch Mode

Android有许多Input Type,包括轨迹球、书写笔、声音等,我们主要探究触摸屏这种输入。针对其他输入,这里说明一点:有些手机的实体按键点击时会把该事件传递给当前屏幕中处于焦点状态View的onKey()回调方法,当然也可以在Activity中dispatchKeyEvent()进行拦截,或者通过onKeyDown()onKeyUp()进行监听。当前手机APP主要交互是通过触摸屏进行,根据KeyCode监听实体按键的情况不是很多了,而且像Back键、Menu键系统也提供了方便的回调方法,但Home键并没有,而且在onKey方法中也不回调,网上通常采用监听Home键产生的广播事件进行监听,本文不过多对此进行讨论。现在手机通常是虚拟软键盘输入,按键时通常不会回调onKeyDown方法,所以针对虚拟键盘最好不要使用onKeyDown等回调事件。针对EditText控件提供了OnEditorActionListener回调方法,用于处理输入完成后的后续工作。Demo如下:

1
2
3
4
5
6
7
8
9
10
private EditText mEditText;
...
mEditText.setImeOptions(EditorInfo.IME_ACTION_SEARCH); // 输入后搜索
mEditText.setSingleLine(true); // required
mEditText.setOnEditorActionListener(new TextView.OnEditorActionListener() {
@Override
public boolean onEditorAction(TextView v, int actionId, KeyEvent event) {
return true;
}
});

想查看支持哪些输入类型,可以通过INPUT_SERVICE服务查看,其类型常量定义在InputDevice类。Demo如下:

1
2
3
4
5
6
7
InputManager inputManager = (InputManager) getSystemService(Context.INPUT_SERVICE);
int[] ids = inputManager.getInputDeviceIds();
for (int i : ids) {
...
inputManager.getInputDevice(i).getSources() == Inputdevice.XXX;
...
}

触摸操作相比其他交互,不需要高亮来提醒用户当前焦点处于何处,所以当用户点击触摸屏时会处于”Touch Mode”,通过isInTouchMode()判断是否处于Touch Mode。在Touc Mode下,只有isFocusableInTouchMode()==true的View会获得焦点(即isFocused()返回true),比如EditText;而可触摸的View(即isFocusable()返回true)点击时也不会获得焦点,仅仅执行其onClick回调,比如Button。如果Button要获得焦点(isFocused()返回true),必须保证:

  1. isFocusableInTouchMode() = true
  2. isFocusable() = true
  3. 调用requestFocus()

View的焦点变化可以通过OnFocusChangeListener进行监听,View失去焦点后如何传递焦点是由系统计算的,当然可以手动进行控制,详情参考(https://developer.android.com/guide/topics/ui/ui-events.html#HandlingFocus)。

MotionEvent

使用该对象记录movement事件,包括mouse、pen、finger、trackball等,这里只讨论touch,所以限定movement是由手指产生的,通过getToolType(int)方法可以判断触摸事件是什么类型的,常量在MotionEvent中定义。MotionEvent主要是由Action Code和一系列坐标值组成,前者记录事件类型,调用getActionMasked()得到;后者记录位置和其他动作属性。通常现在手机支持多点触控,一个完整触摸交互的Action Code流程图如下,但是系统并不能保证所有touch交互都连贯执行,所以一定要处理好ACTION_CANCEL等情况。

系统会纪录多点触控时所有手指touch的运动轨迹,MotionEvent对象中会包含所有手指touch的信息,即使该时刻某个手指并没有产生新的运动轨迹;系统定义产生不同运动轨迹的手指为pointer,每个pointer从ACTION_DOWN开始到结束会分配唯一一个Id,MotionEvent对象提供查询每个pointer坐标和其他属性的方法,但参数使用index(0到getPointCount()-1),调用getActionIndex()可以得到对应的index;每个MotionEvent中pointer的index可能会改变,但是pointer的id不会变,提供getPointerId(int)findPointerIndex(int)进行转化。其中:getX/getY表示相对于父控件的坐标,getRawX/getRawY表示相对于手机坐标系的坐标。
Batching
处理ACTION_MOVE事件时,因效率通常会把多个movement放到一个MotionEvent对象中,叫做batching;系统提供API去查询“历史”坐标值:getHistoricalX(int, int)和getHistoricalY(int, int),官方demo如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
void printSamples(MotionEvent ev) {
final int historySize = ev.getHistorySize();
final int pointerCount = ev.getPointerCount();
for (int h = 0; h < historySize; h++) {
System.out.printf("At time %d:", ev.getHistoricalEventTime(h));
for (int p = 0; p < pointerCount; p++) {
System.out.printf(" pointer %d: (%f,%f)",
ev.getPointerId(p), ev.getHistoricalX(p, h), ev.getHistoricalY(p, h));
}
}
System.out.printf("At time %d:", ev.getEventTime());
for (int p = 0; p < pointerCount; p++) {
System.out.printf(" pointer %d: (%f,%f)",
ev.getPointerId(p), ev.getX(p), ev.getY(p));
}
}

常用手势

系统提供GestureDetector类方便对常见手势进行检测,提供ScaleGestureDetector对缩放手势进行检查。在GestureDetector中OnGestureListener和OnDoubleTapListener定义了常见手势,如下:

  • GestureDetector.OnGestureListener
方法名 描述
onDown 手指轻触屏幕一瞬间,由1个ACTION_DOWN触发
onShowPress 手指轻触屏幕,尚未松开或者拖动
onSingleTapUp 手指轻触屏幕后松开,由1个ACTION_UP触发
onScroll 手指按下屏幕并拖动,由1个ACTION_DOWN,多个ACTION_MOVE触发
onLongPress 手指长按屏幕不放
onFling 快速滑动行为,由1个ACTION_DOWN,多个ACTION_MOVE和1个ACTION_UP触发
  • GestureDetector.OnDoubleTapListener
方法名 描述
onSingleTapConfirmed 严格的单击行为,后面肯定不会再紧跟另一个单击行为
onDoubleTap 双击,由两次单击组成,与OnSingleTapConfirmed不共存
onDoubleTapEvent 双击期间,ACTION_DOWN,ACTION_MOVE和ACTION_UP都会触发该事件

单击Log

1
2
3
4
onDown
onShowPress(快速点击可能不出现)
onSingleTapUp
onSingleTapConfirmed

双击Log

1
2
3
4
5
6
7
onDown
onShowPress(快速点击可能不出现)
onSingleTapUp
onDoubleTap
onDoubleTapEvent
onDown
onDoubleTapEvent

ScaleGestureDetector中OnScaleGestureListener定义缩放手势的回调事件:

  • ScaleGestureDetector.OnScaleGestureListener
方法名 描述
onScaleBegin 通常新pointer按下时会触发,返回false意味不监听该后续的缩放事件
onScale 缩放过程中伴随手指移动会调用多次
onScaleEnd 缩放结束时回调

ScaleGestureDetector提供getFocusX()/getFocusY()、getCurrentSpanX()/getCurrentSpanY()、getScaleFactor()等方法,用于获取缩放时焦点坐标,缩放时X轴和Y轴距离,缩放比例等,使用这些方法可以方便的进行缩放操作。

Tips:

  1. ViewConfiguration类定义了UI中关于Touch的一些常量,比如getScaledTouchSlop()指系统认为用户在滑动的最小距离,单位是px;getScaledMinimumFlingVelocity()指系统认为onFling的最小速度值,单位是pixels per second。
  2. VelocityTracker类可以追踪滑动过程中水平和竖直方向的速度,沿着坐标轴正向滑动方向为正。
  3. 系统在ACTION_MOVE根据滑动距离是否大于getScaledTouchSlop()判断onScroll,在ACTION_UP根据速度是否大于getScaledMinimumFlingVelocity()判断onFling。
  4. GestureDetector.SimpleOnGestureListener提供对上述手势的封装,只需Override感兴趣的手势事件即可。
  5. 使用GestureDetector和ScaleGestureDetector时,需要把onTouchEvent事件全部交给GestureDetector、ScaleGestureDetector处理,其代码如下:
    1
    2
    3
    4
    5
    6
    @Override
    public boolean onTouchEvent(MotionEvent event) {
    boolean retVal = mScaleGestureDetector.onTouchEvent(event);
    retVal = mGestureDetector.onTouchEvent(event) || retVal;
    return retVal || super.onTouchEvent(event);
    }

View的滑动

View的滑动很常见,有多种方式可以实现,讨论几种常用的方法:

1.使用scrollTo/scrollBy

View类本身提供了这对方法用于对content滑动,但不能改变view在布局中的位置。View提供getScrollX()和getScrollY()两个方法得到内部两个属性mScrollX,mScrollY。其中,mScrollX表示View左边缘和View的content左边缘的水平距离,mScrollY表示View上边缘和View的内容上边缘的垂直距离。滑动时方向与正负的关系如下:

直接使用上述方法显得很生硬,所以系统提供OverScroller用于把一次大的滑动分成若干次小的滑动,并在一定时间段内完成,从而实现弹性滑动;官方建议使用OverScroller替代Scroller。使用OverScroller的用法基本固定,demo如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class CustomView extends View{
...
private OverScroller mOverScroller = new OverScroller(getContext());

@Override
public void computeScroll() {
if (mOverScroller.computeScrollOffset()) {
scrollTo(mOverScroller.getCurrX(), mOverScroller.getCurrY());
postInvalidate();
}
}

// 2s内向右滑动100px
mOverScroller.startScroll(getScrollX(), 0, getScrollX() - 100, 0, 2000);
postInvalidate();

...
}

还有一种讨巧的方式,但灵活度挺好,使用属性动画的AnimatorUpdateListener,demo如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
final ValueAnimator animator = ValueAnimator.ofInt(0, 1).setDuration(2000);
animator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
int startX = getScrollX();
@Override
public void onAnimationUpdate(ValueAnimator animation) {
float fraction = animator.getAnimatedFraction();
// 2s内向右滑动100px
mMyScrollView.scrollTo(startX + (int) ((-100) * fraction), 0);
// 可以进行其他操作
...
}
});
animator.start();

2.使用动画

1.Tween Animation
建议使用XML定义动画,但demo使用代码定义,如下:

1
2
3
4
5
6
7
float startX = mMyScrollView.getTranslationX();
float startY = mMyScrollView.getTranslationY();
// 2s内向右滑动100px
TranslateAnimation translateAnimation = new TranslateAnimation(startX, startX + 100, startY, startY);
translateAnimation.setDuration(2000);
translateAnimation.setFillAfter(true);
mMyScrollView.startAnimation(translateAnimation);

动画主要操作View的translationX和translationY属性,但是Tween Animation可以认为只是改变绘制View的位置,但是View本身位置属性没有改变,具体表现为新位置的View不会触发onClick事件。

2.Property Animation
使用ObjectAnimator类可以直接实现弹性的滑动效果(建议复习一下ObjectAnimator类的使用条件),API 11添加了属性动画特性,会真正改变View的位置属性,demo如下:

1
2
// 2s内向右滑动100px
ObjectAnimator.ofFloat(mMyScrollView, "translationX", 0, 100).setDuration(2000).start();

3.改变布局参数

调整LayoutParams的属性也可以实现滑动效果,比较适合交互的View。但是滑动的View需要有父布局,并且知道父布局类型,才能得到对应的LayoutParams。但简单的平移可以通过改变View的Margin属性实现,所以使用ViewGroup.MarginLayoutParams比较方便,demo如下:

1
2
3
4
5
// 向右滑动100px
ViewGroup.MarginLayoutParams layoutParams = (ViewGroup.MarginLayoutParams) mMyScrollView.getLayoutParams();
layoutParams.leftMargin += 100;
// mMyScrollView.setLayoutParams(layoutParams);
mMyScrollView.requestLayout();

View中提供对上述布局调整方法的封装:offsetLeftAndRightoffsetTopAndBottom;偏移量正数表示右移和下移,负数表示左移和上移。

4.使用ViewDragHelper

2013年Google在support包引入DraweLayout和SlidingPaneLayout两个布局实现侧边栏滑动效果,内部都使用了ViewDragHelper工具类,可以方便实现View的drag,包括滑动,可以方便实现手QQ侧滑菜单栏的效果。其内部滑动使用offsetLeftAndRightoffsetTopAndBottom进行实现。ViewDragHelper提供smoothSlideViewTo(View,int,int)settleCapturedViewAt(int,int)方法用于View滑动,本质使用Scroller机制,建议阅读DraweLayout和SlidingPaneLayout源码学习ViewDragHelper的使用。

5.总结几个函数

getLocationOnScreen(int []):获得View在屏幕中坐标
getLocationInWindow(int []):获得View相对Window的坐标

View的事件分发和滑动冲突解决

1.使用TouchDelegate扩大View点击事件范围

官方demo比较好,建议学习;缺点是只能扩大parent中一个child view。官方demo如下:

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
...
// Get the parent view
View parentView = findViewById(R.id.parent_layout);

parentView.post(new Runnable() {
// Post in the parent's message queue to make sure the parent
// lays out its children before you call getHitRect()
@Override
public void run() {
// The bounds for the delegate view (an ImageButton
// in this example)
Rect delegateArea = new Rect();
ImageButton myButton = (ImageButton) findViewById(R.id.button);
myButton.setEnabled(true);
myButton.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View view) {
Toast.makeText(MainActivity.this,
"Touch occurred within ImageButton touch region.",
Toast.LENGTH_SHORT).show();
}
});

// The hit rectangle for the ImageButton
myButton.getHitRect(delegateArea);

// Extend the touch area of the ImageButton beyond its bounds
// on the right and bottom.
delegateArea.right += 100;
delegateArea.bottom += 100;

// Instantiate a TouchDelegate.
// "delegateArea" is the bounds in local coordinates of
// the containing view to be mapped to the delegate view.
// "myButton" is the child view that should receive motion
// events.
TouchDelegate touchDelegate = new TouchDelegate(delegateArea,
myButton);

// Sets the TouchDelegate on the parent view, such that touches
// within the touch delegate bounds are routed to the child.
if (View.class.isInstance(myButton.getParent())) {
((View) myButton.getParent()).setTouchDelegate(touchDelegate);
}
}
});
...

2.事件分发机制简述

各种资料对这部分内容都有讲解,本文对整个View的点击事件(Touch Event)的分发机制进行简述。主要涉及以下三个函数,其返回值true表示消费事件,false表示不消费。

1
2
3
public boolean dispatchTouchEvent(MotionEvent event);
public boolean onInterceptTouchEvent(MotionEvent event);
public boolean onTouchEvent(MotionEvent event);

1. 所有点击产生的MotionEvent都传递到Activity.dispatchTouchEvent()中,源码如下,如果getWindow()没有消费该事件,则默认交给Activity.onTouchEvent()处理,其默认值为false。

1
2
3
4
5
6
7
8
9
public boolean dispatchTouchEvent(MotionEvent ev) {
if (ev.getAction() == MotionEvent.ACTION_DOWN) {
onUserInteraction();
}
if (getWindow().superDispatchTouchEvent(ev)) {
return true;
}
return onTouchEvent(ev);
}

2. getWindow().superDispatchTouchEvent()实际上调用mDecor.superDispatchTouchEvent(),因为DecorView继承于FrameLayout,所以该方法最终到ViewGroup.dispatchTouchEvent()中。

3. ViewGroup.dispatchTouchEvent()关键流程的简化版如下:

Tips:

  1. FLAG_DISALLOW_INTERCEPT标识位通常是子View调用requestDisallowInterceptTouchEvent(boolean)进行设置,用于控制父ViewGroup对事件的拦截,但是ACTION_DOWN事件一定会被父ViewGroup拦截的。
  2. onInterceptTouchEvent()方法并不是每次都被调用,如果onInterceptTouchEvent()对ACTION_DOWN进行拦截,即返回true,则同一序列的后续事件不会进入onInterceptTouchEvent()方法,会直接交给super.dispatchTouchEvent()处理,也就是说ViewGroup响应事件,不会传递给子View。

4. View.dispatchTouchEvent()关键流程的简化版如下:

Tips:
1.View即使处于不可用状态(isEnabled() = false),也会消费事件:

1
2
3
return (((viewFlags & CLICKABLE) == CLICKABLE
|| (viewFlags & LONG_CLICKABLE) == LONG_CLICKABLE)
|| (viewFlags & CONTEXT_CLICKABLE) == CONTEXT_CLICKABLE);

2.onTouchListener优先级最高, 最低的是onClickListener。
3.调用setOnClickListener()和setLongClickable()方法时会自动把CLICKABLE和LONG_CLICKABLE属性设为true。

3.常用滑动冲突解决方法

父View与子View同时可以滑动时就会产生滑动冲突,表现就是滑动作用控件不对或者卡顿。解决这种问题通常有两种方法:

1.使用父View的onInterceptTouchEvent()进行拦截。大体思想如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public boolean onInterceptTouchEvent(MotionEvent event){
boolean intercepted = false;
switch(event.getActionMasked){
case MotionEvent.ACTION_DOWN:
intercepted = false;
break;
case MotionEvent.ACTION_MOVE:
if(父控件拦截){
intercepted = true;
}else{
intercepted =false;
}
break;
...
}
...
return intercepted;
}

2.子View使用requestDisallowInterceptTouchEvent进行控制,解决ViewPager嵌套通常使用该方法,建议研究ViewPager源码,内部也用到该方法解决滑动冲突的问题。其大体思想如下:

a) 父View的onInterceptTouchEvent()拦截所有事件

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

b) 子View的dispatchTouchEvent()进行控制

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public boolean dispatchTouchEvent(MotionEvent event){
switch(event.getActionMasked){
case MotionEvent.ACTION_DOWN:
parent.requestDisallowInterceptTouchEvent(true);
break;
case MotionEvent.ACTION_MOVE:
if(父控件拦截){
parent.requestDisallowInterceptTouchEvent(false);
}
break;
...
}
...
return super.dispatchTouchEvent(event);
}

参考

[1] https://developer.android.com/training/gestures/index.html
[2] https://developer.android.com/reference/android/view/MotionEvent.html
[3] https://developer.android.com/guide/topics/ui/ui-events.html
[4] https://developer.android.com/training/custom-views/making-interactive.html
[5] 《Android开发艺术探索》 任玉刚 著
[6] 《Android群英传》 徐宜生 著