【朝花夕拾】Android自定義View篇之(七)Android事件分發機制(下)滑動衝突解決方案總結

前言

       轉載請聲明,轉自【https://www.cnblogs.com/andy-songwei/p/11072989.html】,謝謝!

       前面兩篇文章,花了很大篇幅講解了Android的事件分發機制的原理性知識。然而,“紙上得來終覺淺,絕知此事要躬行”,前面講的那些原理,也都是為解決實際問題而服務的。本文將結合實際工作中經常遇到的滑動衝突案例,總結滑動衝突的場景以及解決方案。本文的主要內容如下:

 

一、滑動衝突簡介

       滑動組合在平時的UI開發中非常常見,比如下圖中某App界面(圖片來源:https://www.jb51.net/article/90032.htm),該頁面上半部分显示商品列表,而下半部分显示頁面導航。當滑動上面的列表時,列表部分滑動;當列表滑動到底或者滑動下半部分時,整個頁面一起滑動。

       但是在平時的開發中,可能會經常遇到這樣的場景,滑動列表部分時,整個頁面一起滑動,而不是只滑動列表內容。或者一會兒是列表滑動,一會兒是整個頁面滑動,而不是按照預期的要求來滑動。這就是我們常說的滑動衝突問題。滑動衝突的問題,經常讓開發者們頭痛不已。因為經常很多滑動相關的控件,如ScrollView、ListView等,在單獨使用的時候酷炫不已,但將他們組合在一起使用,就失靈了。比如上圖中,手指在屏幕上上下滑動,列表和整個頁面都有滑動功能,此時如果處理不當,就會導致系統也不知道要讓誰來消費這個滑動事件,這就是滑動衝突產生的原因。

 

二、滑動衝突的三種場景

       儘管實際工作中滑動衝突的場景看似各種各樣,但最終可以歸納為三種,如下圖所示:1)圖一:外部滑動和內部滑動方向不一致;2)圖二:外部滑動和內部滑動方向不一致;3)圖三:多層滑動疊加。

 

  1、外部滑動和內部滑動方向不一致

       圖一中只示意了外部為左右滑動,內部為上下滑動的場景。顯然,內外滑動不一致,還包括外部為上下滑動,內部為左右滑動的場景。對於這種場景,平時工作中最常見的使用大概是外層為PageView,內層為一個Fragment+ListView/RecyclerView了。慶幸的是,控件PageView和RecyclerView對事件衝突做了處理的,所以平時使用這兩個控件的時候不會感受到滑動衝突的存在。如果是ScrollView+GridView等這類組合,就需要解決衝突了。

  2、外部滑動和內部滑動方向一致

       同樣,這種場景除了圖二中的內外都是上下滑動的情況外,還包括內外到時左右滑動的場景了。ScollView(垂直滾動)+ListView的組合就是比較常見的場景。第一節中的動態圖就是一個外部滑動和內部滑動方向一致的例子。

  3、多層滑動嵌套

       這種場景一般就是前面兩種場景的嵌套。“騰訊新聞”客戶端就是典型的多層滑動嵌套的使用案例,如下圖中,圖一的左邊是主頁向右滑動時才出現的滑動側邊欄,圖二是主頁界面,頂部導航欄在主頁左右滑動時可以切換,整個“要聞”界面可以上下滑動,“熱點精選”是一個可以左右滑動的橫向列表,下方還有豎直方向的列表……可見這其中嵌套層數不少。

           

 

三、滑動衝突三種場景的處理思路

       儘管滑動衝突看起來比較複雜,但是上述將它們分為三類場景后,就可以根據這三類場景來分別找出對應的分析思路。

  1、內外滑動方向不一致時處理思路

       這一類場景其實比較容易分析,因為外層和內層滑動的方向不一致,所以根據手勢的動向來確定把事件給誰。我們前面兩篇文章中分析過,默認情況下,當點擊內層控件時,事件會先一層層從外層傳到內層,由內層來處理。這裏以外層為左右滑動,內層為上下滑動為例。當判定手勢的滑動為左右時,需要外層來消費事件,所以外層將事件攔截,即在外層的onInterceptTouchEvent中檢測為ACTION_MOVE時返回true;而如果判定手勢的滑動為上下時,需要內層來消費事件,外層不需要攔截,事件會傳遞到內層來處理(具體的代碼實現,在後面會詳細列出)。這樣就通過判斷滑動的方向來決定事件的處理對象,從而解決滑動衝突的問題。

       那麼,如何來判定手勢的滑動方向呢?最常用的辦法就是比較水平和豎直方向上的位移值來判斷。 MotionEvent事件包含了事件的坐標,只要記錄一次移動事件的起點和終點坐標,如下圖所示,通過比較在水平方向的位移|dx|和|dy|的大小,來決定滑動的方向:|dy|>|dx|,本次移動的方向認為是豎直方向;反之,則認為是水平方向。當然,還可以通過夾角α的大小、斜率、速率等方式來作為判斷條件。

  2、內外滑動方向一致時處理思路

       這種場景要比上面一種複雜一些,因為滑動方向一致,所以無法通過上述的方式來判斷將事件交給誰處理。在這種情況下,往往需要根據業務的需要來判定誰來處理事件。比如豎直方向的ScrollView嵌套ListView的場景下,手指在ListView上上下滑動時:當ListView滑動到頂部且手勢向下時,顯然ListView不能再向下滑動了,這種情況下事件需要被外層控件攔截,由ScrollView來消費;當ListView滑動到底部且手勢向上時,顯然ListView也不能再向上滑動了,這種情況下事件也需要被外層控件攔截,由ScrollView來消費;其它情況下,ScrollView就不能再攔截了,滑動事件就需要由ListView來消費了,即此時上下滑動時,滑動的是ListView,而不是ScrollView。後面會以這為案例進行編碼實現。

  3、多層滑動嵌套時處理思路

       場景3看起來比較複雜,但前面也說過了,也是由前面兩種場景嵌套形成的。所以在處理場景的處理方式,就是將其拆分為簡單的場景,然後按照前面的場景分析方式來處理。

 

四、滑動衝突的兩種解決套路

       前面我們將滑動衝突分為了3種場景,並根據每一種場景提供了解決衝突的思路。但是這些思路解決的是判斷條件問題,即什麼情況下事件交給誰的問題。這一節將拋開前面的場景分類,介紹對所有場景適用的兩種通用解決方法,可以通俗地理解為處理滑動衝突的“套路”。這兩種解決滑動衝突的方式為:外部攔截法和內部攔截法。

  1、外部攔截法

       顧名思義,就是在外部滑動控件中處理攔截邏輯。這需要外部控件重寫父類的onInterceptTouchEvent方法,在其中判斷什麼時候需要攔截事件由自身處理,什麼時候需要放行將事件傳給內層控件處理,內部控件不需要做任何處理。這個“套路”的偽代碼錶示所示:

 1 @Override
 2 public boolean onInterceptTouchEvent(MotionEvent ev) {
 3     boolean intercepted = false;
 4     switch (ev.getAction()){
 5         case MotionEvent.ACTION_DOWN:
 6             intercepted = false;
 7             break;
 8         case MotionEvent.ACTION_MOVE:
 9             if(父容器需要自己處理改事件){
10                 intercepted = true;
11             }else {
12                 intercepted = false;
13             }
14             break;
15         case MotionEvent.ACTION_UP:
16             intercepted = false;
17             break;
18             default:
19             break;
20     }
21     return intercepted;
22 }

前面對滑動處理的場景分類,並對不同場景給了分析思路,它們的作用就是在這裏的第9行來做判斷條件的。所以,不論什麼場景,都可以在這個套路的基礎上,修改判斷是否攔截事件的條件語句即可。另外,需要說明一下的是,第6行和第16行,這裏都賦值為false,因為ACTION_DOWN如果被攔截了,該動作序列的其它事件就都無法傳遞到子View中了,ListView也就永遠不能滑動了;而ACTION_UP如果被攔截,那子View就無法被點擊了,這兩點我們前面的文章都講過,這裏再強調一下。

 

  2、內部攔截法

       顧名思義,就是將事件是否需要攔截的邏輯,放到內層控件中來處理。這種方式需要結合requestDisllowInterceptTouchEvent(boolean),在內層控件的重寫方法dispatchTouchEvent中,根據邏輯來決定外層控件何時需要攔截事件,何時需要放行。偽代碼如下:

 1 @Override
 2 public boolean dispatchTouchEvent(MotionEvent ev) {
 3     switch (ev.getAction()){
 4         case MotionEvent.ACTION_DOWN:
 5             getParent().requestDisallowInterceptTouchEvent(true);
 6             break;
 7         case MotionEvent.ACTION_MOVE:
 8             if (父容器需要處理改事件) {
 9                 //允許外層控件攔截事件
10                 getParent().requestDisallowInterceptTouchEvent(false);
11             } else {
12                 //需要內部控件處理該事件,不允許上層viewGroup攔截
13                 getParent().requestDisallowInterceptTouchEvent(true);
14             }
15             break;
16         case MotionEvent.ACTION_UP:
17             break;
18         default:
19             break;
20     }
21     return super.dispatchTouchEvent(ev);
22 }

除此之外,還需要外層控件在onInterceptTouchEvent中做一點處理:

1 @Override
2 public boolean onInterceptTouchEvent(MotionEvent ev) {
3     if (ev.getAction() == MotionEvent.ACTION_DOWN) {
4         return false;
5     } else {
6         return true;
7     }
8 }

ACTION_DOWN事件仍然不能攔截,上一篇文章分析源碼的時候講過,ACTION_DOWN時會初始化一些狀態和標誌位等變量,requestDisllowInterceptTouchEvent(boolean)作用會失效。這裏再順便強調一下,不明白的可以去上一篇文章中閱讀這部分內容。 

       這種方式比“外部攔截法”稍微複雜一些,所以一般推薦使用前者。同前者一樣,這也是一個套路用法,無論是之前提到的何種場景,只要根據實際判斷條件修改上述if語句即可。對於requestDisllowInterceptTouchEvent(boolean)的相關信息,在前面的文章中介紹過,這裏不再贅述了。

 

 五、代碼示例

       前面通過文字描述和偽代碼,對滑動衝突進行了介紹,並提供了一些對應的解決方案。本節將通過一個具體的實例,分別使用上述的套路來解決一個滑動衝突,從而具體演示前面“套路”的使用。

  1、未解決衝突前的示例情況

       本示例外層為一個ScrollView,內層為TextView+ListView+TextView,這兩個TextView分別為“Tittle”和”Bottom”,显示在ListView的頂部和底部,添加它們是為了方便觀察ScrollView的滑動效果。最終的布局效果如下所示:

在手機上的显示效果為:

     

在沒有解決衝突前,如果滑動中間的ListView部分,會出現ListView中的列表內容不會滑動,而是整個ScrollView滑動的現象,或者一會兒ListView滑動,一會兒ScrollView滑動。顯然,這不是我們希望看到的結果。我們希望的是,如果ListView滑到頂部時,而且手勢繼續下滑時,整個頁面下滑,即ScrollView滑動;如果ListView滑到底部了,而且手勢繼續上滑時,希望整個頁面上滑,即也是ScrollView向上滑動。

 

  2、用外部攔截法解決滑動衝突的示例

       前面說過了,這種方式需要外層的控件在重寫的onInterceptTouchEvent時進行攔截判斷,所以需要自定義一個ScrollView控件。

 1 public class CustomScrollView extends ScrollView {
 2 
 3     ListView listView;
 4     private float mLastY;
 5     public CustomScrollView(Context context, AttributeSet attrs) {
 6         super(context, attrs);
 7     }
 8 
 9     @Override
10     public boolean onInterceptTouchEvent(MotionEvent ev) {
11         super.onInterceptTouchEvent(ev);
12         boolean intercept = false;
13         switch (ev.getAction()){
14             case MotionEvent.ACTION_DOWN:
15                 intercept = false;
16                 break;
17             case MotionEvent.ACTION_MOVE:
18                 listView = (ListView) ((ViewGroup)getChildAt(0)).getChildAt(1);
19                    //ListView滑動到頂部,且繼續下滑,讓scrollView攔截事件
20                 if (listView.getFirstVisiblePosition() == 0 && (ev.getY() - mLastY) > 0) {
21                     //scrollView攔截事件
22                     intercept = true;
23                 }
24                 //listView滑動到底部,如果繼續上滑,就讓scrollView攔截事件
25                 else if (listView.getLastVisiblePosition() ==listView.getCount() - 1 && (ev.getY() - mLastY) < 0) {
26                     //scrollView攔截事件
27                     intercept = true;
28                 } else {
29                     //不允許scrollView攔截事件
30                     intercept = false;
31                 }
32                 break;
33             case MotionEvent.ACTION_UP:
34                 intercept = false;
35                 break;
36             default:
37                 break;
38         }
39         mLastY = ev.getY();
40         return intercept;
41     }
42 }

       相比於前面的偽代碼,這裏需要注意一點的是多了第12行。因為本控件是繼承自ScrollView,而ScrollView中的onInterceptTouchEvent做了很多的工作,這裏需要使用ScrollView中的處理邏輯,才需要加上這一句。如果是完全自繪的控件,即直接繼承自ViewGroup,那就無需這一句了,因為控件需要自己完成自己的特色功能。第18行是獲取子控件ListView的實例,這個是參照後面的布局文件activity_event_examples來定位的,也可以通過其它的方式來獲取實例。另外就是ListView的實例可以通過其它方式一次性賦值,而不用這裏每次ACTION_MOVE都獲取一次實例,從性能上考慮會更好,這裏為了便於演示,先忽略這一點。其它要點在註釋中也說得比較明確了,這裏不贅述。

       使用CustomScrollView控件,界面的布局如下:

 1 //==============activity_event_examples=============
 2 <?xml version="1.0" encoding="utf-8"?>
 3 <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
 4     android:layout_width="match_parent"
 5     android:layout_height="match_parent"
 6     android:orientation="vertical">
 7 
 8     <com.example.demos.customviewdemo.CustomScrollView
 9         android:id="@+id/demo_scrollview"
10         android:layout_width="match_parent"
11         android:layout_height="match_parent">
12 
13         <LinearLayout
14             android:layout_width="match_parent"
15             android:layout_height="match_parent"
16             android:orientation="vertical">
17 
18             <TextView
19                 android:id="@+id/tv_title"
20                 android:layout_width="match_parent"
21                 android:layout_height="100dp"
22                 android:background="@android:color/darker_gray"
23                 android:gravity="center"
24                 android:text="Title"
25                 android:textSize="50dp" />
26 
27             <ListView
28                 android:id="@+id/demo_lv"
29                 android:layout_width="match_parent"
30                 android:layout_height="600dp" />
31 
32             <TextView
33                 android:layout_width="match_parent"
34                 android:layout_height="100dp"
35                 android:background="@android:color/darker_gray"
36                 android:gravity="center"
37                 android:text="Bottom"
38                 android:textSize="50dp" />
39         </LinearLayout>
40     </com.example.demos.customviewdemo.CustomScrollView>
41 </LinearLayout>

這裏需要注意的是,在ScrollView中嵌套ListView時,ListView的高度需要特別處理,如果設置為match_parent或者wrap_content,都會一次只能看到一條item,所以上面給了固定的高度600dp來演示效果。平時工作中,往往還需要對ListView的高度做一些特殊的處理,這不是本文的重點,這裏不細講,讀者可以自行去研究。

       最後就是給ListView填充足夠的數據:

 1 public class EventExmaplesActivity extends AppCompatActivity {
 2 
 3     private String[] data = {"Apple", "Banana", "Orange", "Watermelon",
 4             "Pear", "Grape", "Pineapple", "Strawberry", "Cherry", "Mango",
 5             "Apple", "Banana", "Orange", "Watermelon",
 6             "Pear", "Grape", "Pineapple", "Strawberry", "Cherry", "Mango"};
 7 
 8     @Override
 9     protected void onCreate(Bundle savedInstanceState) {
10         super.onCreate(savedInstanceState);
11         setContentView(R.layout.activity_event_exmaples);
12         showList();
13     }
14 
15     private void showList() {
16         ArrayAdapter<String> adapter = new ArrayAdapter<String>(
17                 EventExmaplesActivity.this, android.R.layout.simple_list_item_1, data);
18         ListView listView = findViewById(R.id.demo_lv);
19         listView.setAdapter(adapter);
20     }
21 }

 

  3、用內部攔截法解決滑動衝突的示例

       同樣,前面的偽代碼中也講過,這裏需要在內層控件中重寫的dispatchTouchEvent方法處判斷外層控件的攔截邏輯,所以首先需要自定義ListView。

 1 public class CustomListView extends ListView {
 2 
 3     public CustomListView(Context context, AttributeSet attrs) {
 4         super(context, attrs);
 5     }
 6 
 7     //為listview/Y,設置初始值,默認為0.0(ListView條目一位置)
 8     private float mLastY;
 9 
10     @Override
11     public boolean dispatchTouchEvent(MotionEvent ev) {
12         int action = ev.getAction();
13         switch (action) {
14             case MotionEvent.ACTION_DOWN:
15                 //不允許上層的ScrollView攔截事件.
16                 getParent().requestDisallowInterceptTouchEvent(true);
17                 break;
18             case MotionEvent.ACTION_MOVE:
19                 //滿足listView滑動到頂部,如果繼續下滑,那就允許scrollView攔截事件
20                 if (getFirstVisiblePosition() == 0 && (ev.getY() - mLastY) > 0) {
21                     //允許ScrollView攔截事件
22                     getParent().requestDisallowInterceptTouchEvent(false);
23                 }
24                 //滿足listView滑動到底部,如果繼續上滑,允許scrollView攔截事件
25                 else if (getLastVisiblePosition() == getCount() - 1 && (ev.getY() - mLastY) < 0) {
26                     //允許ScrollView攔截事件
27                     getParent().requestDisallowInterceptTouchEvent(false);
28                 } else {
29                     //其它情形時不允ScrollView攔截事件
30                     getParent().requestDisallowInterceptTouchEvent(true);
31                 }
32                 break;
33             case MotionEvent.ACTION_UP:
34                 break;
35         }
36 
37         mLastY = ev.getY();
38         return super.dispatchTouchEvent(ev);
39     }
40 }

可能有讀者會有些疑惑,從布局結構上看,listView和ScrollView之間還隔了一層LinearLayout,getParent().requestDisallowInterceptTouchEvent(boolean)方法會奏效嗎?實際上這個方法是針對所有的父布局的,而不是只針對直接父布局,這一點需要注意。

       參照偽代碼的套路,這裏還需要對外層的ScrollView做一些邏輯處理:

 1 public class CustomScrollView extends ScrollView {
 2     public CustomScrollView(Context context, AttributeSet attrs) {
 3         super(context, attrs);
 4     }
 5 
 6     @Override
 7     public boolean onInterceptTouchEvent(MotionEvent ev) {
 8         if (ev.getAction() == MotionEvent.ACTION_DOWN) {
 9             return false;
10         } else {
11             return true;
12         }
13     }
14 }

       在布局文件中使用CustomListView,將前面activity_event_examples.xml布局文件中的第27行的ListView替換為com.example.demos.customviewdemo.CustomListView即可。其它的和前面外部攔截法示例一樣,這裏不贅述。

 

結語

       關於滑動衝突的內容就講完了。實際工作中的場景可能比這裏demo中要複雜一些,筆者為了突出重點,所舉的例子選得比較簡單,但原理都一樣的,所以希望讀者能夠好好理解,重要的地方,甚至需要記下來。同樣,Android事件分發機制系列的知識點,要講的也講完了,三篇文章側重於三個方面:1)第一篇重點總結了Touch相關的三個重要方法對事件的處理邏輯;2)第二篇重點分析源碼,從源碼的角度來分析第一篇文章中的邏輯;3)第三篇重點在實踐,側重解決實際工作中經常遇到的事件衝突問題——滑動衝突。當然,事件分發相關的問題遠不是這3篇文章能說清楚的,文中若有描述錯誤或者不妥的地方,歡迎讀者來拍磚!!!

 

參考資料

       任玉剛《Android開發藝術探索》

【精選推薦文章】

如何讓商品強力曝光呢? 網頁設計公司幫您建置最吸引人的網站,提高曝光率!!

想要讓你的商品在網路上成為最夯、最多人討論的話題?

網頁設計公司推薦更多不同的設計風格,搶佔消費者視覺第一線

不管是台北網頁設計公司台中網頁設計公司,全省皆有專員為您服務

想知道最厲害的台北網頁設計公司推薦台中網頁設計公司推薦專業設計師"嚨底家"!!

您可能也會喜歡…