多點觸摸處理

接着上文,我們做了一個簡陋的下拉刷新控件,目前用到的知識點有

  1. view的滑動
  2. view的彈性滑動
  3. 事件分發機制
  4. 事件分發機制的兩個小問題(事件的二次分發)

目前這個控件除了簡陋一點,沒做抽象封裝,在單手操作下,表現還是不錯的,但是多手操作試一下,頁面會產生位移突變。這就引出了本節的多點觸摸知識點

多點觸摸的原理明白後,一般只是用來處理多點觸摸所引起的bug,一般不會使用多點觸摸來處理縮放等高級的多點觸摸問題,因爲畢竟太麻煩了,我們有系統封裝好的GestureDetector、ScaleGestureDetector、GestureDetector.SimpleOnGestureListener

首先推薦這個大神的博客
安卓自定義View進階-MotionEvent詳解
安卓自定義View進階-多點觸控詳解
以及這個官方教程
拖拽與縮放

知識點

前奏開始了

假設大家已經認真閱讀了上邊的博客,下面列出必知必會的知識點:

  1. 多點觸控獲取事件類型請使用 getActionMasked()

  2. 每一根手指有兩個標記,index和pointId,

    index會隨着之前手指的擡起而發生變化,pointId不會發生變化

  3. 說一下多指觸摸下的事件流

    第一根手指按下: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

  4. 重要的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

在這裏插入圖片描述

  1. 前三步很好理解,依次按下三根手指,index和id依次遞增(注意第三根手指index爲2,id爲2)
  2. 第四步擡起了第二根手指,index和id均顯示1,也很正常
  3. 第五步按下第四根手指(重點1),index和id均顯示1,【他填補了第二根手指釋放的的index和id】
  4. 第六步,擡起第一根手指,index和id均顯示0,也算正常
  5. 第七步,擡起第三根手指(重點2),index爲1,id爲2,但是當第三根手指按下時,index爲2,id爲2,【發現index變了,但是id沒變】
  6. 第八步,擡起第四根手指,index爲0,id爲1,但是當第四根手指按下時,index爲1,id爲1,【同樣發現index變了,但是id沒變】

正是這種看似很奇怪的現象,導致我們追蹤每一根手指的行爲變得比較困難
觀察log得出的結論是:
要想追蹤手指,必須跟蹤id,index會隨着其他手指的擡起發生變化

這種變化,應該是,每次擡起一根手指,所有的手指的index中,比這根擡起的手指index大的都減去一,比他小的保持不變

應用:

如何跟蹤指定的一根手指,無論其他手指如何起起落落,我都要追蹤某一根手指。

大概思路:

  1. 每次down或ACTION_POINTER_DOWN時,通過index=event.getActionIndex()得到該手指的index,然後通過id=event.getPointerId(index)得到該手指的id,然後你記住這個id,存起來作比對用
  2. 然後現在要move了,你先得到手指數量count=event.getPointerCount(),再去遍歷所有手指,此時遍歷用到的是index,通過index得到id,看這個id是不是你要追蹤的id,如果是的話,記住對應的index,然後通過y = event.getY(curActiveIndex)得到座標信息,然後操作就行了
  3. 爲什麼上面,每次都是要找一次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處理原則是:

  1. 第一根手指按下滑動,頁面響應滑動事件
  2. 第二根手指按下滑動時,頁面響應滑動事件,但是此時第一根手指滑動不會導致頁面滑動
  3. 第三根手指按下滑動時,頁面響應滑動事件,但是此時第一根和第二根手指滑動都不會導致頁面滑動
  4. 第四根手指按下滑動時,頁面響應滑動事件,但是此時第一根、第二根和第三根手指滑動都不會導致頁面滑動
    結論:在多根手指依次按到頁面上時,追蹤的是最新的那根手指的滑動事件
  5. 現在頁面上有四根手指,鬆開第三根手指,發現,依然第四根手指控制滑動,其餘的手指滑動無效
  6. 現在頁面上有三根手指,鬆開第四根手指(也就是當前控制滑動的手指),發現,第一根手指控制滑動,其餘的手指滑動無效
  7. 結論:在多根手指按到頁面上時,如果松開的是非操控手指,那麼操控權依然是剛纔的操控手指,如果松開的是當前的操控手指,那麼把操控權,交給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();

    }
}

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章