1. 程式人生 > >Android頭部伸縮元件的原理及實現(上)

Android頭部伸縮元件的原理及實現(上)

前言

我們的App最近上了一個新Feature,名叫Speed Dial,類似於視訊中這樣,Header和ViewPager裡面的Page一起滾動,支援下拉重新整理,有的頁面還要支援上拉重新整理,有的頁面還有Bar固定在頁面底部。

其實陌陌、大眾點評、馬蜂窩現在都有類似的功能,但可能我們的更復雜一些。

ViewPager是之前就有的,裡面的頁面支援使用者自定義,有各式各樣,如何在改動儘量少的情況下加上這個Header,滿足業務需求,並且提高程式碼複用性呢?

下文是我司的Android工程師Troy帶來的分享。

正文

相信大家對目前最新版本微信客戶端中的小程式塢都不會陌生,就是那個聊天列表滑到頂部後繼續向下overscroll就能被拉出來的“抽屜”(裡面包含一個支援橫向滾動的小程式列表)。得益於其在豐富了頁面層次的同時保持了對螢幕空間的友好等特性,這種縱向抽屜式的設計在目前並不罕見,於是便有了做一個通用的頭部header可伸縮元件的想法。考慮到大多數的此類產品需求為向一個現有的列表或頁面的頭部新增這樣一個header,或者是將之前的layout分為兩段(header&body),並且要求可根據使用者的垂直滾動操作伸縮,這個元件必須能夠做到支援無侵入式整合,即無需改變現有列表或頁面的實現,採用組合的方式便可完成整合,即插即用。

OK,需求清楚了,下面就開始看看什麼樣的介面和控制元件能夠幫助我們實現這個元件,這裡分為兩部分來描述,本文會根據需求理出實現的思路,而下一篇文章則會討論具體的實現。

在開始前先給出已完成的元件,原始碼放在了GitHub上GitHub - kfrozen/HeaderCollapsibleLayout (https://github.com/kfrozen/HeaderCollapsibleLayout),點選閱讀原文可檢視,同時也上傳到了maven上,大家可以通過在module的gradle中新增下述依賴來使用:

dependencies {
    compile 'com.troy.collapsibleheaderlayout:collapsibleheaderlayout:2.0.2'
}

下面開始正文。首先,顯而易見的是該元件的行為一定是基於垂直方向的滾動事件完成的,這是大方向,同時列出幾個關鍵詞:垂直方向,頭部控制元件,分層,無侵入,滾動。下面就來一步步通過佈局,事件傳遞,header開閉方式等幾個方面進行分析:

  • 佈局:顯然,我們的這個外層元件(起個名字:HeaderCollapsibleLayout)基於縱向LinearLayout是個不錯的選擇,不論是新新增一個頭部控制元件還是把原先的頁面一分為二,都可以將上下兩部分作為兩個child新增到HeaderCollapsibleLayout中。實現的大體思路為將header和body的layoutId作為屬性傳入HeaderCollapsibleLayout,並在其建構函式中讀取layoutId,同時inflate並完成新增操作:

    private void initStyleable(Context context, AttributeSet attrs) {
            if (attrs == null) {
                  return;
            }
            final TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.HeaderCollapsibleLayout, 0, 0);
    
            if (a.hasValue(R.styleable.HeaderCollapsibleLayout_topPanelLayoutId)) {
                  initTopView(a.getResourceId(R.styleable.HeaderCollapsibleLayout_topPanelLayoutId, -1), this);
            }
            if (a.hasValue(R.styleable.HeaderCollapsibleLayout_bottomPanelLayoutId)) {
                  initBottomView(a.getResourceId(R.styleable.HeaderCollapsibleLayout_bottomPanelLayoutId, -1), this);
            }
    
          ......
    
          if (mTopView != null) addView(mTopView);
            if (mBottomView != null) addView(mBottomView);
    
            a.recycle();
        }
  • 事件傳遞:我們需要根據使用者的手勢進行對header的縮放,自然地,這裡離不開對於滾動事件的上下傳遞。回想一下我們平時對於此類頁面的使用習慣:當header關閉時,手指向上移動就是對body列表的正常向下滾動(這裡指列表內容向下滾動),而手指向下移動時,我們期待的是先向上滾動列表內容,當列表內容已到頂部時,此時手指再向下滑動才將header開啟;相反的,在header已開啟時,手指向下滑動應直接作用於列表,將其內容向上滾動,而手指向上滑動應該首先關閉header,當header被完全關閉後,再下來的向上滑動手勢才會帶動列表內容向下滾動。 因此,對於我們外層的HeaderCollapsibleLayout來說,需要攔截並按需消費向上的移動距離dy(dy>0),當header被完全關閉時即無需繼續消費,此時需將剩餘的dy下放給body。而對於向下的移動距離dy(dy<0)則不應由HeaderCollapsibleLayout攔截,而是應該等待下層body傳回的剩餘dy並消費。 好了,傳遞的方向和層次理清楚了,下面就該來選擇用何種方式來實現上述的邏輯了。最直接的就是在dispatchTouchEvent, onInterceptTouchEvent和onTouchEvent這三個標準的touch事件回撥中處理,但是這樣的話無法避免地需要修改body中的程式碼,而且對於fling事件的處理也比較麻煩,所以需要考慮其他方案。考慮到body中的主體多為可垂直滾動的列表,似乎根據滾動事件來處理會方便很多,此處不難想到Android官方在support.v4包中推出的NestedScroll相關介面和元件,這套元件不僅提供了滾動發生前後的兩組回撥介面,更是幫我們處理了fling事件並且在其發生前後同樣提供了回撥介面。更重要的是,作為一個外層wrapper元件我們無法保證之前的body頁面是否包含可滾動的控制元件,也許只是一個滿屏的大圖或是一個webview,面對這種情況NestedScrollView就能派上大用場了,它不僅能傳遞我們所需的滾動事件,還能很好地處理巢狀滾動的情況,並且在body最外層新增一個NestedScrollView成本是不高的,最多加幾行程式碼設幾個引數就行,畢竟我們的目的只是讓滾動事件能通過NestedScroll機制在上下層View間傳遞,而不是真的讓body能夠滾動。 既然決定了利用NestedScroll機制來傳遞事件,就得先了解一下這傢伙是如何工作的,這裡分為兩部分來看:NestedScrollingChild和NestedScrollingParent。其實跟我們平常的View間onTouchEvent傳遞很類似,事件由底至上傳遞,child可以消費事件也可以向上分發,parent接收到來自child的事件並處理。一個ViewGroup可以同時實現child和parent兩個介面,或只選擇其一實現,而一個View只能作為NestedScrollingChild存在。這兩個介面主要是圍繞nestedScroll和nestedFling兩類事件展開,同時還分為PreNestedXXX和NestedXXX兩大類,顧名思義,方法名帶有Pre的會在此事件即將發生前被呼叫,給parent類一個機會去攔截或處理該事件,而不帶Pre的就是事件被child執行完畢後回到parent時的餘量,相當於沒有被child消費的事件,fling事件(如果有)總是會發生在scroll事件之後。聽起來是不是和我們的需求還挺契合的,下面就分別看一下兩個介面中我們需要關注的方法:

    • NestedScrollingChild一個NestedScrollingChild的實現主要負責將事件向其parent分發,按照一個touch事件被接收後的呼叫順序,我們主要關注以下的呼叫鏈:dispatchPreNestedScroll -> dispatchNestedScroll -> dispatchPreNestedFling -> dispatchNestedFling -> stopNestedScroll。

    • NestedScrollingParent而一個NestedScrollingParent的實現是通過接收由其NestedScrollingChild分發來的事件進行處理及動作的,對應分發順序,這裡的呼叫鏈為:onPreNestedScroll -> onNestedScroll -> onPreNestedFling -> onNestedFling -> onStopNestedScroll。

      就HeaderCollapsibleLayout來說,這是一個類似於NestedScrollView的夾層layout,既要作為NestedScrollingParent來接收其child傳來的nestedScrolling事件,同時也需要作為NestedScrollingChild將處理後的事件分發出去,所以它需要同時實現child和parent兩組介面以便做到能夠無侵入地插入已有layout結構中,但與NestedScrollView不同的是,NestedScrollView不需要對接受到的事件做任何處理,直接dispatch出去即可,而具體到我們的需求,child介面中對於事件的分發同樣不需要做任何定製,但在parent介面接收到滾動事件時我們需要根據這個事件對HeaderCollapsibleLayout做出相應改變。對於這些介面的實現,Android的support包已經為我們提供了一套預設實現,分別位於NestedScrollingChildHelper和NestedScrollingParentHelper兩個類中,如果翻看NestedScrollView原始碼可以發現其對於所有的介面方法基本都是用這兩個Helper類來代理實現的,這幫我們省了不少事,我們只需要修改上述呼叫鏈中關注的方法實現,而對於介面中其他的方法直接用預設實現即可。

  • 介面實現:上面說到我們需要對NestedScrollingParent中的介面方法實現進行定製,具體分為四種情況:向上scroll,向下scroll,向上fling和向下fling。我們首先跟著事件傳遞順序過一遍我們需要重寫的地方,而具體的實現方案有兩種,一個是基於scroll滾動,一個是基於reLayout也即修改header高度,在後文中會給出詳細實現及這兩種方案的優劣。下面先過一遍流程:

    • 向上scroll手指自下而上滾動的時候,合理的響應應該是先摺疊Header,當Header完全摺疊後Body中的內容再開始接收滾動事件。所以顯而易見的,這裡需要在onNestedPreScroll回撥中根據接收到的滾動事件開始摺疊Header,下面是該方法的定義:

      /*@param target View that initiated the nested scroll
      * @param dx Horizontal scroll distance in pixels
      * @param dy Vertical scroll distance in pixels
      * @param consumed Output. The horizontal and vertical scroll distance consumed by this parent
      */
      public void onNestedPreScroll(View target, int dx, int dy, int[] consumed) 

      我們要關注的是後兩個引數,dy是本次滾動事件在垂直方向上的有效距離,consumed是由child傳入的一個輸出陣列,用於記錄該parent對於本次滾動事件的消費量。此方法會在NestedScrollingChild的dispatchPreNestedScroll方法中被呼叫,具體如下:

      public boolean dispatchNestedPreScroll(int dx, int dy,
          @Nullable @Size(2) int[] consumed, @Nullable @Size(2) int[] offsetInWindow) {
      
          ......
      
          consumed[0] = 0;
          consumed[1] = 0;
          mNestedScrollingParent.onNestedPreScroll(this, dx, dy, consumed);
      
          ......
      
          return consumed[0] != 0 || consumed[1] != 0;
      
          ......
      }

      其中,dy>0表示手指自下向上移動,反之表示向下。根據上面的分析,我們在這個方法中只需要處理dy>0的情況,也即關閉header的操作,所以在HeaderCollapsibleLayout中,重寫這個方法,並在dy>0的時候攔截消費該事件來關閉header。具體的實現會在後文中給出。

    • 向下scroll手指向下滾動時,我們期待的情景是先滾動body中的內容,當其已經滾動到頂部或不需要繼續消費滾動事件時,再進行header展開的操作。於是這裡我們應考慮在onNestedScroll回撥中根據body分發來的剩餘事件展開header,該方法定義如下:

      /*
      * @param target The descendent view controlling the nested scroll
      * @param dxConsumed Horizontal scroll distance in pixels already consumed by target
      * @param dyConsumed Vertical scroll distance in pixels already consumed by target
      * @param dxUnconsumed Horizontal scroll distance in pixels not consumed by target
      * @param dyUnconsumed Vertical scroll distance in pixels not consumed by target
      */
      public void onNestedScroll(View target, int dxConsumed, int dyConsumed,
              int dxUnconsumed, int dyUnconsumed);

      我們在這裡需要關心的是dyConsumed和dyUnconsumed,它們分別代表了child在垂直方向已經消費掉的以及仍未消費的距離。其中dyUnconsumed就是我們可以用來消費的距離。此方法會在NestedScrollingChild的dispatchNestedScroll方法中被呼叫,具體如下:

      public boolean dispatchNestedScroll(int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed, @Nullable int[] offsetInWindow, int type) {
          if((isNestedScrollingEnabled() && mNestedScrollingParent != null) {
              ......
      
              mNestedScrollingParent.onNestedScroll(this, dxConsumed, dyConsumed,
                      dxUnconsumed, dyUnconsumed);
      
              ......
      
              return true;
          }
          return false;
      }

      由於我們在這裡只需要處理手指向下滾動的情況,所以當dyUnconsumed>0時不需做處理並直接將該事件dispatch出去即可。而對於dyUnconsumed<0的情況,我們可以利用它進行開啟header的操作

    • 向上fling & 向下fling因為fling事件的相關回調都是在scroll事件後才發生的,所以不需要像處理scroll事件那樣在事件發生的前後去分別處理,一律在onNestedPreFling中處理即可,下面是方法的定義:

      /*
      * @param target View that initiated the nested scroll
      * @param velocityX Horizontal velocity in pixels per second
      * @param velocityY Vertical velocity in pixels per second
      * @return true if this parent consumed the fling ahead of the target view
      */
      public boolean onNestedPreFling(View target, float velocityX, float velocityY);

      其中第三個引數velocityY是垂直方向上fling的速度,通過這個引數的正負我們可以判斷出fling的放向,大於0表示向上fling,此時應該自動關閉header,反之向下時應該自動開啟header,這裡的開啟關閉應使用動畫完成而非像scroll一樣跟隨手指移動。這裡有個需要注意的地方,在發生向下的fling動作時,我們仍應該保證只有在body的內容已滾動到頂時才打開header(當然這只是常規的操作體驗,具體還是要根據產品需求來定),為了做到這一點,我們需要在onNestedScroll方法中記錄下當前傳入的dyUnconsumed,這樣在後續的onNestedPreFling回撥中,我們就可以通過判斷本次事件是否在垂直方向上還有未被消費的部分,如果有說明body內容已到頂,這時就可以進行開啟header的操作。 本來到這裡我們的需求流程已經走完了,可誰讓咱們是Android工程師呢,機型適配永遠是一個繞不開的坎,果然這次也沒讓我失望,在三星S9上測試的時候,所有跟fling相關的回撥都華麗麗的不工作了,而且是完全不會被呼叫的那種,經過一番debug,還是沒明白到底是為啥。。。所以只好曲線救國了,回看到我們之前給出的函式呼叫鏈,在fling之後還會跟一個onStopNestedScroll事件,事實上這個事件是無論如何都會被回撥的,不論fling事件有沒有被觸發,而且是在使用者的手指從螢幕上擡起的時候被觸發,所以這是一個很好的替代實現fling相關功能的地方,同時這裡還能兼顧實現吸入式開關header的功能。下面是這個方法的定義:

       /*
       * @param target View that initiated the nested scroll
       */
       public void onStopNestedScroll(View target);

      到這裡,整體的實現思路和流程就有了,回顧一下:佈局方面我們採用了垂直方向的LinearLayout來裝載一個可摺疊的header和原本的body;事件傳遞方面我們選擇了基於NestedScrolling事件來實現這個元件,在其提供的NestedScrollingChild和NestedScrollingParent兩組介面中,我們重點需要重寫onNestedPreScroll,onNestedScroll和onStopNestedScroll這三個方法來實現header可摺疊的需求。

下一篇文章中,我們會根據本文的分析,繼續討論具體的實現方案和一些技術細節,最後再給出實現過程中遇到的一些坑供大家參考討論。

閱讀原文

https://mp.weixin.qq.com/s/Y0MoLSpw7rw5iardzZm8WA