觸摸事件分發機制(有用的小技巧:事件二次分發)

存在的問題

在上文的下拉刷新控件中,有兩個問題

  1. 在下拉到ScrollView頂部時候,繼續往下拉時,並不會直接把頭佈局拉下來,而是需要把手鬆開後,再次下拉纔會拉下頭佈局,爲什麼?

上文說過,onInterceptTouchEvent方法雖然不是每次都被調用,但是如果子view在處理事件的時候,onInterceptTouchEvent是一直會調用的,因爲他要等待子View不想消費事件的時機出現時,交給自己處理觸摸事件,按理說這個問題裏,子View不想處理事件的時機已經到了,爲什麼父View沒馬上接受呢?

  1. 當刷新頭已經出現了,手指上滑 ,當把刷新頭完全隱藏了,繼續上滑,此時由於外層佈局攔截了事件,會導致把整個外層佈局往上滑,而我們想要的是此時讓內部的scrollview響應事件,滑動的是scrollview。

我們知道事件分發機制,一旦父view攔截了事件後,就不會把他交給子View了,所以從此之後,scrollview是不會再次有處理事件的機會的,那有什麼辦法,改良一下呢?

其實這些問題都指向了一個方法onInterceptTouchEvent,他的調用時機到底是什麼時候。我們只看僞代碼,因爲僞代碼是思路,是看完源碼後的精華,其實這個問題在 之前博客 說過了,但是他太重要了,所以還要總結一次。

事件分發精華僞代碼

先看事件分發的入口 dispatchTouchEvent僞代碼:
  public boolean dispatchTouchEvent(MotionEvent ev) {
        Boolean consume = false;
        if (【當前ViewGroup要不要攔截事件(ev)】) {
        //如果本ViewGroup攔截事件,那麼調用本ViewGroup的onTouchEvent
            consume = onTouchEvent(ev);
        } else {
        //否則,調用子View的dispatchTouchEvent,
        //如果子View還是一個ViewGroup的話,dispatchTouchEvent邏輯是一樣的,會迭代此邏輯
        //如果子View是一個View,那他的dispatchTouchEvent是不同的(稍後給出)
            consume = child.dispatchTouchEvent(ev);
        return consume;//返回true,表示事件被消耗了,
        // 如果是最裏層級的view或ViewGroup的dispatchTouchEvent返回true,表示由本ViewGroup來處理以後的事件

    }

注意【當前ViewGroup要不要攔截事件(ev)】是一個方法,方法名我寫成了中文名字,這個方法內部的邏輯,纔是重點。這個方法可不等同於直接調用onInterceptTouchEvent喲,而是圍繞onInterceptTouchEvent展開的一系列邏輯

【當前ViewGroup要不要攔截事件(ev)】僞代碼★相當重要:

public boolean 【當前ViewGroup要不要攔截事件(ev)】{
	 boolean intercepted;
	   if(DOWN事件 |或| 他的子View正在消費觸摸事件) {

	        if (子View請求父容器攔截) {
		      //子View請求父容器攔截就是指,子view通過parent.requestDisallowInterceptTouchEvent(false)
		      //★★★注意只有此種情況父容器的onInterceptTouchEvent纔會被調用
	  		     intercepted = onInterceptTouchEvent(ev);
	        } else {
	            //如果子View請求不攔截,那麼直接返回false,根本不需要走父容器的onInterceptTouchEvent方法
	            intercepted = false; 
	                }
	    } else {
	        //當這個事件不是ACTION_DOWN,並且當前的ViewGroup也沒有子ViewGroup(view)可以處理事件,那麼就由本ViewGroup直接攔截這個事件,也不需要走父容器的onInterceptTouchEvent方法
	        intercepted = true;
	    }
	    return intercepted;
	}

簡單說下requestDisallowInterceptTouchEvent(boolean)

注意是在子view裏通過,getParent().requestDisallowInterceptTouchEvent(boolean)來使用

- requestDisallowInterceptTouchEvent(false):
子view請求攔截,讓父view去詢問下自己的onInterceptTouchEvent方法,看看要不要攔截

- requestDisallowInterceptTouchEvent(true):
子view請求不要攔截,那就直接返回false,不去攔截
   

總結:

關鍵問題在於:【當前ViewGroup要不要攔截事件(ev)】的邏輯,雖然每次觸摸事件都會通過dispatchTouchEvent來調用到【當前ViewGroup要不要攔截事件(ev)】這個方法,但是這並不意味着onInterceptTouchEvent每次都會被調用
這個關鍵邏輯,我們能得出以下重要結論
onInterceptTouchEvent被調用的前提條件是:

  1. Down事件&&子View請求父View不要攔截
  2. 他的子View正在消費觸摸事件&&子View請求父View不要攔截

現在看看最上面拋出的問題:

  1. 在下拉到ScrollView頂部時候,繼續往下拉時,並不會直接把頭佈局拉下來,而是需要把手鬆開後,再次下拉纔會拉下頭佈局,爲什麼?

上文說過,onInterceptTouchEvent方法雖然不是每次都被調用,但是如果子view在處理事件的時候,onInterceptTouchEvent是一直會調用的,因爲他要等待子View不想消費事件的時機出現時,交給自己處理觸摸事件,按理說這個問題裏,子View不想處理事件的時機已經到了,爲什麼父View沒馬上接受呢?

爲什麼父View沒馬上接受呢?因爲在本例子裏,內部的ScrollView在某一個時機,調用了 requestDisallowInterceptTouchEvent(false)方法,即請求父view不要攔截,那麼根本不去走onInterceptTouchEvent方法,直接intercepted=false了,所以此時下拉不會落下刷新頭,而是在第二次下拉時,纔會訪問自己的onInterceptTouchEvent,發現滿足條件,就把刷新頭拉下來了(此時ScrollView還沒來得及調用requestDisallowInterceptTouchEvent(false)方法)

所以解決方法很簡單
在父容器裏重寫requestDisallowInterceptTouchEvent,讓

    @Override
    public void requestDisallowInterceptTouchEvent(boolean b) {
        // 去掉默認行爲,使得每個事件都會經過走一下這個佈局
    }

  1. 當刷新頭已經出現了,手指上滑 ,當把刷新頭完全隱藏了,繼續上滑,此時由於外層佈局攔截了事件,會導致把整個外層佈局往上滑,而我們想要的是此時讓內部的scrollview響應事件,滑動的是scrollview。

我們知道事件分發機制,一旦父view攔截了事件後,就不會把他交給子View了,所以從此之後,scrollview是不會再次有處理事件的機會的,那有什麼辦法,改良一下呢?

解決方法,就是在合適的時機,手動代碼造一個Down事件,並且分發此事件,因爲,這樣就突破了在一系列觸摸事件中,父容器攔截事件後,子View就沒機會再次處理事件的問題,因爲我們手動造出了第二次觸摸事件,一切從Down開始

    if (mLp.topMargin <= -mHeaderHeight && deltaY <0) {
        // 重新dispatch一次down事件,使得ScrollView可以繼續滾動
        int oldAction = event.getAction();
        event.setAction(MotionEvent.ACTION_DOWN);
        dispatchTouchEvent(event);
        event.setAction(oldAction);
    }

在這裏插入圖片描述

改良後的源碼

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
    }

    @Override
    public boolean onTouchEvent(MotionEvent event) {

        float y = event.getY();

        switch (event.getAction()) {
            case MotionEvent.ACTION_DOWN:

                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;

        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();

    }
}

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