Android 多点触控 MotionEvent详解

相关API 介绍

MotionEvent.getY() 和 MotionEvent.getRawY() 的区别

getY 表示触摸事件在当前的View内的Y 坐标, getRawY表示触摸事件在整个屏幕上面的Y 坐标

MotionEvent.getActionIndex()

event.getActionIndex() 表示当前触摸手指的index, 用于多点触控。
getActionIndex 只在 ACTION_POINTER_DOWN 和 ACTION_POINTER_UP 的时候用到。
返回当前ACTION_POINTER_DOWN 或者 ACTION_POINTER_UP 对应的手指Index。如果不是ACTION_POINTER_DOWN 和ACTION_POINTER_UP 事件就会一直返回0。

我们拿到当前的触摸手指的Index 之后,就可以拿到当前触摸手指的Id:event.getPointerId(event.getActionIndex()). 在多点触控过程中,Index 可能会变,但是Id 不会变。 我们也可以根据Id 拿到 index,从而计算触摸手指Id 对应的Y 坐标:event.findPointerIndex(mActivePointerId)

MotionEvent.getActionMasked() 和 MotionEvent.getAction 有什么区别

MotionEvent.getAction() 识别不了 MotionEvent.ACTION_POINTER_DOWN 和 ACTION_POINTER_UP 事件,所以如果自定义View 用到了多点触控,要使用getActionMasked() 方法

问题

比如我们写一个支持手指滑动操作的控件时,当你一根手指操作你发现没有问题,但是当多根手指的时候,会有一些问题。具体表现为:
开始的时候,按下第一根手指,然后我们在距离第一根手指很远的地方,按下第二根手指,就会发现页面突然滚动了一段距离。

是因为代码没有去支持多点触控
因为event.getY() 返回的可能是任意的一个手指的位置, 你可以打印log 去观察。默认getY()返回第1个手指的位置 当你再放一个手指(第二个手指)到屏幕上, 然后松开第一根手指, 你会发现第getY() 返回的变成了第二个手指的位置 然后你再放一个手指,你会发现getY()又变成了新的手指的位置。

所以你在onTouchEvent 里面 ,如果你是按照getY() 和 LastY 做差值去移动页面,ACTION_MOVE 的时候会有两个手指的落差 ,造成双指切换的时候 页面会来回跳动

解决方法:

我们定义一个变量为有效手指Id,每当有一个有新的手指触摸屏幕的时候,把新的手指当做有效手指,记录有效手指的Id,更新上次手指的位置为新手指的位置。在Action_MOVE的时候,我们根据有效手指的Id 去拿到有效手指滚动的距离,从而决定当前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
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
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
    @Override
    public boolean onTouchEvent(MotionEvent event) {
        if (mVelocityTracker == null) {
            mVelocityTracker = VelocityTracker.obtain();
        }
        switch (event.getActionMasked()) {
            case MotionEvent.ACTION_DOWN:
                if (!mScroller.isFinished()) {
                    mScroller.abortAnimation();
                }

                mLastY = (int) event.getY();
                mActivePointerId = event.getPointerId(event.getActionIndex());
                Log.d(TAG, "ACTION_DOWN mLastY:" + mLastY);
                break;

            case MotionEvent.ACTION_UP:
                Log.d(TAG, "ACTION_UP");                mVelocityTracker.computeCurrentVelocity(1000,mScaledMaximumFlingVelocity);
                int yVelocity = (int) mVelocityTracker.getYVelocity();
                Log.d("ScrollTextView", "yVelocity:" + yVelocity);

                if (Math.abs(yVelocity) > mScaledMinimumFlingVelocity) {
                    if (!mScroller.isFinished()) {
                        mScroller.abortAnimation();
                    }
                    mScroller.fling(0,getScrollY(),0, -yVelocity,0,0,0,getLayout().getHeight() - getHeight());
                    //之前老是掉帧的感觉   原来是因为fling 之后 并没有去执行invalidate 有时候可以是因为可能刚好到了60帧刷新的节点
                    ViewCompat.postInvalidateOnAnimation(this);
                }
                mVelocityTracker.recycle();
                mVelocityTracker = null;
                mLastY = (int) event.getY();
                mActivePointerId = INVALIDATE_ID;
                break;              
            case MotionEvent.ACTION_MOVE:
               if (mActivePointerId == INVALIDATE_ID) {
                    break;
                }
                int pointerIndex = event.findPointerIndex(mActivePointerId);
                Log.d(TAG, "mActivePointerId:" + mActivePointerId);
                Log.d(TAG, "pointerIndex:" + pointerIndex);
                //通过index 获取 坐标
                float eventY = event.getY(pointerIndex);
                boolean b = overScrollBy(0, (int) (mLastY - eventY), getScrollX(), getScrollY(), 0, getLayout().getHeight() - getHeight(), 0, 0, true);
                if (b) {
                    mVelocityTracker.clear();
                }
                mLastY = (int) event.getY(pointerIndex);
                Log.d(TAG, "mLastY:" + mLastY);

                break;

                //手指放下
            case MotionEvent.ACTION_POINTER_DOWN:
                //拿到手指的index
                int actionIndex = event.getActionIndex();
                //记录有效的手指id
                mActivePointerId = event.getPointerId(actionIndex);
                mLastY = (int) event.getY(actionIndex);
                break;

                //手指拿起
            case MotionEvent.ACTION_POINTER_UP:
                int actionIndex1 = event.getActionIndex();
                int pointerId = event.getPointerId(actionIndex1);
                if (pointerId == mActivePointerId) {
                    int newPointIndex = actionIndex1 == 0 ? 1 : 0;
                    mLastY = (int) event.getY(newPointIndex);
                    mActivePointerId = event.getPointerId(newPointIndex);
                    if (mVelocityTracker != null) {
                        mVelocityTracker.clear();
                    }

                }

                break;
            default:
                Log.d(TAG, "event:" + event);
                break;
        }
        if (mVelocityTracker != null) {
            mVelocityTracker.addMovement(event);
        }

        return true;
    }

反思总结

1.ScrollView 原生控件都是我们学习的很好的例子。如果你想学习多点触控,ScrollView 只一个很好地学习材料。

2.某一时刻 触摸事件的打印:

2020-06-17 21:56:31.141 22380-22380/com.pipiyang.cn03 D/ScrollTextView: event:MotionEvent { action=ACTION_POINTER_DOWN(2), actionButton=0, id[0]=0, x[0]=396.0, y[0]=1144.0, toolType[0]=TOOL_TYPE_FINGER, id[1]=1, x[1]=510.0, y[1]=803.0, toolType[1]=TOOL_TYPE_FINGER, id[2]=2, x[2]=690.0, y[2]=441.0, toolType[2]=TOOL_TYPE_FINGER, buttonState=0, metaState=0, flags=0x0, edgeFlags=0x0, pointerCount=3, historySize=0, eventTime=488846740, downTime=488846006, deviceId=4, source=0x1002 }

附:完整的一个支持超长文字滚动的TextView:

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
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
import android.content.Context;
import android.graphics.Canvas;
import android.util.AttributeSet;
import android.util.Log;
import android.view.MotionEvent;
import android.view.VelocityTracker;
import android.view.ViewConfiguration;
import android.widget.OverScroller;

import androidx.annotation.Nullable;
import androidx.core.view.ViewCompat;

/**
 * =======================================================================================
 * 作    者:caoxinyu
 * 创建日期:2020/5/10.
 * 类的作用:可以上下滑动的TextView
 * 修订历史:
 *
 *
 * 问题1.textView 高度有问题
 * 终于知道MeasureSpec.UNSPECIFIED 有什么用了
 * 因为有些是无限大的 比如 listView
 * 所以 他的尺寸应该是UNSPECIFIED 的
 * =======================================================================================
 */
public class ScrollTextView extends androidx.appcompat.widget.AppCompatTextView {

    private OverScroller mScroller;
    private OverScroller mOverScroller;
    private int mLastY;
    private VelocityTracker mVelocityTracker;
    private ViewConfiguration mViewConfiguration;
    private int mScaledMaximumFlingVelocity;
    private int mScaledMinimumFlingVelocity;
    private static final String TAG = "ScrollTextView";
    private int mActivePointerId;
    private final int INVALIDATE_ID = -1;

    public ScrollTextView(Context context) {
        super(context);
        init(context);
    }

    public ScrollTextView(Context context, @Nullable AttributeSet attrs) {
        super(context, attrs);
        init(context);
    }

    public ScrollTextView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        init(context);
    }

    private void init(Context context) {
        mScroller = new OverScroller(context);
        mScroller = new OverScroller(context);
        mOverScroller = new OverScroller(context);
        mViewConfiguration = ViewConfiguration.get(context);
        mScaledMinimumFlingVelocity = mViewConfiguration.getScaledMinimumFlingVelocity();
        mScaledMaximumFlingVelocity = mViewConfiguration.getScaledMaximumFlingVelocity();
        Log.d("ScrollTextView", "mScaledMinimumFlingVelocity:" + mScaledMinimumFlingVelocity);
        Log.d("ScrollTextView", "mScaledMaximumFlingVelocity:" + mScaledMaximumFlingVelocity);


    }

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);

    }

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        if (mVelocityTracker == null) {
            mVelocityTracker = VelocityTracker.obtain();
        }
        //getY 表示在当前的View 内的Y 坐标 getRawY表示 在整个屏幕上面的Y 坐标
        Log.d(TAG, "event.getY():" + event.getY());
        Log.d(TAG, "event.getRawY():" + event.getRawY());
        Log.d(TAG+"23", "event.getActionIndex():" + event.getActionIndex());
        Log.d(TAG+"23", "event.getPointerId(event.getActionIndex()):" + event.getPointerId(event.getActionIndex()));

        Log.d(TAG, "event:" + event);
        // TODO: 2020/6/18  event.getActionIndex() 为什么一直返回0?
        //getActionIndex 只在 ACTION_POINTER_DOWN 和 ACTION_POINTER_UP 的时候用到
        //放回当前ACTION_POINTER_DOWN 或者 ACTION_POINTER_UP 对应的手指Index
        //如果不是这种ACTION_POINTER_DOWN 和 ACTION_POINTER_UP 事件
        //就会一直返回0

        Log.d(TAG, "event.getActionIndex():" + event.getActionIndex());
        // TODO: 2020/6/18 event.getActionMasked() 和 event.getAction 有什么区别?
        // TODO: 2020/6/18  event.getAction 好像识别不了 MotionEvent.ACTION_POINTER_DOWN
        switch (event.getActionMasked()) {
            case MotionEvent.ACTION_DOWN:
                if (!mScroller.isFinished()) {
                    mScroller.abortAnimation();
                }

                mLastY = (int) event.getY();
                mActivePointerId = event.getPointerId(event.getActionIndex());
                Log.d(TAG, "ACTION_DOWN mLastY:" + mLastY);
                break;

            case MotionEvent.ACTION_UP:
                Log.d(TAG, "ACTION_UP");

                mVelocityTracker.computeCurrentVelocity(1000,mScaledMaximumFlingVelocity);
                int yVelocity = (int) mVelocityTracker.getYVelocity();
                Log.d("ScrollTextView", "yVelocity:" + yVelocity);

                if (Math.abs(yVelocity) > mScaledMinimumFlingVelocity) {
                    if (!mScroller.isFinished()) {
                        mScroller.abortAnimation();
                    }
                    mScroller.fling(0,getScrollY(),0, -yVelocity,0,0,0,getLayout().getHeight() - getHeight());
                    //之前老是掉帧的感觉   原来是因为fling 之后 并没有去执行invalidate 有时候可以是因为可能刚好到了60帧刷新的节点
                    ViewCompat.postInvalidateOnAnimation(this);
                }
                mVelocityTracker.recycle();
                mVelocityTracker = null;
                mLastY = (int) event.getY();
                mActivePointerId = INVALIDATE_ID;
                break;

                //todo 同时只能收到一个手指的移动?
            case MotionEvent.ACTION_MOVE:
                // TODO: 2020/6/17 为什么event.getY() 是任意的?
                //因为总是第0个 但是第0个 可能会是任意一个
                // TODO: 2020/6/18  event.getY(mPointerId)
                //根据有效id 获取 index

                // TODO: 2020/6/18 为什么在actionUP 之后还能收到actionMove? 多指触控的时候
                if (mActivePointerId == INVALIDATE_ID) {
                    break;
                }
                int pointerIndex = event.findPointerIndex(mActivePointerId);
                Log.d(TAG, "mActivePointerId:" + mActivePointerId);
                Log.d(TAG, "pointerIndex:" + pointerIndex);
                //通过index 获取 坐标
                float eventY = event.getY(pointerIndex);
                boolean b = overScrollBy(0, (int) (mLastY - eventY), getScrollX(), getScrollY(), 0, getLayout().getHeight() - getHeight(), 0, 0, true);
                if (b) {
                    mVelocityTracker.clear();
                }
                mLastY = (int) event.getY(pointerIndex);
                Log.d(TAG, "mLastY:" + mLastY);

                break;

                //手指放下
            case MotionEvent.ACTION_POINTER_DOWN:
                //拿到手指的index
                int actionIndex = event.getActionIndex();
                //记录有效的手指id
                mActivePointerId = event.getPointerId(actionIndex);
                mLastY = (int) event.getY(actionIndex);
                break;

                //手指拿起
            case MotionEvent.ACTION_POINTER_UP:
                int actionIndex1 = event.getActionIndex();
                int pointerId = event.getPointerId(actionIndex1);
                if (pointerId == mActivePointerId) {
                    int newPointIndex = actionIndex1 == 0 ? 1 : 0;
                    mLastY = (int) event.getY(newPointIndex);
                    mActivePointerId = event.getPointerId(newPointIndex);
                    if (mVelocityTracker != null) {
                        mVelocityTracker.clear();
                    }

                }

                break;
            default:
                Log.d(TAG, "event:" + event);
                break;
        }
        if (mVelocityTracker != null) {
            mVelocityTracker.addMovement(event);
        }

        return true;
    }

    @Override
    protected void onOverScrolled(int scrollX, int scrollY, boolean clampedX, boolean clampedY) {
        super.onOverScrolled(scrollX, scrollY, clampedX, clampedY);
        if (!mScroller.isFinished()) {
            if (clampedY) {
//                mScroller.springBack(mScrollX, mScrollY, 0, 0, 0, getScrollRange());

            }
        }else {
            super.scrollTo(scrollX, scrollY);
//            Log.d("ScrollTextView", "onOverScrolled");

        }

    }

    @Override
    public void computeScroll() {
        super.computeScroll();
        if (mScroller.computeScrollOffset()) {
            scrollBy(mScroller.getCurrX() - getScrollX() ,mScroller.getCurrY() - getScrollY());
            Log.d("ScrollTextView", "computescorll");
        }

        if (mOverScroller.computeScrollOffset()) {
            scrollTo(mOverScroller.getCurrX(),mOverScroller.getCurrY());
        }
    }

    /**
     *
     * @param startx
     * @param starty
     * @param xDistance
     * @param yDistance
     * @param time
     */
    //如果time 很大,那么你的距离要相应的大 不然可能不会滑动
    public void startScroll(int startx,int starty,int xDistance,int yDistance,int time ){
        //startScroll 要调用invalidate 请求重新绘制
        mScroller.startScroll(startx,starty,xDistance,yDistance,time);
        invalidate();
    }

    public void startScroll(int startx,int starty,int xDistance,int yDistance){
        //startScroll 要调用invalidate 请求重新绘制
//        mScroller.startScroll(startx,starty,xDistance,yDistance);
        mOverScroller.startScroll(startx,starty,xDistance,yDistance);
        ViewCompat.postInvalidateOnAnimation(this);
    }

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
    }

    /**
     * 不支持多点触控
     * 是因为event.getY() 返回的可能是任意的一个手指的位置
     * 你可以打印log 默认getY()返回第1个手指的位置  当你再放一个手指(第二个手指)到屏幕上
     * 然后松开 你会发现第getY() 返回的变成了第二个手指的位置  然后你再放一个手指,你会发现
     * getY()又变成了新的手指的位置
     *
     * 所以你在onTouchEvent 里面 ,ACTION_MOVE 的时候
     * 会有两个手指的落差  造成双指切换的时候 页面会来回跳动
     */
}