Android View的事件分發機制與滑動沖突解決方案

在Android開發中,如果是一些簡單的布局,都很容易搞定,但是一旦涉及到復雜的頁面,特別是為了兼容小屏手機而使用了ScrollView以后,就會出現很多滑動事件的沖突,最經典的就是ScrollView中嵌套了ListView。今天主要總結一下這方面的知識點,也當作以后復習的筆記。

這里主要講述以下幾點:

  • View的事件分發機制
  • 事件滑動沖突的思路及方法
  • ScrollView里面嵌套ViewPager滑動沖突問題
  • ViewPager里面嵌套ViewPager滑動沖突問題
  • Scrollview里面嵌套Listview滑動沖突問題

View的事件分發機制

關于View的事件分發機制講解網上一搜一大堆,所以本文不細描述,而是讓你理解主要的運行機制,當然也不是只是自己描述一下就結束了,會提供具體的博客參考,指引你去更詳細的了解。
View的事件分發機制說白了就是點擊事件的傳遞,也就是一個Down事件,若干個Move事件,一個Up事件構成的事件序列的傳遞。
先看下Down事件和Up事件的分發流程走向:

事件傳遞流程.png

下面講述一下View事件分發機制涉及的幾個方法

  • boolean dispatchTouchEcent(MotionEvent ev)
  • boolean onInterceptTouchEvent(MotionEvent event)
  • boolean onTouchEvent(MotionEvent event)
  • public void requestDisallowInterceptTouchEvent(boolean disallowIntercept)

前三個方法的關系用下面偽代碼表示一下:

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

    return consum;
}

根據下面這幅圖逐個簡單介紹下上述的四個方法:

Paste_Image.png
  • dispatchTouchEcent:
    只要事件傳遞到了當前View,那么dispatchTouchEcent方法就一定會被調用,主要是用來分發事件的。返回結果表示是否消耗當前事件。
    ture:事件就此消費,不會繼續往別的地方傳了,事件終止。
    false:則回傳給父View的onTouchEvent事件處理。
  • onInterceptTouchEvent:
    在dispatchTouchEcent方法內部調用此方法,用來判斷是否攔截某個事件。如果當前View攔截了某個事件,那么在這同一個事件序列中,此方法不會再次被調用(需要注意的是ViewGroup才有這個方法,View沒有onInterceptTouchEvent這個方法)。返回結果表示是否攔截當前事件。
    true:攔截事件,則交給它的 onTouchEvent 來處理。
    false:不攔截該事件,傳遞給子 view ,由子 view 的 dispatchTouchEvent 再來開始這個事件的分發。
  • onTouchEvent:
    在dispatchTouchEcent方法內調用此方法,用來處理事件。返回結果表示是否處理當前事件。
    true:表示消費該事件。
    false:表示不處理,那么在同一個事件序列里面,當前View無法再收到后續的事件。
  • requestDisallowInterceptTouchEvent:
    該方法中的參數disallowIntercept的意思就是childView告訴父容器要不要進行攔截。
    true :告訴所有父控件不要攔截,事件交由childrenView處理;
    false:告訴所有父控件攔截。在父控件的onInterceptTouchEvent()中可能類似這樣的處理。

這里總結一下:(結合下圖看)
事件總是從上往下進行分發,即先到達Activity,再到達ViewGroup,再到達子View,如果沒有任何視圖消耗事件的話,事件會順著路徑往回傳遞。

app1.png

  1. 事件從Activity.dispatchTouchEvent()開始傳遞,只要沒有被停止或攔截,從最上層的View(ViewGroup)開始一直往下(子View)傳遞。子View 可以通過onTouchEvent()對事件進行處理。
  2. 事件由父View(ViewGroup)傳遞給子View,ViewGroup 可以通過onInterceptTouchEvent()對事件做攔截,停止其往下傳遞。
  3. 如果事件從上往下傳遞過程中一直沒有被停止,且最底層子View 沒有消費事件,事件會反向往上傳遞,這時父View(ViewGroup)可以進行消費,如果還是沒有被消費的話,最后會到Activity 的onTouchEvent()函數。
  4. 如果View 沒有對ACTION_DOWN 進行消費,之后的其他事件不會傳遞過來。
  5. OnTouchListener 優先于onTouchEvent()對事件進行消費。
    如果還不理解這幾個方法的用處,請參考博文圖解 Android 事件分發機制,一定要有耐心仔細看,相信看完之后你會相信事件并沒有白白浪費的。

滑動沖突解決方案

滑動沖突的基本形式分為兩種,其他復雜的滑動沖突都是由這兩種基本形式演變而來:

  1. 外部滑動方向與內部方向不一致。
  2. 外部滑動方向與內部方向一致。

第一種可以理解為ScrollView 嵌套ViewPager,第二種可以理解為ViewPager嵌套ViewPager,稍后提供具體解決方案。
根據《Android開發藝術探索》講述滑動沖突的攔截方法有兩種:

外部攔截法

從父View著手,重寫onInterceptTouchEvent方法,在父View需要攔截的時候攔截,不需要則不攔截返回false。其偽代碼如下:

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;
}

在這里,首先down事件父容器必須返回false ,因為若是返回true,也就是攔截了down事件,那么后續的move和up事件就都會傳遞給父容器,子元素就沒有機會處理事件了。其次是up事件也返回了false,一是因為up事件對父容器沒什么意義,其次是因為若事件是子元素處理的,卻沒有收到up事件會讓子元素的onClick事件無法觸發。

內部攔截法

從子View入手,重寫子元素的dispatchTouchEvent方法,父View先不要攔截任何事件,所有的 事件傳遞給 子View,如果子View需要此事件就消費掉,不需要此事件的話就通過requestDisallowInterceptTouchEvent方法交給父View處理。偽代碼如下:

@Override
 public boolean dispatchTouchEvent(MotionEvent event) {
     int x = (int) event.getX();
     int y = (int) event.getY();

     switch (event.getAction()) {
     case MotionEvent.ACTION_DOWN: {
         parent.requestDisallowInterceptTouchEvent(true);
         break;
     }
     case MotionEvent.ACTION_MOVE: {
         int deltaX = x - mLastX;
         int deltaY = y - mLastY;
         if (父容器需要此類點擊事件) {
             parent.requestDisallowInterceptTouchEvent(false);
         }
         break;
     }
     case MotionEvent.ACTION_UP: {
         break;
     }
     default:
         break;
     }

     mLastX = x;
     mLastY = y;
     return super.dispatchTouchEvent(event);
 }

然后修改父容器的onInterceptTouchEvent方法:

@Override
 public boolean onInterceptTouchEvent(MotionEvent event) {

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

ScrollView里面嵌套ViewPager滑動沖突問題

  • 外部攔截法
    如上面所述,從 父ViewScrollView著手,重寫 OnInterceptTouchEvent方法,在上下滑動的時候攔截事件,在左右滑動的時候不攔截事件,返回 false,這樣確保子View 的dispatchTouchEvent方法會被調用,代碼如下:
public class VerticalScrollView extends ScrollView {

    public VerticalScrollView(Context context) {
        super(context);
    }

    public VerticalScrollView(Context context, AttributeSet attrs) {
        super(context, attrs);
    }

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

    @TargetApi(21)
    public VerticalScrollView(Context context, AttributeSet attrs, int defStyleAttr, int
            defStyleRes) {
        super(context, attrs, defStyleAttr, defStyleRes);
    }

    private float mDownPosX = 0;
    private float mDownPosY = 0;

    @Override
    public boolean onInterceptTouchEvent(MotionEvent ev) {
        final float x = ev.getX();
        final float y = ev.getY();

        final int action = ev.getAction();
        switch (action) {
            case MotionEvent.ACTION_DOWN:
                mDownPosX = x;
                mDownPosY = y;

                break;
            case MotionEvent.ACTION_MOVE:
                final float deltaX = Math.abs(x - mDownPosX);
                final float deltaY = Math.abs(y - mDownPosY);
                // 這里是夠攔截的判斷依據是左右滑動,讀者可根據自己的邏輯進行是否攔截
                if (deltaX > deltaY) {
                    return false;
                }
        }

        return super.onInterceptTouchEvent(ev);
    }
}
  • 內部攔截法
    如上面上述,通過requestDisallowInterceptTouchEvent(true)方法來影響父View是否攔截事件,我們通過重寫ViewPager的 dispatchTouchEvent()方法,在左右滑動的時候請求父View ScrollView不要攔截事件,其他的時候攔截事件,代碼如下:
public class MyViewPager extends ViewPager {

    private static final String TAG = "MyViewPager ";

    int lastX = -1;
    int lastY = -1;

    public MyViewPager(Context context) {
        super(context);
    }

    public MyViewPager(Context context, AttributeSet attrs) {
        super(context, attrs);
    }

    @Override
    public boolean dispatchTouchEvent(MotionEvent ev) {
        int x = (int) ev.getRawX();
        int y = (int) ev.getRawY();
        int dealtX = 0;
        int dealtY = 0;

        switch (ev.getAction()) {
            case MotionEvent.ACTION_DOWN:
                dealtX = 0;
                dealtY = 0;
                // 保證子View能夠接收到Action_move事件
                getParent().requestDisallowInterceptTouchEvent(true);
                break;
            case MotionEvent.ACTION_MOVE:
                dealtX += Math.abs(x - lastX);
                dealtY += Math.abs(y - lastY);
                Log.i(TAG, "dealtX:=" + dealtX);
                Log.i(TAG, "dealtY:=" + dealtY);
                // 這里是夠攔截的判斷依據是左右滑動,讀者可根據自己的邏輯進行是否攔截
                if (dealtX >= dealtY) {
                    getParent().requestDisallowInterceptTouchEvent(true);
                } else {
                    getParent().requestDisallowInterceptTouchEvent(false);
                }
                lastX = x;
                lastY = y;
                break;
            case MotionEvent.ACTION_CANCEL:
                break;
            case MotionEvent.ACTION_UP:
                break;

        }
        return super.dispatchTouchEvent(ev);
    }
}

ViewPager里面嵌套ViewPager滑動沖突問題

內部攔截法:
從子View ViewPager著手,重寫 子View的 dispatchTouchEvent方法,在子 View需要攔截的時候進行攔截,否則交給父View處理,代碼如下:

public class ChildViewPager extends ViewPager {

    private static final String TAG = "ChildViewPager ";
    public ChildViewPager(Context context) {
        super(context);
    }

    public ChildViewPager(Context context, AttributeSet attrs) {
        super(context, attrs);
    }

    @Override
    public boolean dispatchTouchEvent(MotionEvent ev) {
        int curPosition;

        switch (ev.getAction()) {
            case MotionEvent.ACTION_DOWN:
                getParent().requestDisallowInterceptTouchEvent(true);
                break;
            case MotionEvent.ACTION_MOVE:
                curPosition = this.getCurrentItem();
                int count = this.getAdapter().getCount();
                Log.i(TAG, "curPosition:=" +curPosition);
                // 當當前頁面在最后一頁和第0頁的時候,由父親攔截觸摸事件
                if (curPosition == count - 1|| curPosition==0) {
                    getParent().requestDisallowInterceptTouchEvent(false);
                } else {//其他情況,由孩子攔截觸摸事件
                    getParent().requestDisallowInterceptTouchEvent(true);
                }

        }
        return super.dispatchTouchEvent(ev);
    }
}

Scrollview里面嵌套Listview滑動沖突問題

ScrollView里面嵌套ListView,通常會出現以下兩個問題:

  • ListView的高度顯示問題,常見的問題就是只顯示一行;
  • ScrollView和ListView都有上下滑動事件,放在一起會存在滑動沖突。

常用方案有如下三種:

  1. 自定義ListView
public class ListViewForScroll extends ListView
{
    public ListViewForScroll(Context context)
    {
        super(context);
    }
    public ListViewForScroll(Context context, AttributeSet attrs)
    {
        super(context, attrs);
    }
    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec)
    {
        intexpandSpec = MeasureSpec.makeMeasureSpec(Integer.MAX_VALUE >> 2,    
                  MeasureSpec.AT_MOST);
        super.onMeasure(widthMeasureSpec, expandSpec);
    }
}

主要就是重載了onMeasure方法,改變了heightMeasureSpec。這里widthMeasureSpec和heightMeasureSpec用了32位的int作為參數,高2位代表模式,有三種UNSPECIFIED、EXACTLY、AT_MOST,這是自定義View的基礎知識。低30位代表數值。
MeasureSpec.makeMeasureSpec函數中第一個參數是高度的值,第二個參數是模式,makeMeasureSpec則是把模式和值合成為一個int值,這里賦給了高度。
Integer.MAX_VALUE >> 2是int類型取30位時的最大整數,即Integer.MAX_VALUE是int的最大32位值,再右移2位,就是30位,同樣是最大值,只不過是30位的最大值,所以在模式上也只能選擇MeasureSpec.AT_MOST。最終這個ListView的顯示高度會是其能顯示出來的最大值,所有的條目都會顯示出來。
優點:寫法簡單,不影響ListView使用。
缺點:
i. 由于高度設置成最大值,所有條目都會進行繪制,只是有些條目會在屏幕之外。舉個例子,我傳遞的數據有20條,但是屏幕只夠顯示10條,此時用自定義的ListView會調用20次getView把所有條目都繪制出來,完全放棄了ListView的復用機制,跟直接寫布局沒有什么區別了,會造成頁面加載速度緩慢的問題。
ii. ListView高度必須設置成match_parent。

  1. 動態測量ListView高度
public static void setListViewHeightBasedOnChildren(ListView listView) {
    ListAdapter listAdapter = listView.getAdapter();
    if (listAdapter == null) {
        return;
    }
    int totalHeight = 0;
    for (int i = 0; i < listAdapter.getCount(); i++) {
        View listItem = listAdapter.getView(i, null, listView);
        listItem.measure(0, 0);
        totalHeight += listItem.getMeasuredHeight();
    }
    ViewGroup.LayoutParams params = listView.getLayoutParams();
    params.height = totalHeight
            + (listView.getDividerHeight() * (listAdapter.getCount() - 1));
    listView.setLayoutParams(params);
}

這里就是去獲取每個條目的View高度,然后所有子View高度相加得到總高度,并設置給ListView的LayoutParams。
優點:能夠實現功能需求。
缺點:
i. 每個條目的布局只能用LinearLayout,而不能用RelativeLayout,因為LinearLayout重寫了onMeasure方法,才能調用listItem.measure(0, 0)這句,而其他布局沒有。
ii. ListView高度必須設置成match_parent。
iii. 在ListView設置Adaper和調用notifyDataSetChanged時候都要調用該方法。
iv. 由于高度設置成最大值,所有條目都會進行繪制,跟第一個方法“自定義ListView”存在同樣的問題。

  1. 第三是自定義LinearLayout模擬ListView。
    public class LinearLayoutListView extends LinearLayout
      {
          private BaseAdapter adapter;
          private MyOnItemClickListener onItemClickListener;
          boolean footerViewAttached = false;
          private View footerview;
          public LinearLayoutListView(Context context)
          {
              super(context);
              initAttr(null);
          }
    
          public LinearLayoutListView(Context context, AttributeSet attrs)
          {
              super(context, attrs);
              initAttr(attrs);
          }
    
          public void initAttr(AttributeSet attrs)
          {
              setOrientation(VERTICAL);
          }
    
          /**
           * 初始化footerview
           *
           * @param footerView
           */
          public void initFooterView(final View footerView)
          {
              this.footerview = footerView;
          }
    
          /**
           * 設置footerView監聽事件
           *
           * @param onClickListener
           */
          public void setFooterViewListener(OnClickListener onClickListener)
          {
              this.footerview.setOnClickListener(onClickListener);
          }
    
          public BaseAdapter getAdapter()
          {
              return adapter;
          }
    
          /**
           * 設置adapter并模擬listview添加????數據
           *
           * @param adpater
           */
          public void setAdapter(BaseAdapter adpater)
          {
              this.adapter = adpater;
              removeAllViews();
              if (footerViewAttached)
                  addView(footerview);
              notifyChange();
          }
    
          /**
           * 設置條目監聽事件
           *
           * @param onClickListener
           */
          public void setOnItemClickListener(MyOnItemClickListener onClickListener)
          {
              this.onItemClickListener = onClickListener;
          }
    
          /**
           * 沒有下一頁了
           */
          public void noMorePages()
          {
              if (footerview != null && footerViewAttached)
              {
                  removeView(footerview);
                  footerViewAttached = false;
              }
          }
    
          /**
           * 可能還有下一??
           */
          public void mayHaveMorePages()
          {
              if (!footerViewAttached && footerview != null)
              {
                  addView(footerview);
                  footerViewAttached = true;
              }
          }
    
          /**
           * 通知更新listview
           */
          public void notifyChange()
          {
              int count = getChildCount();
              if (footerViewAttached)
              {
                  count--;
              }
              LayoutParams params = new LayoutParams(LayoutParams.FILL_PARENT, LayoutParams.WRAP_CONTENT);
              for (int i = count; i < adapter.getCount(); i++)
              {
                  final int index = i;
                  final LinearLayout layout = new LinearLayout(getContext());
                  layout.setLayoutParams(params);
                  layout.setOrientation(VERTICAL);
                  View v = adapter.getView(i, null, null);
                  v.setOnClickListener(new OnClickListener()
                  {
                      @Override
                      public void onClick(View v)
                      {
                          if (onItemClickListener != null)
                          {
                              onItemClickListener.onItemClick(LinearLayoutListView.this, layout, index,
                                      adapter.getItem(index));
                          }
                      }
                  });
                  ImageView imageView = new ImageView(getContext());
                  imageView.setBackgroundResource(R.color.background);
                  imageView.setLayoutParams(params);
                  layout.addView(v);
                  layout.addView(imageView);
                  addView(layout, index);
              }
          }
          public static interface MyOnItemClickListener
          {
              public void onItemClick(ViewGroup parent, View view, int position, Object o);
          }
      }
    

生硬的實現了ListView的基礎功能,但是ListView的復用機制完全沒有,跟直接寫布局有何區別。
優點:能夠實現功能需求。
缺點:
i. ListView高度要設置成match_parent
ii. 由于高度設置成最大值,所有條目都會進行繪制,跟“自定義ListView”存在同樣的問題。

另外推薦解決滑動沖突方案的博文:
【Android】ListView、RecyclerView、ScrollView里嵌套ListView 相對優雅的解決方案:NestFullListView

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 227,533評論 6 531
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,055評論 3 414
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事?!?“怎么了?”我有些...
    開封第一講書人閱讀 175,365評論 0 373
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,561評論 1 307
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,346評論 6 404
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 54,889評論 1 321
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 42,978評論 3 439
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,118評論 0 286
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,637評論 1 333
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,558評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,739評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,246評論 5 355
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 43,980評論 3 346
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,362評論 0 25
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,619評論 1 280
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,347評論 3 390
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,702評論 2 370

推薦閱讀更多精彩內容