接着上文,我們做了一個簡陋的下拉刷新控件,目前用到的知識點有
- view的滑動
- view的彈性滑動
- 事件分發機制
- 事件分發機制的兩個小問題(事件的二次分發)
目前這個控件除了簡陋一點,沒做抽象封裝,在單手操作下,表現還是不錯的,但是多手操作試一下,頁面會產生位移突變。這就引出了本節的多點觸摸知識點
多點觸摸的原理明白後,一般只是用來處理多點觸摸所引起的bug,一般不會使用多點觸摸來處理縮放等高級的多點觸摸問題,因爲畢竟太麻煩了,我們有系統封裝好的GestureDetector、ScaleGestureDetector、GestureDetector.SimpleOnGestureListener
首先推薦這個大神的博客
安卓自定義View進階-MotionEvent詳解
安卓自定義View進階-多點觸控詳解
以及這個官方教程
拖拽與縮放
知識點
前奏開始了
假設大家已經認真閱讀了上邊的博客,下面列出必知必會的知識點:
-
多點觸控獲取事件類型請使用 getActionMasked()
-
每一根手指有兩個標記,index和pointId,
index會隨着之前手指的擡起而發生變化,pointId不會發生變化
-
說一下多指觸摸下的事件流
第一根手指按下:ACTION_DOWN(0x00000000)
第二根手指按下:ACTION_POINTER_DOWN(0x00000105)
第三根手指按下:ACTION_POINTER_DOWN(0x00000205)
任意一根手指滑動:ACTION_MOVE(0x00000002)
第三根手指擡起:ACTION_POINTER_UP(0x00000206)
第二根手指擡起:ACTION_POINTER_UP(0x00000106)
第一根手指擡起:ACTION_UP(0x00000001)只有第一根手指按下會調用ACTION_DOWN,其餘手指按下,會調用ACTION_POINTER_DOWN,而且ACTION_POINTER_DOWN最後的105,5代表事件的類型(多指按下事件),那個1是該手指的index,同理205中的2代表第二根手指的index。
當手指move時候,沒有對應的事件類型,表明你在move哪根手指,都用(0x00000002)表示
當最後一根手指擡起時,纔會觸發ACTION_UP,其餘手指擡起來,觸發的是ACTION_POINTER_UP,事件類型爲6,前面的是手指index
-
重要的api
盜圖了,來自安卓自定義View進階-MotionEvent詳解
// 獲取index,在move時候,此方法無效,只能在ACTION_DOWN,
// ACTION_POINTER_DOWN,ACTION_POINTER_UP,
// ACTION_UP裏得到的index纔是有效的
int action_index = event.getActionIndex();
// 通過index得到該手指的id
int action_id = event.getPointerId(action_index);
// 得到事件類型
int action = event.getActionMasked();
// 得到指定索引手指的y座標
float y = event.getY(activeIndex);
這纔是最難理解的
關於多指操作時候,每一根手指的index和id的變化情況
我這裏通過log來演示,log不包含move的情況,因爲move不區分手指的index和id
- 前三步很好理解,依次按下三根手指,index和id依次遞增(注意第三根手指index爲2,id爲2)
- 第四步擡起了第二根手指,index和id均顯示1,也很正常
- 第五步按下第四根手指(重點1),index和id均顯示1,【他填補了第二根手指釋放的的index和id】
- 第六步,擡起第一根手指,index和id均顯示0,也算正常
- 第七步,擡起第三根手指(重點2),index爲1,id爲2,但是當第三根手指按下時,index爲2,id爲2,【發現index變了,但是id沒變】
- 第八步,擡起第四根手指,index爲0,id爲1,但是當第四根手指按下時,index爲1,id爲1,【同樣發現index變了,但是id沒變】
正是這種看似很奇怪的現象,導致我們追蹤每一根手指的行爲變得比較困難
觀察log得出的結論是:
要想追蹤手指,必須跟蹤id,index會隨着其他手指的擡起發生變化
這種變化,應該是,每次擡起一根手指,所有的手指的index中,比這根擡起的手指index大的都減去一,比他小的保持不變
應用:
如何跟蹤指定的一根手指,無論其他手指如何起起落落,我都要追蹤某一根手指。
大概思路:
- 每次down或ACTION_POINTER_DOWN時,通過
index=event.getActionIndex()
得到該手指的index,然後通過id=event.getPointerId(index)
得到該手指的id,然後你記住這個id,存起來作比對用 - 然後現在要move了,你先得到手指數量
count=event.getPointerCount()
,再去遍歷所有手指,此時遍歷用到的是index,通過index得到id,看這個id是不是你要追蹤的id,如果是的話,記住對應的index,然後通過y = event.getY(curActiveIndex)
得到座標信息,然後操作就行了 - 爲什麼上面,每次都是要找一次index,因爲你不能直接通過event得到id和座標值,必須通過index來得到,但是index又是不可靠的,老變化,不變的只有id,所以又要去比對id。
另一種思路,是每次當手指擡起時ACTION_POINTER_UP,去實時地算出你要追蹤的手指的index。因爲我們知道index小於擡起手指index的手指,index不變,大於的需要減去一,這樣你可以準確地直接追蹤index,再拿着index去都得到座標,id就不用管了
迴歸到本例,如何解決位移突變問題
現象:
當一根手指下拉到一定位置時,另外一根手指按到屏幕上,然後鬆開第一根手指,發現,位移突變了
原因:
先看看這兩個方法
float getY()
默認取index爲0的手指的座標
getY(int pointerIndex)
取出指定index的手指的座標
當我們一根手指下拉時(此刻這根手指的index爲0),getY(),獲取到的自然是這唯一的手指的座標,
當第二根手指按住屏幕時(此刻這根手指的index爲1),這根手指的Y座標與之前的手指座標裏的較遠,
此時第一根手指一鬆手,按照前面我們的分析,第二根手指的index馬上變爲0,那麼getY,就會取到這個手指的座標,然而他距離上一次的Y座標離得很遠了,所以deltaY=y-mLastY,deltaY會很大,導致位移突變
如何解決呢?
解決思路肯定是多點觸摸了,但是你要解決城什麼樣子呢?拿出你的手機,隨便翻出一個ScrollView或者RecyclerView,你多指觸摸,仔細看看,發現系統的View處理原則是:
- 第一根手指按下滑動,頁面響應滑動事件
- 第二根手指按下滑動時,頁面響應滑動事件,但是此時第一根手指滑動不會導致頁面滑動
- 第三根手指按下滑動時,頁面響應滑動事件,但是此時第一根和第二根手指滑動都不會導致頁面滑動
- 第四根手指按下滑動時,頁面響應滑動事件,但是此時第一根、第二根和第三根手指滑動都不會導致頁面滑動
結論:在多根手指依次按到頁面上時,追蹤的是最新的那根手指的滑動事件 - 現在頁面上有四根手指,鬆開第三根手指,發現,依然第四根手指控制滑動,其餘的手指滑動無效
- 現在頁面上有三根手指,鬆開第四根手指(也就是當前控制滑動的手指),發現,第一根手指控制滑動,其餘的手指滑動無效
- 結論:在多根手指按到頁面上時,如果松開的是非操控手指,那麼操控權依然是剛纔的操控手指,如果松開的是當前的操控手指,那麼把操控權,交給index爲0的手指,即第一根手指
ok,我們就來實現以下,上面的多指觸摸邏輯
代碼
源碼
package com.view.custom.dosometest.view;
import android.animation.ValueAnimator;
import android.content.Context;
import android.graphics.Color;
import android.util.AttributeSet;
import android.util.Log;
import android.view.MotionEvent;
import android.view.View;
import android.widget.LinearLayout;
import android.widget.ScrollView;
/**
* 描述當前版本功能
*
* @Project: DoSomeTest
* @author: cjx
* @date: 2019-12-01 10:06 星期日
*/
public class RefreshView extends LinearLayout {
private ScrollView mScrollView;
private View mHeader;
private int mHeaderHeight;
private MarginLayoutParams mLp;
public RefreshView(Context context) {
super(context);
init(context);
}
public RefreshView(Context context, AttributeSet attrs) {
super(context, attrs);
init(context);
}
public RefreshView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
init(context);
}
private void init(Context context) {
setBackgroundColor(Color.GRAY);
post(new Runnable() {
@Override
public void run() {
initView();// 因爲涉及到獲取控件寬高的問題,所以寫到post裏
}
});
}
private void initView() {
if (getChildCount() > 2) {
// 給刷新頭設置負高度的margin,讓他隱藏
mHeader = getChildAt(0);
mHeaderHeight = mHeader.getMeasuredHeight();
mLp = (MarginLayoutParams) mHeader.getLayoutParams();
mLp.topMargin = -mHeaderHeight;
mHeader.setLayoutParams(mLp);
// 得到第二個view,scrollView
View child1 = getChildAt(1);
if (child1 instanceof ScrollView) {
mScrollView = (ScrollView) child1;
}
}
}
float mLastY;
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
boolean intercept = false;
int x = (int) ev.getX();
int y = (int) ev.getY();
switch (ev.getAction()) {
case MotionEvent.ACTION_DOWN:
intercept = false;
break;
case MotionEvent.ACTION_MOVE:
int deltaY = (int) (y - mLastY);
if (needIntercept(deltaY)) {//外部攔截的模板代碼,只要重寫needIntercept方法邏輯就行
//注意當前ViewGroup一旦攔截,一次事件序列中就再也不會調用onInterceptTouchEvent了,
// 所以子View再也不會得到事件處理的機會了
// 爲了解決這個問題,就引出了《嵌套滑動》這個新的事物,見下文
intercept = true;
} else {
intercept = false;
}
break;
case MotionEvent.ACTION_UP:
intercept = false;
break;
default:
break;
}
mLastY = y;
return intercept;
}
private boolean needIntercept(int deltaInteceptY) {
// mScrollView已經下拉到最頂部&&你還在下來,那麼父容器攔截
if (!mScrollView.canScrollVertically(-1) && deltaInteceptY > 0) {
Log.e("ccc", "不能再往下拉了&&你還在往下拉,父佈局攔截,開始拉出刷新頭");
return true;
}
if (mLp.topMargin > -mHeaderHeight) {
Log.e("ccc", "只要頂部刷新頭,顯示着,就讓父佈局攔截");
return true;
}
return false;
}
@Override
public void requestDisallowInterceptTouchEvent(boolean b) {
// 去掉默認行爲,使得每個事件都會經過這個Layout
}
int curActiveId = 0;// 當前操作滑動的手指的id
int lastActiveId = 0;//上次操作滑動的手指的id
int curActiveIndex = 0;//當前操作滑動的手指的index
@Override
public boolean onTouchEvent(MotionEvent event) {
int count = event.getPointerCount();
// 避免索引越界,應該不會越界,判斷一下穩妥
curActiveIndex = (curActiveIndex >= count) ? count - 1 : curActiveIndex;
curActiveIndex = (curActiveIndex < 0) ? 0 : curActiveIndex;
Log.e("qqq", "curActiveIndex:" + curActiveIndex);
float y = event.getY(curActiveIndex);//得到操控手指的座標(只是關心操控手指)
curActiveId = event.getPointerId(curActiveIndex);
//下面判斷手指是不是同一個,必須用id,因爲index隨時會變的
if (curActiveId != lastActiveId) {//判斷當前操控手指id和上次操控手指id是不是一樣
mLastY = y;//★★★如果不一樣,馬上把此刻的y座標賦值給上次的y座標,這是避免位移突變的關鍵點
}
switch (event.getActionMasked()) {//一定要用getActionMasked
case MotionEvent.ACTION_DOWN:
break;
case MotionEvent.ACTION_POINTER_DOWN:
//新手指按下,讓它成爲控制手指,更新下當前的控制手指的index
curActiveIndex = event.getActionIndex();
break;
case MotionEvent.ACTION_POINTER_UP:
int upIndex = event.getActionIndex();
Log.e("qqq", "upIndex:" + upIndex + " curActiveIndex:" + curActiveIndex);
if (curActiveIndex > upIndex) {
// 如果當前控制手指的index>擡起的手指index,需要減去一(很關鍵,博客分析過)
curActiveIndex = curActiveIndex - 1;
} else if (curActiveIndex == upIndex) {
// 如果相等,說明你擡起來的就是操控手指,那麼變更操控手指爲第一根手指
curActiveIndex = 0;
}
break;
case MotionEvent.ACTION_MOVE:
float deltaY = y - mLastY;
// 防止刷新頭被無限制下拉,限定個高度
if (mLp.topMargin + deltaY > mHeaderHeight) {
deltaY = mHeaderHeight - mLp.topMargin;
}
// 動態改變刷新頭的topMargin
mLp.topMargin += (int) deltaY;
Log.e("ccc", "y:" + y + "mLastY:" + mLastY + "deltaY:" + deltaY + "mLp.topMargin:" + mLp.topMargin);
mHeader.setLayoutParams(mLp);
if (mLp.topMargin <= -mHeaderHeight && deltaY < 0) {
// 重新dispatch一次down事件,使得列表可以繼續滾動
int oldAction = event.getAction();
event.setAction(MotionEvent.ACTION_DOWN);
dispatchTouchEvent(event);
event.setAction(oldAction);
}
break;
case MotionEvent.ACTION_UP:
//鬆手後,看位置,如果過半,刷新頭全部顯示,沒過半,刷新頭全部隱藏
if (mLp.topMargin > -mHeaderHeight / 2) {
smoothChangeTopMargin(mLp.topMargin, 0);
} else {
smoothChangeTopMargin(mLp.topMargin, -mHeaderHeight);
}
break;
}
mLastY = y;
lastActiveId = curActiveId;//別忘了,更新上次的操控手指id
return true;
}
/**
* 使用屬性動畫平滑地過度topMargin
*
* @param start
* @param end
*/
private void smoothChangeTopMargin(int start, int end) {
ValueAnimator valueAnimator = ValueAnimator.ofInt(start, end);
valueAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator animation) {
mLp.topMargin = (int) animation.getAnimatedValue();
mHeader.setLayoutParams(mLp);
}
});
valueAnimator.setDuration(300);
valueAnimator.start();
}
}