這可能是最"俗"的View的事件分發機制(含Demo講解)

weixin_33797791發表於2017-11-04

本文將通過三個demo來讓你深刻感受到了解View的事件分發機制之後你能做什麼,能做好什麼!!

5815336-e0b94dd75e235f0e.png

首先我們裝作這些概念都理解了:(下文詳細介紹)

觸控事件型別: 主要型別三種:
ACTION_DOWN
ACTION_MOVE
ACTION_UP

完整的事件傳遞主要包括三個階段: 事件的分發,攔截和消費

分發:對應dispatchTouchEvent方法。返回true表示事件被當前檢視消費,不再繼續分發

攔截:對應onInterceptTouchEvent方法。返回true表示攔截此事件,不繼續分發。(viewGroup和其子類中才擁有)
消費:對應onTouchEvent方法,返回true表示消費事件,不在向上傳遞。

view的事件傳遞機制:
—— 觸控事件的傳遞流程是從dispatchTouchEvent開始的,如果我們不進行重寫(也就是返回預設的父類同名函式),則事件將會依照巢狀層次從外層向內層傳遞,到底最內層的View時,就交給它的onTouchEvent處理,該方法如果能消費該事件,則返回true,如果處理不了,則返回false,這時事件會重新向外傳遞。並由外層的onTouchEvent處理,依此類推

—— 如果事件在向內層傳遞的過程中被我們重寫事件處理函式返回true時,則會導致整個事件提前被消費,內層View不會收到這個事件了。

—— View控制元件的事件觸發順序是先執行onTouch方法,最後才執行onClick方法。如果onTouch方法返回true的話,則事件將不會繼續傳遞,最後也不會呼叫onClick方法,如果onTouch返回false,則繼續向下傳遞。因為button的preformClick是利用onTouchEvent實現的,假設onTouchEvent沒有被呼叫到,那麼點選事件就無效了。

viewGroup的事件傳遞機制:

—— 觸控事件的傳遞順序是由Activity到ViewGroup,再由ViewGrop遞迴傳遞給它的子View。

—— ViewGroup通過onInterceptTouchEvent方法對事件進行攔截,如果該方法返回true,則事件不會繼續傳遞給子View,如果返回false或者super.onInterceptTouchEvent,則事件會繼續傳遞給子View。

—— 在子View對事件進行消費後,ViewGroup將接收不到任何事件。

5815336-c122129316119a17.png

臥槽,這些概念我在別的地方也看到過呀,你這也不是就bibi一些概念嗎,可是到底怎麼用,用在哪裡呢,這些概念表達的意思又到底是個啥呀?

我們們舉個例子哈,在一個美好的早晨,一家子人都起來啦,開啟門迎接美好的一天,突然天上掉下一個餡餅,還是金的,掉在你的祖爺爺面前。(金餡餅就是我們的事件),這時大家就聚在一起啊,你的祖爺爺(activity)輩分最大,餡餅也是掉他那的,先擁有這個餡餅的分配權,你的祖爺爺非常愛你們,他說這餡餅啊,我都不久於人世了用不著,留給我的寶貝兒子吧(ViewGroup),他兒子不就是你爺爺嗎,你爺爺也愛你爸啊,就又給了你爸,你爸最後給了你,這時你就開心了,我拿到了這個金餡餅,那我是留著還是留著?這時你非常激動啊,你想著自己還沒娶媳婦,你就說那恭敬不如從命了,你拿著餡餅娶了一個漂亮能幹的媳婦(消費掉了事件,onTouchEvent返回true),那這個事情就結束了。當然,還有一種情況,你已經有漂亮媳婦了,不需要了,你覺得應該孝順長輩,你又跟你爸說,我不用啊,這金餅給我也沒用,我有大金鍊子,你身體不好自己拿著看大夫吧。然後你爸拿著一想孩子說的沒錯,就自己拿去治病了(消費掉了)。
一個餡餅由一次觸控事件的ACTION_DOWN開始,最先拿到的是Activity(Window),然後一層一層往下分發(dispatchTouchEvent),如果有誰需要拿到這個金餡餅乾啥,他就攔截掉(onInterceptTouchEvent),那麼備份最小的你(View)就根本摸不到這個金餡餅了,如果沒有ViewGroup(比你輩分大的爺爺,爸爸等)攔截,都想給你娶媳婦,,那麼你就拿到了這個金餡餅,先呼叫你的onTouchEvent事件,你確實不需要啊,然後又一層一層返回去,一層層呼叫onTouchEvent,看誰需要,大致就這麼一個邏輯。

5815336-e00bf47e4f56c49b.png

哇靠,你這麼說我似乎優點明白了,但我們能不拿這個餡餅說事嗎,我開發又不是寫餡餅,能不拿舉個別的例子啊。好的,客官你別急,這就給你上菜。

滑動衝突想必是在開發中老生常談的問題了,只要我們內部View和外部View都能滑動,那麼必定就會存在滑動衝突,我們想要處理的話,就需要用到我們的事件分發知識啦。而通常我們處理滑動衝突分為兩種,分別叫做外部攔截法和內部攔截法,比如我們一個ViewPager中巢狀了一個RecycleView或ListView,滑動時非常的不爽,安卓並不知道是具體誰要處理這個事件,金餅就一塊,我到底給誰啊,你們好幾個人都要。那我肯定需要新增一些條件了,看看到底是給誰啊,外部攔截就是重寫父容器的onInterceptTouchEvent()方法,因為這塊金餅先到的還是長輩手裡,這個時候你就要 處理好啊,我到底是留給自己處理ViewPager的左右滑動呢,還是處理ListView的上下滑動呀,你只需要比較在X軸和Y軸移動的距離,如果X軸大於Y,那就是左右滑動,就把這塊金餅直接攔截掉消費了,就不給ListView了,如果X小於Y,那就是上下滑動了,你就不攔截,把金餅給ListView消費。

 public boolean onInterceptTouchEvent(MotionEvent event) {
boolean intercepted = false;
int x = (int)event.getX();
int y = (int)event.getY();
switch (event.getAction()) {
    case MotionEvent.ACTION_DOWN: {
        intercepted = false;
       break;
   }
   case MotionEvent.ACTION_MOVE: {
       if (滿足父容器的攔截要求) {
            intercepted = true;
       } else {
            intercepted = false;
       }
       break;
   }
   case MotionEvent.ACTION_UP: {
       intercepted = false;
       break;
   }
   default:
       break;
   }
        mLastXIntercept = x;
        mLastYIntercept = y;
        return intercepted;}

以上是外部攔截法的模板程式碼,針對不同的滑動衝突,只需要修改父容器需要攔截當前事件這個條件即可,其他均不需做修改並且也不能修改,在onInterceptTouchEvent方法中,首先是ACTION_DOWN這個事件,父容器絕大部分情況下必須返回false,即不攔截ACTION_DOWN事件,這是因為一旦父容器攔截了ACTION_DOWN,那麼後續的ACTION_MOVE和ACTION_UP事件都會直接交給父容器處理,這個時候事件沒法再傳遞給子元素了;其次是ACTION_MOVE事件,這個事件可以根據需要來決定是否攔截,如果父容器需要攔截就返回true,否則返回false;最後是ACTION_UP事件,這裡必須要返回false,因為ACTION_UP事件本身沒有太多意義。

5815336-702cb3f34c664c94.png
重點!!!

臥槽,那玩意我是一個ViewPager巢狀一個ViewPager呢,兩個都是水平方向的滑動,這個我要怎麼判斷啊,這個.....這個貌似這種方式行不通吧,好像比較難啊,怎麼去獲取判斷的條件啊,依據是什麼呀。

5815336-7dea4ce710960119.png
ViewPager巢狀

不要怕不要驚慌啊,我們肯定是可以解決的。我們外部攔截法行不通有沒有內部攔截法,自然是有的,內部攔截法其實就是重寫子元素的dispatchTouchEvent()方法,並呼叫getParent().requestDisallowInterceptTouchEvent(true)父容器不能攔截子元素需要的事件。用我們的餡餅來說就是不管有多少長輩(ViewGroup父容器),餡餅都應該是先給你的(子元素),你擁有燒餅的最先處理權,如果你需要消費它那你就直接消費掉,不需要再交給父容器處理。但是我們事件dispatchTouchEvent是由父輩們一層一層分發下來的,萬一哪個中間擺你一道,把餡餅拿去花掉了呢,為了預防這種情況,我們就需要配合getParent().requestDisallowInterceptTouchEvent(true)來事先通知他們不可以攔截。

首先是子元素的dispatchTouchEvent方法:

public boolean dispatchTouchEvent(MotionEvent event) {
...

switch (action) {
  case MotionEvent.ACTION_MOVE:
        getParent().requestDisallowInterceptTouchEvent(true);

    break;
  case MotionEvent.ACTION_MOVE:
    if(父容器需要處理此事件)
          getParent().requestDisallowInterceptTouchEvent(false);


    break;
  case MotionEvent.ACTION_UP: {
    break;
}
...
return super.dispatchTouchEvent(event); }

這事件我們還要修改父容器的onInterceptTouchEvent()方法,程式碼如下:

@Override
  public boolean onInterceptTouchEvent(MotionEvent ev) {

  int action=ev.getAction();
  if(action==MotionEvent.ACTION_DOWN){
    return false;
  }else {
    return true;
  }
}

父容器攔截了除了DOWN事件以外的其他事件,這樣當子元素呼叫parent.requestDisallowInterceptTouchEvent(false)方法時,父元素才能繼續攔截所需的事件。當返回true時,不分發到子元素,並執行自己的onTouch方法。onInterceptTouchEvent()方法預設是不攔截的,所以我們需要考慮到,當子元素不處理時,我們需要父元素(外層ViewPager來處理),所以我們才會重寫父容器的onInterceptTouchEvent方法。

5815336-a776e85d6bda1a44.png

現在相信大家對於安卓Touch事件有了一個相對還比較清晰的瞭解了,至少知道他們的一個事件流向,分發,攔截以及消費,這裡在安卓開發探索一書中總結得特別好,大致如下:

1:同一個事件序列是指手機接觸螢幕那一刻起,到離開螢幕那一刻結束,有一個down事件,若干個move事件,一個up事件構成。

2:某個View一旦決定攔截事件,那麼這個事件序列之後的事件都會由它來處理,並且不會再呼叫onInterceptTouchEvent。

3:正常情況下,一個事件序列只能被一個View攔截並消耗。這個原因可以參考第2條,因為一旦攔截了某個事件,那麼這個事件序列裡的其他事件都會交給這個View來處理,所以同一事件序列中的事件不能分別由兩個View同時處理,但是我們可以通過特殊手段做到,比如一個View將本該自己處理的事件通過onTouchEvent強行傳遞給其他View處理。

4:一個View如果開始處理事件,如果它不處理down事件(onTouchEvent裡面返回了false),那麼這個事件序列的其他事件就不會交給它來繼續處理了,而是會交給它的父元素去處理。

5:如果一個View處理了down事件,卻沒有處理其他事件,那麼這些事件不會交給父元素處理,並且這個View還能繼續受到後續的事件。而這些未處理的事件,最終會交給Activity來處理。

6:ViewGroup的onInterceptToucheEvent預設返回false,也就是預設不攔截事件。

7:View沒有InterceptTouchEvent方法,如果有事件傳過來,就會直接呼叫onTouchEvent方法。

8:View的onTouchEvent方法預設都會消耗事件,也就是預設返回true,除非他是不可點選的(longClickable和clickable同時為false)。

9:View的enable屬性不會影響onTouchEvent的預設返回值。就算一個View是不可見的,只要他是可點選的(clickable或者longClickable有一個為true),它的onTouchEvent預設返回值也是true。

10:onClick方法會執行的前提是當前View是可點選的,並且它收到了down和up事件。

11:事件傳遞過程是由外向內的,也就是事件會先傳給父元素在向下傳遞給子元素。但是子元素可以通過requestDisallowInterceptTouchEvent來干預父元素的分發過程,但是down事件除外(因為down事件方法裡,會清除所有的標誌位)。

我們用虛擬碼表示一下分發,攔截和消費的關係:

  public boolean dispatchTouchEvent(MotionEvent ev){
    boolean consume = false;
    if (onInterceptTouchEvent(ev)){
        consume = onTouchEvent(ev);
    }else {
        consume = child.dispatchTouchEvent(ev);
    }
    return consume
}

如果以上知識,你基本瞭解清楚了,那麼針對各種情況下的滑動衝突,你都能處理了,只是判斷的邏輯不同而已。這個就需要自己多加練習了,最近我也會不斷補強這一塊的知識,在實踐中不斷成長。

那麼本文到此就結束了嗎,View的事件分發機制就是用來處理滑動衝突的嗎,隨便這已經很了不起了但他能做的遠遠不止這些。

5815336-a42b20e399268d01.png

當我們的設計師用想象力衝破天機的思維告訴你想要什麼什麼樣的互動效果,我的內心是崩潰的

5815336-d0f6f90a5239cc66.png

那麼通常這樣的互動效果需要我們時刻追蹤著使用者在螢幕上的一舉一動,然後獲取到使用者操作的座標,通過動畫等產生特定的效果,讓我們的使用者有一個爽歪歪的互動體驗。

5815336-f27643c1b348a922.png

這裡我們就實現一個最簡單的下拉頭部圖片放大,鬆手時自動回彈的ScrollView吧。

5815336-1109be00fc99edf0.gif
回彈.gif

臥槽我也不知道為什麼錄製的gif這麼小,反正大概就這樣子。

拿到這樣一個需求我們首先要分析他,解剖一下。

(1)下拉時頭部變大
(2)鬆手後回彈,頭部回覆大小
那麼這兩個需求,可能用到哪些知識點來實現呢?
1:記錄下拉的值,下拉越大,頭部倍數越大,在哪裡一直記錄這個下拉值,自然就是我們今天學的咯,我們可以在dispatchTouchEvent或者onTouchEvent中獲取到我們觸控點的座標
2:View頭部的變大和回彈,需要動畫來達到一個流暢順滑有彈性的效果,而我們的補間程式碼似乎無法滿足此類要求,所以我們需要考慮用到一個ValueAnimator動畫,通過改變View物件的屬性來實現動畫效果。
3:放大應該有一個最大倍數,不可能無限放大,那太難看了
4:自定義ScrollView的話,我們需要獲取到ScrollView中的頭部這個View
5:縮放的話我們應該通過設定頭部LayoutParam改變

下面我就直接貼程式碼了,大家可以自己參考一下:

public class PullBackScrollView extends ScrollView {

private View mHeaderView;
private int mHeaderWidth;
private int mHeaderHeight;

// 是否正在下拉
private boolean mIsPulling;

private int mLastY;

// 最大的放大倍數
private float mScaleTimes = 2.0f;
// 滑動放大係數:係數越大,滑動時放大程度越大
private float mScaleRatio = 0.4f;
// 回彈時間係數:係數越小,回彈越快
private float mReplyRatio = 0.5f;

// 當前座標值
private float currentX = 0;
private float currentY = 0;
// 移動座標值
private float distanceX = 0;
private float distanceY = 0;
// 最後座標值
private float lastX = 0;
private float lastY = 0;
// 上下滑動標記
private boolean upDownSlide = false;

public static final String TAG = "PullBackScrollView";


public PullBackScrollView(Context context) {
    this(context, null);
}

public PullBackScrollView(Context context, AttributeSet attrs) {
    this(context, attrs, 0);
}

public PullBackScrollView(Context context, AttributeSet attrs, int defStyleAttr) {
    super(context, attrs, defStyleAttr);
}

@Override
protected void onFinishInflate() {
    super.onFinishInflate();
    // 設定不可過度滾動,否則上移後下拉會出現部分空白的情況
    setOverScrollMode(OVER_SCROLL_NEVER);
    View child = getChildAt(0);
    if (child != null && child instanceof ViewGroup) {
        // 獲取預設第一個子View
        ViewGroup vg = (ViewGroup) getChildAt(0);
        if (vg.getChildAt(0) != null) {
            mHeaderView = vg.getChildAt(0);//此時headView為activity_header.xml中的RelativeLayout

        }
    }
}

@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
    super.onSizeChanged(w, h, oldw, oldh);
    mHeaderWidth = mHeaderView.getMeasuredWidth();
    mHeaderHeight = mHeaderView.getMeasuredHeight();
}

//重寫事件分發
@Override
public boolean dispatchTouchEvent(MotionEvent ev) {

    currentX = ev.getX();
    currentY = ev.getY();

    switch (ev.getAction()) {
        case MotionEvent.ACTION_MOVE:
            distanceX = currentX - lastX;
            distanceY = currentY - lastY;
            if (Math.abs(distanceX) < Math.abs(distanceY) && Math.abs(distanceY) > 12) {
                upDownSlide = true;
            }
            break;
    }

    lastX = currentX;
    lastY = currentY;

    if (upDownSlide && mHeaderView != null) {
        commOnTouchEvent(ev);
    }
    return super.dispatchTouchEvent(ev);
}

/**
 * @Description 觸控事件
 */
private void commOnTouchEvent(MotionEvent ev) {
    switch (ev.getAction()) {
        case MotionEvent.ACTION_UP:
            // 手指離開後頭部恢復圖片
            mIsPulling = false;
            replyView();
            clear();
            break;
        case MotionEvent.ACTION_MOVE:
            if (!mIsPulling) {
                // 第一次下拉
                if (getScrollY() == 0) {
                    // 滾動到頂部時記錄位置,否則正常返回
                    mLastY = (int) ev.getY();
                } else {
                    break;
                }
            }

            int distance = (int) ((ev.getY() - mLastY) * mScaleRatio);
            // 當前位置比記錄位置要小時正常返回
            if (distance < 0) {
                break;
            }
            mIsPulling = true;
            setZoom(distance);
            break;
    }
}

/**
 * @Description 頭部縮放
 */
private void setZoom(float s) {
    float scaleTimes = (float) ((mHeaderWidth + s) / (mHeaderWidth * 1.0));
    // 如超過最大放大倍數則直接返回
    if (scaleTimes > mScaleTimes) {
        return;
    }
    ViewGroup.LayoutParams layoutParams = mHeaderView.getLayoutParams();
    layoutParams.width = (int) (mHeaderWidth + s);
    layoutParams.height = (int) (mHeaderHeight * ((mHeaderWidth + s) / mHeaderWidth));
    // 設定控制元件水平居中
    ((MarginLayoutParams) layoutParams).setMargins(-(layoutParams.width - mHeaderWidth) / 2, 0, 0, 0);
    mHeaderView.setLayoutParams(layoutParams);
}

/**
 * @Description 回彈動畫
 */
private void replyView() {
    final float distance = mHeaderView.getMeasuredWidth() - mHeaderWidth;
    // 設定動畫
    ValueAnimator anim = ObjectAnimator.ofFloat(distance, 0.0F).setDuration((long) (distance * mReplyRatio));
    anim.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
        @Override
        public void onAnimationUpdate(ValueAnimator animation) {
            setZoom((Float) animation.getAnimatedValue());
        }
    });
    anim.start();
}

/**
 * @Description 清除屬性值
 */
private void clear() {
    lastX = 0;
    lastY = 0;
    distanceX = 0;
    distanceY = 0;
    upDownSlide = false;
}}

在xml中引用我們的控制元件:

  <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
tools:context="com.example.pz.zoomscrollview.MainActivity">

<com.example.pz.zoomscrollview.PullBackScrollView
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:orientation="vertical">

        <include layout="@layout/activity_header" />

        <include layout="@layout/activity_content" />

    </LinearLayout>

</com.example.pz.zoomscrollview.PullBackScrollView></LinearLayout>

xml中的程式碼我就不全部貼出來了,自己隨便寫點啥都可以,喜歡美女的放張美女背景圖,喜歡跑車的放跑車。

5815336-73c93a6634ab9a4d.png
這裡自己可以只放一張圖片

按道理來說Activity程式碼是什麼都不需要寫的,你在xml中引用了自定義就好了,這裡的話為了視覺效果,實現了一下沉浸式狀態,就也貼出來參考一下實現。

public class MainActivity extends AppCompatActivity {

@Override
protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.activity_main);
    fullScreen(this);
}

private void fullScreen(Activity activity) {
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
            //5.x開始需要把顏色設定透明,否則導航欄會呈現系統預設的淺灰色
            Window window = activity.getWindow();
            View decorView = window.getDecorView();
            //兩個 flag 要結合使用,表示讓應用的主體內容佔用系統狀態列的空間
            int option = View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
                    | View.SYSTEM_UI_FLAG_LAYOUT_STABLE;
            decorView.setSystemUiVisibility(option);
                     window.addFlags(WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS);
            window.setStatusBarColor(Color.TRANSPARENT);
            //導航欄顏色也可以正常設定
//                window.setNavigationBarColor(Color.TRANSPARENT);
        } else {
            Window window = activity.getWindow();
            WindowManager.LayoutParams attributes = window.getAttributes();
            int flagTranslucentStatus = WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS;
            int flagTranslucentNavigation = WindowManager.LayoutParams.FLAG_TRANSLUCENT_NAVIGATION;
            attributes.flags |= flagTranslucentStatus;
//                attributes.flags |= flagTranslucentNavigation;
            window.setAttributes(attributes);
        }
    }
}}

本文可能有一些知識點並未闡述得特別詳細,由於本人水平有限,也會在最近一段時間不短的學習相關知識以及更新一些相關文章,大家一起成長。

相關文章