使用 CoordinatorLayout 實現複雜聯動效果

weixin_34138377發表於2016-12-14

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

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

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


1507403-63c6f206b8be0a49.gif
效果圖

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

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

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

編寫佈局檔案

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

activity_main.xml:

<?xml version="1.0" encoding="utf-8"?>
<android.support.design.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:fitsSystemWindows="false"
    android:background="#fff"
    tools:context="com.example.cyandev.androidplayground.ScrollingActivity">

    <ImageView
        android:id="@+id/scrolling_header"
        android:layout_width="match_parent"
        android:layout_height="200dp"
        android:scaleType="centerCrop"
        android:background="@drawable/bg_header" />

    <LinearLayout
        android:id="@+id/edit_search"
        android:layout_width="match_parent"
        android:layout_height="40dp"
        android:background="@color/colorInitFloatBackground"
        app:layout_behavior="@string/header_float_behavior">
        <TextView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_gravity="center_vertical"
            android:layout_marginStart="20dp"
            android:textColor="#90000000"
            android:text="搜尋關鍵字" />
    </LinearLayout>

    <android.support.v7.widget.RecyclerView
        android:id="@+id/rv"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:background="#fff"
        app:layout_behavior="@string/header_scrolling_behavior"
        app:layoutManager="LinearLayoutManager" />

</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 控制的檢視型別:

public class HeaderScrollingBehavior extends CoordinatorLayout.Behavior<RecyclerView> {

    private boolean isExpanded = false;
    private boolean isScrolling = false;

    private WeakReference<View> dependentView;
    private Scroller scroller;
    private Handler handler;

    public HeaderScrollingBehavior(Context context, AttributeSet attrs) {
        super(context, attrs);
        scroller = new Scroller(context);
        handler = new Handler();
    }

    ...

}

解釋一下這幾個例項變數的作用,Scroller 用來實現使用者釋放手指後的滑動動畫,Handler 用來驅動 Scroller 的執行,而 dependentView 是依賴檢視的一個弱引用,方便我們後面的操作。剩下的是幾個狀態變數,不多解釋了。

我們先看這幾個方法:

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

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

</br>
</br>

@Override
public boolean onLayoutChild(CoordinatorLayout parent, RecyclerView child, int layoutDirection) {
    CoordinatorLayout.LayoutParams lp = (CoordinatorLayout.LayoutParams) child.getLayoutParams();
    if (lp.height == CoordinatorLayout.LayoutParams.MATCH_PARENT) {
        child.layout(0, 0, parent.getWidth(), (int) (parent.getHeight() - getDependentViewCollapsedHeight()));
        return true;
    }
    return super.onLayoutChild(parent, child, layoutDirection);
}

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

</br>
</br>

@Override
public boolean onDependentViewChanged(CoordinatorLayout parent, RecyclerView child, View dependency)
 {
    Resources resources = getDependentView().getResources();
    final float progress = 1.f -
            Math.abs(dependency.getTranslationY() / (dependency.getHeight() - resources.getDimension(R.dimen.collapsed_header_height)));

    child.setTranslationY(dependency.getHeight() + dependency.getTranslationY());

    float scale = 1 + 0.4f * (1.f - progress);
    dependency.setScaleX(scale);
    dependency.setScaleY(scale);

    dependency.setAlpha(progress);

    return true;
}

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

在這個例子中,依賴檢視的屬性影響到了依賴檢視自己的屬性,這也是可以的,因為我們主要依賴的就是 translateY 這個屬性,其他依賴檢視屬性本質就是一個 Computed Property。最後別忘了設定目標檢視的位移,讓其始終跟在 Header View 下面。

</br>
還有兩個便利函式,比較簡單:

private float getDependentViewCollapsedHeight() {
    return getDependentView().getResources().getDimension(R.dimen.collapsed_header_height);
}

private View getDependentView() {
    return dependentView.get();
}

下面我們主要來看看 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 相關方法不會呼叫,直接執行到這裡。這裡我們做一些清理工作,當然有時也要處理中間態問題。

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

@Override
public boolean onStartNestedScroll(CoordinatorLayout coordinatorLayout, RecyclerView child, View directTargetChild, View target, int nestedScrollAxes) {
    return (nestedScrollAxes & ViewCompat.SCROLL_AXIS_VERTICAL) != 0;
}

@Override
public void onNestedScrollAccepted(CoordinatorLayout coordinatorLayout, RecyclerView child, View directTargetChild, View target, int nestedScrollAxes) {
    scroller.abortAnimation();
    isScrolling = false;
    super.onNestedScrollAccepted(coordinatorLayout, child, directTargetChild, target, nestedScrollAxes);
}

@Override
public void onNestedPreScroll(CoordinatorLayout coordinatorLayout, RecyclerView child, View target, int dx, int dy, int[] consumed) {
    if (dy < 0) {
        return;
    }
    View dependentView = getDependentView();
    float newTranslateY = dependentView.getTranslationY() - dy;
    float minHeaderTranslate = -(dependentView.getHeight() - getDependentViewCollapsedHeight());
    if (newTranslateY > minHeaderTranslate) {
        dependentView.setTranslationY(newTranslateY);
        consumed[1] = dy;
    }
}

@Override
public void onNestedScroll(CoordinatorLayout coordinatorLayout, RecyclerView child, View target, int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed) {
    if (dyUnconsumed > 0) {
        return;
    }
    View dependentView = getDependentView();
    float newTranslateY = dependentView.getTranslationY() - dyUnconsumed;
    final float maxHeaderTranslate = 0;
    if (newTranslateY < maxHeaderTranslate) {
        dependentView.setTranslationY(newTranslateY);
    }
}

@Override
public boolean onNestedPreFling(CoordinatorLayout coordinatorLayout, RecyclerView child, View target, float velocityX, float velocityY) {
    return onUserStopDragging(velocityY);
}

@Override
public void onStopNestedScroll(CoordinatorLayout coordinatorLayout, RecyclerView child, View target) {
    if (!isScrolling) {
        onUserStopDragging(800);
    }
}

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

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

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

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

private boolean onUserStopDragging(float velocity) {
    View dependentView = getDependentView();
    float translateY = dependentView.getTranslationY();
    float minHeaderTranslate = -(dependentView.getHeight() - getDependentViewCollapsedHeight());

    if (translateY == 0 || translateY == minHeaderTranslate) {
        return false;
    }

    boolean targetState; // Flag indicates whether to expand the content.
    if (Math.abs(velocity) <= 800) {
        if (Math.abs(translateY) < Math.abs(translateY - minHeaderTranslate)) {
            targetState = false;
        } else {
            targetState = true;
        }
        velocity = 800; // Limit velocity's minimum value.
    } else {
        if (velocity > 0) {
            targetState = true;
        } else {
            targetState = false;
        }
    }

    float targetTranslateY = targetState ? minHeaderTranslate : 0;
    scroller.startScroll(0, (int) translateY, 0, (int) (targetTranslateY - translateY), (int) (1000000 / Math.abs(velocity)));
    handler.post(flingRunnable);
    isScrolling = true;

    return true;
}

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

方法中向 Handler 新增的 Runnable 如下:

private Runnable flingRunnable = new Runnable() {
    @Override
    public void run() {
        if (scroller.computeScrollOffset()) {
            getDependentView().setTranslationY(scroller.getCurrY());
            handler.post(this);
        } else {
            isExpanded = getDependentView().getTranslationY() != 0;
            isScrolling = false;
        }
    }
};

很簡單就不解釋了。


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

實現 HeaderFloatBehavior

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

1507403-43c2f63a74d01e8e.png

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

總結

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

- EOF -

相關文章