前言
轉載請聲明,轉自【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開發藝術探索》
【精選推薦文章】
如何讓商品強力曝光呢? 網頁設計公司幫您建置最吸引人的網站,提高曝光率!!
想要讓你的商品在網路上成為最夯、最多人討論的話題?
網頁設計公司推薦更多不同的設計風格,搶佔消費者視覺第一線
不管是台北網頁設計公司、台中網頁設計公司,全省皆有專員為您服務
想知道最厲害的台北網頁設計公司推薦、台中網頁設計公司推薦專業設計師"嚨底家"!!