使用 CoordinatorLayout 實現復雜聯動效果


GitHub 地址已更新: unixzii / android-FancyBehaviorDemo

CoordinatorLayout 是 Google 在 Design Support 包中提供的一個十分強大的布局視圖,它本質是一個 FrameLayout,然而它允許開發者通過制定 Behavior 從而實現各種復雜的 UI 效果。

本文就通過一個具體的例子來講解一下 Behavior 的開發思路,首先我們看效果(GIF 圖效果一般,大家就看看大概意思吧):

效果圖

我們先歸納一下整個效果的細節:

  • 界面分為上下兩部分,上部分隨列表滑動而折疊與展開;
  • 頭部視圖背景隨折疊狀態而縮放和漸變;
  • 浮動搜索框隨折疊狀態改變位置和 margins;
  • 滑動結束前會根據滑動速度動畫到相應的狀態:
    • 如果速度達到一定閾值,則按速度方向切換狀態
    • 如果速度未達到閾值,則切換到距離當前狀態最近的狀態;

主要的細節就是這些,下面我們來一步步實現它!

編寫布局文件

首先我們將所有的控件在 xml 寫好,由於是 Demo,我這里就用一些很簡單的控件了。

 

activity_main.xml:

 

 1 <?xml version="1.0" encoding="utf-8"?>
 2 <android.support.design.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
 3     xmlns:app="http://schemas.android.com/apk/res-auto"
 4     xmlns:tools="http://schemas.android.com/tools"
 5     android:layout_width="match_parent"
 6     android:layout_height="match_parent"
 7     android:fitsSystemWindows="false"
 8     android:background="#fff"
 9     tools:context="com.example.cyandev.androidplayground.ScrollingActivity">
10 
11     <ImageView
12         android:id="@+id/scrolling_header"
13         android:layout_width="match_parent"
14         android:layout_height="200dp"
15         android:scaleType="centerCrop"
16         android:background="@drawable/bg_header" />
17 
18     <LinearLayout
19         android:id="@+id/edit_search"
20         android:layout_width="match_parent"
21         android:layout_height="40dp"
22         android:background="@color/colorInitFloatBackground"
23         app:layout_behavior="@string/header_float_behavior">
24         <TextView
25             android:layout_width="wrap_content"
26             android:layout_height="wrap_content"
27             android:layout_gravity="center_vertical"
28             android:layout_marginStart="20dp"
29             android:textColor="#90000000"
30             android:text="搜索關鍵字" />
31     </LinearLayout>
32 
33     <android.support.v7.widget.RecyclerView
34         android:id="@+id/rv"
35         android:layout_width="match_parent"
36         android:layout_height="match_parent"
37         android:background="#fff"
38         app:layout_behavior="@string/header_scrolling_behavior"
39         app:layoutManager="LinearLayoutManager" />
40 
41 </android.support.design.widget.CoordinatorLayout>

 

這里需要注意的是 CoordinatorLayout 子視圖的層級關系,如果想在子視圖中使用 Behavior 進行控制,那么這個子視圖一定是 CoordinatorLayout 的直接孩子,間接子視圖是不具有 behavior 屬性的,原因當然也很簡單,behavior 是 LayoutParams 的一個屬性,而間接子視圖的 LayoutParams 根本不是 CoordinatorLayout 類型的。

通過分解整個效果,我們可以將 Behavior 分為兩個,分別應用於 RecyclerView (或者其他支持 Nested Scrolling 的滾動視圖)和搜索框。

Behavior 基本概念

不要其被表面嚇到了,Behavior 實際就是將一些布局的過程以及 Nested Scrolling 的過程暴露了出來,利用代理和組合模式,可以讓開發者為 CoordinatorLayout 添加各種效果插件。

依賴視圖

一個 Behavior 能夠將指定的視圖作為一個依賴項,並且監聽這個依賴項的一切布局信息,一旦依賴項發生變化,Behavior 就可以做出適當的響應。很簡單的例子就是 FABSnackBar 的聯動,具體表現就是 FAB 會隨 SnackBar 的彈出而上移,從而不會被 SnackBar 遮擋,這就是依賴視圖的最簡單的一個用法。

Nested Scrolling

這是 Google 開發的一種全新嵌套滾動方案,由 NestedScrollingParentNestedScrollingChild 組成,一般來講我們都會圍繞  NestedScrollingParent 來進行開發,而 NestedScrollingChild 相比來說較為復雜,本文也不贅述其具體用法了。NestedScrollingParent(下文簡稱 NSP) 和 NestedScrollingChild(下文簡稱 NSC) 有一組相互配對的事件方法,NSC 負責派發這些方法到 NSPNSP 可以對這些方法做出響應。同時 Google 也提供了一組 Helper 類來幫助開發者使用 NSPNSC,其中 NestedScrollingParentHelper 較為簡單,僅是記錄一下滾動的方向。對於 Nested Scrolling 的具體用法,我在下文中會詳細講解。

案例 Behavior 實現思路

我們最終需要實現兩個 Behavior 類: HeaderScrollingBehavior 負責協調 RecyclerView 與 Header View 的關系,同時它依賴於 Header View,因為它要根據 Header View 的位移調整自己的位置。 HeaderFloatBehavior 負責協調搜索框與 Header View 的關系,也是依賴於 Header View,相對比較簡單。

可以看到,整個視圖體系都是圍繞 Header View 展開的,Recycler View 通過 Nested Scrolling 機制調整 Header View 的位置,進而因 Header View 的改變而影響自身的位置。搜索框也是隨 Header View 的位置變化而改變自己的位置、大小與背景顏色,這里只需要依賴視圖這一個概念就可以完成。

實現 HeaderScrollingBehavior

首先繼承自 Behavior,這是一個范型類,范型類型為被 Behavior 控制的視圖類型:

 1 public class HeaderScrollingBehavior extends CoordinatorLayout.Behavior<RecyclerView> {
 2 
 3     private boolean isExpanded = false;
 4     private boolean isScrolling = false;
 5 
 6     private WeakReference<View> dependentView;
 7     private Scroller scroller;
 8     private Handler handler;
 9 
10     public HeaderScrollingBehavior(Context context, AttributeSet attrs) {
11         super(context, attrs);
12         scroller = new Scroller(context);
13         handler = new Handler();
14     }
15 
16     ...
17 
18 }

解釋一下這幾個實例變量的作用,Scroller 用來實現用戶釋放手指后的滑動動畫,Handler 用來驅動 Scroller 的運行,而  dependentView 是依賴視圖的一個弱引用,方便我們后面的操作。剩下的是幾個狀態變量,不多解釋了。

我們先看這幾個方法:

1 @Override
2 public boolean layoutDependsOn(CoordinatorLayout parent, RecyclerView child, View dependency) {
3     if (dependency != null && dependency.getId() == R.id.scrolling_header) {        
4         dependentView = new WeakReference<>(dependency);
5         return true;
6     }
7     return false;
8 }

負責查詢該 Behavior 是否依賴於某個視圖,我們在這里判讀視圖是否為 Header View,如果是則返回 true,那么之后其他操作就會圍繞這個依賴視圖而進行了。

 

1 @Override
2 public boolean onLayoutChild(CoordinatorLayout parent, RecyclerView child, int layoutDirection) {
3     CoordinatorLayout.LayoutParams lp = (CoordinatorLayout.LayoutParams) child.getLayoutParams();
4     if (lp.height == CoordinatorLayout.LayoutParams.MATCH_PARENT) {
5         child.layout(0, 0, parent.getWidth(), (int) (parent.getHeight() - getDependentViewCollapsedHeight()));
6         return true;
7     }
8     return super.onLayoutChild(parent, child, layoutDirection);
9 }

 

負責對被 Behavior 控制的視圖進行布局,就是將 ViewGrouponLayout 針對該視圖的部分抽出來給 Behavior 處理。我們判斷一下如果目標視圖高度要填充父視圖,我們就自己將其高度減去 Header View 折疊后的高度。為什么要這么做呢?因為 CoodinatorLayout 就是一個 FrameLayout,不像 LinearLayout 一樣能自動分配各個 View 的高度,因此我們要自己實現大小控制。

 1 @Override
 2 public boolean onDependentViewChanged(CoordinatorLayout parent, RecyclerView child, View dependency)
 3  {
 4     Resources resources = getDependentView().getResources();
 5     final float progress = 1.f -
 6             Math.abs(dependency.getTranslationY() / (dependency.getHeight() - resources.getDimension(R.dimen.collapsed_header_height)));
 7 
 8     child.setTranslationY(dependency.getHeight() + dependency.getTranslationY());
 9 
10     float scale = 1 + 0.4f * (1.f - progress);
11     dependency.setScaleX(scale);
12     dependency.setScaleY(scale);
13 
14     dependency.setAlpha(progress);
15 
16     return true;
17 }

這段就是根據依賴視圖進行調整的方法,當依賴視圖發生變化時,這個方法就會被調用。這里我把相關的尺寸數據寫到了 dimens.xml 中,通過當前依賴視圖的位移,計算出一個位移因數(取值 0 - 1),對應到依賴視圖的縮放和透明度。

在這個例子中,依賴視圖的屬性影響到了依賴視圖自己的屬性,這也是可以的,因為我們主要依賴的就是 translateY 這個屬性,其他依賴視圖屬性本質就是一個 Computed Property。最后別忘了設置目標視圖的位移,讓其始終跟在 Header View 下面。
還有兩個便利函數,比較簡單:

1 private float getDependentViewCollapsedHeight() {
2     return getDependentView().getResources().getDimension(R.dimen.collapsed_header_height);
3 }
4 
5 private View getDependentView() {
6     return dependentView.get();
7 }

下面我們主要來看看 Nested Scrolling 怎么實現。

本例子中我們需要 NSP (Behavior 就是 NSP 的一個代理) 的這幾個回調方法:

  • onStartNestedScroll
  • onNestedScrollAccepted
  • onNestedPreScroll
  • onNestedScroll
  • onNestedPreFling
  • onStopNestedScroll

onStartNestedScroll

用戶按下手指時觸發,詢問 NSP 是否要處理這次滑動操作,如果返回 true 則表示“我要處理這次滑動”,如果返回 false 則表示“我不 care 你的滑動,你想咋滑就咋滑”,后面的一系列回調函數就不會被調用了。它有一個關鍵的參數,就是滑動方向,表明了用戶是垂直滑動還是水平滑動,本例子只需考慮垂直滑動,因此判斷滑動方向為垂直時就處理這次滑動,否則就不 care。

onNestedScrollAccepted

NSP 接受要處理本次滑動后,這個回調被調用,我們可以做一些准備工作,比如讓之前的滑動動畫結束。

onNestedPreScroll

NSC 即將被滑動時調用,在這里你可以做一些處理。值得注意的是,這個方法有一個參數 int[] consumed,你可以修改這個數組來表示你到底處理掉了多少像素。假設用戶滑動了 100px,你做了 90px 的位移,那么就需要把 consumed[1] 改成 90(下標 0、1 分別對應 x、y 軸),這樣 NSC 就能知道,然后繼續處理剩下的 10px。

onNestedScroll

上一個方法結束后,NSC 處理剩下的距離。比如上面還剩 10px,這里 NSC 滾動 2px 后發現已經到頭了,於是 NSC 結束其滾動,調用該方法,並將 NSC 處理剩下的像素數作為參數(dxUnconsumeddyUnconsumed)傳過來,這里傳過來的就是 8px。參數中還會有 NSC 處理過的像素數(dxConsumeddyConsumed)。這個方法主要處理一些越界后的滾動。

onNestedPreFling

用戶松開手指並且會發生慣性滾動之前調用。參數提供了速度信息,我們這里可以根據速度,決定最終的狀態是展開還是折疊,並且啟動滑動動畫。通過返回值我們可以通知 NSC 是否自己還要進行滑動滾動,一般情況如果面板處於中間態,我們就不讓 NSC 接着滾了,因為我們還要用動畫把面板完全展開或者完全折疊。

onStopNestedScroll

一切滾動停止后調用,如果不會發生慣性滾動,fling 相關方法不會調用,直接執行到這里。這里我們做一些清理工作,當然有時也要處理中間態問題。

思路有了,我們直接看代碼就很容易理解了:

 1 @Override
 2 public boolean onStartNestedScroll(CoordinatorLayout coordinatorLayout, RecyclerView child, View directTargetChild, View target, int nestedScrollAxes) {
 3     return (nestedScrollAxes & ViewCompat.SCROLL_AXIS_VERTICAL) != 0;
 4 }
 5 
 6 @Override
 7 public void onNestedScrollAccepted(CoordinatorLayout coordinatorLayout, RecyclerView child, View directTargetChild, View target, int nestedScrollAxes) {
 8     scroller.abortAnimation();
 9     isScrolling = false;
10     super.onNestedScrollAccepted(coordinatorLayout, child, directTargetChild, target, nestedScrollAxes);
11 }
12 
13 @Override
14 public void onNestedPreScroll(CoordinatorLayout coordinatorLayout, RecyclerView child, View target, int dx, int dy, int[] consumed) {
15     if (dy < 0) {
16         return;
17     }
18     View dependentView = getDependentView();
19     float newTranslateY = dependentView.getTranslationY() - dy;
20     float minHeaderTranslate = -(dependentView.getHeight() - getDependentViewCollapsedHeight());
21     if (newTranslateY > minHeaderTranslate) {
22         dependentView.setTranslationY(newTranslateY);
23         consumed[1] = dy;
24     }
25 }
26 
27 @Override
28 public void onNestedScroll(CoordinatorLayout coordinatorLayout, RecyclerView child, View target, int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed) {
29     if (dyUnconsumed > 0) {
30         return;
31     }
32     View dependentView = getDependentView();
33     float newTranslateY = dependentView.getTranslationY() - dyUnconsumed;
34     final float maxHeaderTranslate = 0;
35     if (newTranslateY < maxHeaderTranslate) {
36         dependentView.setTranslationY(newTranslateY);
37     }
38 }
39 
40 @Override
41 public boolean onNestedPreFling(CoordinatorLayout coordinatorLayout, RecyclerView child, View target, float velocityX, float velocityY) {
42     return onUserStopDragging(velocityY);
43 }
44 
45 @Override
46 public void onStopNestedScroll(CoordinatorLayout coordinatorLayout, RecyclerView child, View target) {
47     if (!isScrolling) {
48         onUserStopDragging(800);
49     }
50 }

值得注意的是展開和折疊兩個動作我分別分配到 onNestedPreScrollonNestedScroll 中處理了,為什么這么做呢。我來解釋一下,當 Header 完全展開時,用戶只能向上滑動,此時 onNestedPreScroll 會先調用,我們判斷滾動方向,如果是向上滾動,我們再看面板的位置,如果可以被折疊,那么我們就改變 Header 的 translateY,並且消耗掉相應的像素數。如果 Header 完全折疊了,NSC 就可以繼續滾動了。

任何情況下用戶向下滑動都不會走 onNestedPreScroll,因為我們在這個方法一開始就短路掉了,因此直接到 onNestedScroll,如果 NSC 還可以滾動,那么 dyUnconsumed 就是 0,我們就什么都不需要做了,此時用戶要滾動 NSC,一旦 dyUnconsumed 有數值了,則說明 NSC 滾到頭了,而如果此時正向下滾動,我們就有機會再處理 Header 位移了。這里為什么不放到 onNestedPreScroll 處理呢?因為如果 Header 完全折疊了,RecyclerView 又可以向下滾動,這時我們就不能決定是讓 Header 位移還是 RecyclerView 滾動了,只有讓 RecyclerView 向下滾動到頭才能保證唯一性。

這里比較繞,大家要結合效果好好理解一下。

最后這個類還有一個方法:

 1 private boolean onUserStopDragging(float velocity) {
 2     View dependentView = getDependentView();
 3     float translateY = dependentView.getTranslationY();
 4     float minHeaderTranslate = -(dependentView.getHeight() - getDependentViewCollapsedHeight());
 5 
 6     if (translateY == 0 || translateY == minHeaderTranslate) {
 7         return false;
 8     }
 9 
10     boolean targetState; // Flag indicates whether to expand the content.
11     if (Math.abs(velocity) <= 800) {
12         if (Math.abs(translateY) < Math.abs(translateY - minHeaderTranslate)) {
13             targetState = false;
14         } else {
15             targetState = true;
16         }
17         velocity = 800; // Limit velocity's minimum value.
18     } else {
19         if (velocity > 0) {
20             targetState = true;
21         } else {
22             targetState = false;
23         }
24     }
25 
26     float targetTranslateY = targetState ? minHeaderTranslate : 0;
27     scroller.startScroll(0, (int) translateY, 0, (int) (targetTranslateY - translateY), (int) (1000000 / Math.abs(velocity)));
28     handler.post(flingRunnable);
29     isScrolling = true;
30 
31     return true;
32 }

用來判斷是否處於中間態,如果處於中間態,我們需要根據滑動速度決定最終切換到哪個狀態,這里滾動我們使用 Scroller 配合 Handler 來實現。這個函數的返回值將會被作為 onNestedPreFling 的返回值。

方法中向 Handler 添加的 Runnable 如下:

 1 private Runnable flingRunnable = new Runnable() {
 2     @Override
 3     public void run() {
 4         if (scroller.computeScrollOffset()) {
 5             getDependentView().setTranslationY(scroller.getCurrY());
 6             handler.post(this);
 7         } else {
 8             isExpanded = getDependentView().getTranslationY() != 0;
 9             isScrolling = false;
10         }
11     }
12 };

很簡單就不解釋了。


OK,以上就是 HeaderScrollingBehavior 的全部內容了。

實現 HeaderFloatBehavior

相信大家有了上面的經驗,這個類寫起來就很簡單了。我們只需要實現 layoutDependsOnonDependentViewChanged 就行了。 下面是 onDependentViewChanged 的代碼:

到這里兩個 Behavior 就都寫完了,直接在布局 xml 中引用就可以了,Activity 或 Fragment 中不需要做任何設置,是不是很方便。

總結

CoordinatorLayoutBehavior 結合可以做出十分復雜的界面效果,本文也只是介紹了冰山一角,很難想象沒有它,這些效果的實現將是一件多么復雜的事情 :-)

- EOF -

 
 

 

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM