首頁>科技>

前言

  對於一個Android開發者而言,要開發一個APP你必須要了解事件分發,而要開發一個優秀的APP你就必須要理解巢狀滾動。  在Android的開發體系裡面,手勢體系是一塊非常重要的內容。從Android誕生之初便有了事件分發,這個分發機制決定了事件的傳播流程和事件如何被消費掉。事件傳播流程大概呈U字型,是一個先從上到下再從上到下的過程,在從手指按下到手指離開螢幕的一個手勢週期中,每個View都有機會消費這個事件。  但是這套機制也並非完美,如果把手勢週期比作一個蛋糕,每個事件是其中的一塊塊蛋糕,當某個View把傳到它面前的那塊蛋糕吃掉之後,它就成了後續蛋糕的指定消費者,其他View無法再享用這個蛋糕,哪怕這個消費者已經吃膩了。  回到我們的APP中,就是當報表消費了滑動手勢,則後續的滑動事件都會交給報表,哪怕報表已經無法繼續滑動了,外層的表單和下拉重新整理元件就接收不到滑動事件了。在越來越追求使用者體驗的今天,這顯然不是一個好事情,Android在相容開發庫(support包)引入了巢狀滾動機制(NestedScroll),甚至在API 23之後的SDK直接內建了這套機制。巢狀滾動機制允許事件消費者把多餘的事件主動分享出去。

表單裡的報表滑不動了?報表裡的圖表滑不動了?表單還沒滑動,下拉重新整理怎麼先出來了?

在我們的資料分析APP的開發中,我們遇到過很多看似坑爹的問題,其實這些都是和手勢衝突有關的,後面將會分別介紹手勢分發和巢狀滾動,以及如何藉助巢狀滾動解決這類手勢衝突,並且實現更多高大上的互動效果。

手勢分發

基礎概念:

MotionEvent:手勢物件,包含有action(事件型別)、座標等資訊。View:安卓的所有檢視都是View的子類。為了方便描述,本文用View指代檢視單元,是整個檢視樹的葉子節點,比如TextView、Button等。ViewGroup:檢視容器,裡面可以包含其他檢視,也是View的子類。一般在整個檢視樹作為非葉節點,比如Scrollview、LinearLayout等。Activity:你就理解為是電影中的一個場景吧,一個安卓APP是由一個或多個Activity組成的。

安卓的手勢事件型別包括(部分):

ACTION_DOWN:手指按下;ACTION_MOVE:手指移動;ACTION_UP:手指抬起;ACTION_CANCEL:手勢終止,比如手勢在中途被其他View攔截消費、手勢滑出螢幕(非抬起),大部分場景下可視為ACTION_UP;在多指手勢中還有:ACTION_PONINTER_DOWN:其他手指按下;ACTION_POINTER_UP:其他手指抬起;後面就簡單概括為DOWN、MOVE、UP三類事件。

關鍵方法

1. Activity中有兩個方法dispatchTouchEvent和onTouchEvent,整個手勢分發從這個dispatchTouchEvent開始,將手勢傳遞到整個View樹的根節點,通過深度遍歷的方式分發下去,如果沒有任何View消費掉的話手勢分發將從這個onTouchEvent結束。不過一般都會有個View中途消費掉的。虛擬碼如下:

public boolean dispatchTouchEvent(MotionEvent ev) { //交給view樹根節點分發手勢 if (viewRoot.dispatchTouchEvent(ev)) { //如果事件被消費了直接返回 return true; } //事件沒人要了,那就給自己的onTouchEvent吧 return onTouchEvent(ev);}

2. View中恰好也有這兩個方法dispatchTouchEvent和onTouchEvent,其中dispatchTouchEvent如其名是分發手勢的,而onTouchEvent是意味事件傳到它這了,可以在這裡執行一些手勢處理的操作。而View預設的dispatchTouchEvent實現非常簡單,就是直接交給自己的onTouchEvent,畢竟它是葉子節點,已經處於深度遍歷的最後一層。虛擬碼如下:

public boolean dispatchTouchEvent(MotionEvent ev) { ... //直接給自己的onTouchEvent吧 boolean handled = onTouchEvent(ev); ... return handled;}

而onTouchEvent則會利用手勢進行一些處理,比如識別單擊、長按事件,設定按壓狀態等.

public boolean onTouchEvent(MotionEvent ev) { if(不可點選 && 不可長按 && 不能獲取焦點) { //要啥單車,這手勢我不要了,給別人吧 return false; } //手勢型別 int action = ev.getAction(); switch(action) { case DOWN: 重置狀態(); 啟用定時器檢查是否長按(); break; case UP: if (允許獲取焦點?) { //所以允許焦點和設定點選事件是一個矛盾體,設定了焦點的View第一次點選不會觸發點選事件 獲取焦點(); break; } if (不是長按) { 關閉長按檢測定時器(); 觸發點選事件(); } break; } return true;}

3. ViewGroup在繼承了View的dispatchTouchEvent和onTouchEvent方法外,還加了onInterceptTouchEvent和requestDisallowInterceptTouchEvent方法。

onInterceptTouchEvent使得ViewGroup有機會直接攔截手勢給自己的onTouchEvent,而不必再向下傳播。

requestDisallowInterceptTouchEvent是允許下層的某個View阻止其攔截的,一物降一物。

ViewGroup重寫了dispatchTouchEvent方法,從這裡我們才看到了手勢分發的奧祕。虛擬碼如下:

public boolean dispatchTouchEvent(MotionEvent ev) { int action = ev.getAction(); if (action == DOWN) { //重置消費者 target = null; } //1.第一步:先判斷一下要不要攔截下來 boolean intercept = false; //DOWN事件要考慮考慮;對於非DOWN事件,如果前面DOWN有人認領過也要考慮考慮,沒人認領過就是那肯定直接攔截下來 if (action == DOWN || target != null) { if(!disallowInterceptTouchEvent) { //詢問是否要攔截這個手勢 intercept = onInterceptTouchEvent(); } } else { //之前DOWN沒一個人要,這孩子多半是沒人要了,那後面MOVE也不打算給你了,自己留著 intercept = true } //2. 第二步:如果不打算攔截,就找當前手勢的所在的child分發下去,找DOWN事件的接盤俠. //僅針對初始的DOWN事件,後續的MOVE事件是不走這個這一步的 if (!intercept && action == DOWN) { //沒攔截,按常規分發 View child = 手勢所在的Child if (child != null) { //遞迴分發 if(child.dispatchTouchEvent(ev)) { //這個child接受了這個事件,後續的事件都給它了 //這裡簡化了,target其實是個連結串列 target = child; } } } //3. 第三步: 直接指派,包括沒有child要消費的DOWN事件及所有的後續事件 if (target != null) { //之前已經有人消費了DOWN,後續的MOVE,UP事件直接給它了(這裡有校驗target不是第二步剛分發過的view) return target.dispatchTouchEvent(ev); } else { //事件沒人要,給自己了,前面知道父類View的dispatch是直接給自己的onTouchEvent return super.dispatchTouchEvent(ev); }}

預設的onInterceptTouchEvent方法直接返回false,也就是預設不攔截。

容器類檢視一般會重寫這個方法,比如Scrollview會重新這個方法,在MOVE事件中當y方向上滑動距離達到指定閾值時會攔截手勢,並在自己的onTouchEvent方法中執行滑動邏輯。

注意如果沒有巢狀滾動的機制,這裡就會出現Scrollview裡面的報表無法滑動的問題了,因為Scrollview先把事件攔下來了。

圖解分發流程

前面的虛擬碼可能還是很難理解,要結合一些圖來看。

1. 完整的DOWN事件手勢流向

DOWN事件手勢流

如果事件沒有任何打斷, 也就是沒有任何容器通過onInterceptTouchEvent攔截下來,每個View都沒有在onTouchEvent消費掉事件(不設定點選事件之類的),那麼一個DOWN事件的走勢如上圖中的U型,事件從Activity的dispatchTouchEvent開發自上而下一路到最底層View的dispatchTouchEvent,再從最底層View的onTouchEvent一路自下而上到Activity的onTouchEvent。

2. DOWN事件被某個View的onTouchEvent消費後的MOVE事件流向

紅色線條是DOWN事件的走勢,藍色線條是MOVE事件的走勢。根據前面虛擬碼,MOVE事件走的是第三步,基本規則就是誰消費了DOWN事件,就把後續的MOVE給誰了。在這裡我一個專案中曾踩過一個坑,有一個表格無法滑動的原因是單元格設定了手勢監聽,要檢測單擊手勢並獲取單擊座標,根據規則如果要收到UP事件,首先他要攔截DOWN事件,導致上層的RecyclerView接收不到後續事件無法滑動。

3. DOWN事件被某個dispatchTouchEvent消費後的MOVE事件走向

由於不呼叫super方法所以任何onTouchEvent都執行不到了。

通過onInterceptTouchEvent攔截並在onTouchEvent消費也是類似的,下層的節點無法接收到任何事件。

巢狀滾動

那為何要引入巢狀滾動呢?看我們APP的一個實際效果圖,這是符合我們預期的效果

這是一個常見的表單內巢狀著報表的情況,上面的佈局樹結構我們大致可以抽象為

我們知道SwipeRefreshLayout(下拉重新整理)、NestedScrollView(這裡是表單佈局)、RecyclerView(表格)都是可滾動的,再複雜點的表格內部還有RecyclerView型別的單元格、支援巢狀滾動的圖表單元格。

而我們預期要讓每個可滾動的元件都有機會滾動,也就是 RecyclerView先滾動,當RecyclerView滾動到頂部的時候Scrollview再繼續滾動,當Scrollview也滾動到頂之後SwipeRefreshLayout接著滾動出現下拉重新整理。 用一個手勢流程圖表示:

上圖中,按照安卓常規的手勢分發,顯然SwipeRefreshLayout搶先攔截事件(走第一條藍虛線),它們的判斷依據都是滑動距離是否大於閾值。後面的Scrollview和RecyclerView根本沒機會滾動。也就是我們要讓MOVE事件按藍實線走到RecyclerView的onTouchEvent,讓RecyclerView成為事實上的事件消費者,同時也要讓上面的NestedScrollView和SwipeRefreshLayout有機會滾動,這就需要藉助巢狀滾動。

關鍵介面NestedScrollingChild 巢狀滾動的發起方,內層的可滾動檢視實現該介面,可以將未消費的多餘手勢滑動距離向上傳播給外層可滾動檢視。該介面主要有以下關鍵方法,與後面的NestedScrollingParent介面一一對應:NestedScrollingParent 巢狀滾動的接收方,外層可滾動檢視實現該介面,在接收到內層傳來的手勢距離後可以根據需要主動滾動自己,並消費掉該距離

當然,一個View可以同時實現上面的兩個介面,Parent在無法完全消費掉收到的距離時可以作為Child把剩餘的距離繼續向上傳播。上圖中的SwipeRefreshLayout和NestedScrollView都同時實現了NestedScrollingParent和NestedScrollingChild,而RecyclerView則實現了NestedScrollingChild介面。

關鍵方法

NestedScrollingChild和NestedScrollingParent介面一組關鍵方法並且一一對應。

實現原理

為了更好的理解巢狀滾動的原理,下面用一個序列圖看的更直觀一點。

上面的流程圖就是簡單的兩層巢狀滾動的場景,對於多層巢狀也是類似的,只不過是Parent在接收到請求時會再向上發起請求。圖太大,對一些過程做了簡化。

多層巢狀

在巢狀滾動中,最底層的可滾動檢視成為事實上的事件消費者,在DOWN事件中就向上宣佈我可以滾動,並且我能帶你們一起滾動,而上層可滾動檢視在收到這個請求後一般都會在後續的MOVE事件中主動放棄攔截。通過NestedScrollingChild和NestedScrollingParent介面的互相配合,完成了先裡後外和巢狀滾動,彌補了常規手勢分發的至上而下的分發方式帶來的不足。

圖太長了,結合一點虛擬碼看看:這裡以RecyclerView (NestedScrollingChild)和NestedScrollView (NestedScrollingParent)為例。Child在onInterceptTouchEvent階段會呼叫巢狀滾動的start和stop方法,可以理解為這是本次巢狀滾動的入口和出口。Child:

public boolean onInterceptTouchEvent(ev) { switch(action) { case 'DOWN': //作為一個NestedScrollingChild,在DOWN階段就給Parent打個預防針,表明自己能進行某個方向的巢狀滾動,不會虧待你的,Parent一般接收到符合自己滾動方向的巢狀滾動都會主動放棄攔截 startNestedScroll(HORIZONTAL|VERTICAL) return false case 'UP': stopNestedScroll() return false case 'MOVE': if(滾動距離大於閾值) { 進入滾動狀態() //即將進入滾動狀態,我需要後續的事件,沒商量餘地,所有Parent不得攔截 requestDisallowInterceptTouchEvent(true) return true } }}

而Parent在onInterceptTouchEvent中會判斷是否即將處於巢狀滾動中,如果手勢所在的Child支援巢狀滾動它是很樂意主動放棄攔截的,因為等下Child會通過巢狀的方式讓自己滾動。Parent:

public boolean onInterceptTouchEvent(ex){ switch(action) { case 'MOVE': //這個axes就是前面Child的startNestedScroll傳來的滾動方向,由於NestedScrollView是縱向滾動的,如果有一個縱向的巢狀滾動那就大可放心放棄攔截 if (getNestedScrollAxes() & VERTICAL != 0) { return false } //非巢狀滾動,就走常規路線,正常攔截事件 if(滾動距離大於閾值) { 進入滾動狀態() //即將進入滾動狀態,我需要後續的事件,沒商量餘地,所有Parent不得攔截 requestDisallowInterceptTouchEvent(true) return true } }}

在Child成功拿到MOVE事件並攔截下來後就到了Child的onTouchEvent。

public boolean onTouchEvent(ev) { switch(action) { case 'DOWN': //和onInterceptTouchEvent一樣,這裡再次start確保進入巢狀滾動(實際上如果前面的start已經鎖定了一個Parent的話這次呼叫會被跳過) startNestedScroll(HORIZONTAL|VERTICAL) break case 'MOVE': //1、先觸發巢狀預滾動 if (dispatchNestedPreScroll(dx,dy,scrollConsumed,scrollOffset)) { //如果Parent在預滾動階段消費了部分距離,做一些必要的偏移工作,比如修正dx、dy,修正手勢座標等 } //2、自己滾動 scrollBy(dx,dy) ///3、觸發巢狀滾動 if (dispatchNestedScroll(consumedX,consumedY,unconsumedX,unconsumedY,offset) { //如果Parent在巢狀滾動階段消費了部分距離,做一些必要的偏移工作,比如修正dx、dy,修正手勢座標等 } case 'UP': if (vx != 0 || vy != 0) { //抬起時有加速度,需要執行甩動動作 //1、觸發巢狀預甩動 dispatchNestedPreFling (vx,vy) //2、自己甩動,如果可以的話 if (canScroll) { fling(vx,vy) } //3、觸發巢狀甩動,告知自己是否已消費 isConsumed = canScroll dispatchNestedFling(vx,vy,isConsumed) //結束巢狀滾動 stopNestedScroll() } }}

可見Child在自身scroll和fling前後都給了Parent機會,Parent即使之前主動放棄了攔截MOVE事件它也能有機會去scroll和fling。Parent相對應的響應巢狀滾動的onNestedxxx方法無非就是執行滾動或者繼續向上傳播巢狀滾動,這裡就不列程式碼了。

當然巢狀滾動的流程並不是都像上面的流程圖及虛擬碼一樣一層不變,這裡只是參考了NestedScrollView的實現方式。具體實現要和業務場景相結合。

巢狀滾動的一些有趣應用場景

在谷歌爸爸官方提供的design support包中有很多跟巢狀滾動有關的元件,比如CoordinatorLayout、AppBarLayout,他們的組合能做出很多酷炫的效果。其中CoordinatorLayout一般作為頂級容器,其實現了NestedScrollingParent,站在上帝視角把巢狀滾動藉助一個個Behavior實現類分發給其他子節點,比如AppBarLayout藉助AppBarLayout.Behavior類可以實現標題欄展開摺疊、顯示隱藏、標題背景視差滾動等特效;懸浮按鈕FloatingActionButton藉助FloatingActionButton.Behavior可以實現跟隨關聯檢視的效果。自定義Behavior可以實現你想要的酷炫效果(可以讓你的APP吸引更多人氣賺更多錢)。標題欄收起和顯示:

下面這個包含了多個效果,包括標題欄展開摺疊、標題欄背景視差滾動、懸浮按鈕跟隨標題欄移動、懸浮按鈕摺疊時隱藏等:上面的兩個例子都是使用網友的一個demo,在cheesesquare裡可以找到。CoordinatorLayout的種種特效能夠執行起來就是依賴巢狀滾動,因此內部要有一個NestedScrollingChild來觸發巢狀滾動,上面的例子中的滾動源就是RecyclerView。

下面我自己寫了一個簡單的demo,展示了標題欄吸附的效果(也就是在狀態列摺疊過程中結束滑動會進一步歸位到展開或摺疊,不會停留在中間狀態)、懸浮按鈕在顯示SnackBar時自動上移(預設效果),以及通過自定義Behavior在NestedScrollView滑動時自動隱藏懸浮按鈕,結束滑動後自動顯示的效果。

總結手勢分發的DOWN事件流程是按先自上而下再自下而上的U性順序,中間每個節點都可能被消費掉;非DOWN事件在到達DOWN事件消費者的父節點時直接分發給該消費者,沒有消費者則分發給父節點本身。dispatchTouchEvent負責手勢分發,onInterceptTouchEvent負責手勢攔截,onTouchEvent負責手勢消費,各司其職,儘量不要修改dispatchTouchEvent方法,以免打亂手勢分發規則。子節點可以通過requestDisallowInterceptTouchEvent和startNestedScroll阻止父節點(或祖先節點)攔截事件。其中requestDisallowInterceptTouchEvent是強制性的,使得父節點的onInterceptTouchEvent方法根本沒機會執行;startNestedScroll是發起巢狀滾動,父節點在onInterceptTouchEvent中主動放棄攔截。在巢狀滾動中子節點請求父節點不要攔截事件,讓事件能夠到達子節點並讓子節點成為事件消費者,子節點在滾動前後會通知並配合父節點滾動。巢狀滾動可以多層巢狀,一個View既可以是NestedScrollingChild也可以是NestedScrollingParent,Child和Parent也不一定是父子關係,也可以是祖孫關係。API 23以上直接集成了巢狀滾動,任何View都是NestedScrollingChild和NestedScrollingParent。巢狀滾動很棒。

最新評論
  • 整治雙十一購物亂象,國家再次出手!該跟這些套路說再見了
  • 解決生鮮產品買賣“兩頭難”,京東快遞全力保障國民“菜籃子”