使用 CoordinatorLayout 實(shí)現(xiàn)復(fù)雜聯(lián)動(dòng)效果

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

CoordinatorLayout 是 Google 在 Design Support 包中提供的一個(gè)十分強(qiáng)大的布局視圖,它本質(zhì)是一個(gè) FrameLayout,然而它允許開(kāi)發(fā)者通過(guò)制定 Behavior 從而實(shí)現(xiàn)各種復(fù)雜的 UI 效果。

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


效果圖

我們先歸納一下整個(gè)效果的細(xì)節(jié):

  • 界面分為上下兩部分,上部分隨列表滑動(dòng)而折疊與展開(kāi);
  • 頭部視圖背景隨折疊狀態(tài)而縮放和漸變;
  • 浮動(dòng)搜索框隨折疊狀態(tài)改變位置和 margins;
  • 滑動(dòng)結(jié)束前會(huì)根據(jù)滑動(dòng)速度動(dòng)畫到相應(yīng)的狀態(tài):
  • 如果速度達(dá)到一定閾值,則按速度方向切換狀態(tài)
  • 如果速度未達(dá)到閾值,則切換到距離當(dāng)前狀態(tài)最近的狀態(tài);

主要的細(xì)節(jié)就是這些,下面我們來(lái)一步步實(shí)現(xiàn)它!

編寫布局文件

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

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="搜索關(guān)鍵字" />
    </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 子視圖的層級(jí)關(guān)系,如果想在子視圖中使用 Behavior 進(jìn)行控制,那么這個(gè)子視圖一定是 CoordinatorLayout 的直接孩子,間接子視圖是不具有 behavior 屬性的,原因當(dāng)然也很簡(jiǎn)單,behavior 是 LayoutParams 的一個(gè)屬性,而間接子視圖的 LayoutParams 根本不是 CoordinatorLayout 類型的。

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

Behavior 基本概念

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

依賴視圖

一個(gè) Behavior 能夠?qū)⒅付ǖ囊晥D作為一個(gè)依賴項(xiàng),并且監(jiān)聽(tīng)這個(gè)依賴項(xiàng)的一切布局信息,一旦依賴項(xiàng)發(fā)生變化,Behavior 就可以做出適當(dāng)?shù)捻憫?yīng)。很簡(jiǎn)單的例子就是 FABSnackBar 的聯(lián)動(dòng),具體表現(xiàn)就是 FAB 會(huì)隨 SnackBar 的彈出而上移,從而不會(huì)被 SnackBar 遮擋,這就是依賴視圖的最簡(jiǎn)單的一個(gè)用法。

Nested Scrolling

這是 Google 開(kāi)發(fā)的一種全新嵌套滾動(dòng)方案,由 NestedScrollingParentNestedScrollingChild 組成,一般來(lái)講我們都會(huì)圍繞 NestedScrollingParent 來(lái)進(jìn)行開(kāi)發(fā),而 NestedScrollingChild 相比來(lái)說(shuō)較為復(fù)雜,本文也不贅述其具體用法了。NestedScrollingParent(下文簡(jiǎn)稱 NSP) 和 NestedScrollingChild(下文簡(jiǎn)稱 NSC) 有一組相互配對(duì)的事件方法,NSC 負(fù)責(zé)派發(fā)這些方法到 NSPNSP 可以對(duì)這些方法做出響應(yīng)。同時(shí) Google 也提供了一組 Helper 類來(lái)幫助開(kāi)發(fā)者使用 NSPNSC,其中 NestedScrollingParentHelper 較為簡(jiǎn)單,僅是記錄一下滾動(dòng)的方向。對(duì)于 Nested Scrolling 的具體用法,我在下文中會(huì)詳細(xì)講解。

案例 Behavior 實(shí)現(xiàn)思路

我們最終需要實(shí)現(xiàn)兩個(gè) Behavior 類:
HeaderScrollingBehavior 負(fù)責(zé)協(xié)調(diào) RecyclerView 與 Header View 的關(guān)系,同時(shí)它依賴于 Header View,因?yàn)樗鶕?jù) Header View 的位移調(diào)整自己的位置。
HeaderFloatBehavior 負(fù)責(zé)協(xié)調(diào)搜索框與 Header View 的關(guān)系,也是依賴于 Header View,相對(duì)比較簡(jiǎn)單。

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

實(shí)現(xiàn) HeaderScrollingBehavior

首先繼承自 Behavior,這是一個(gè)范型類,范型類型為被 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();
    }

    ...

}

解釋一下這幾個(gè)實(shí)例變量的作用,Scroller 用來(lái)實(shí)現(xiàn)用戶釋放手指后的滑動(dòng)動(dòng)畫,Handler 用來(lái)驅(qū)動(dòng) Scroller 的運(yùn)行,而 dependentView 是依賴視圖的一個(gè)弱引用,方便我們后面的操作。剩下的是幾個(gè)狀態(tài)變量,不多解釋了。

我們先看這幾個(gè)方法:

@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;
}

負(fù)責(zé)查詢?cè)?Behavior 是否依賴于某個(gè)視圖,我們?cè)谶@里判讀視圖是否為 Header View,如果是則返回 true,那么之后其他操作就會(huì)圍繞這個(gè)依賴視圖而進(jìn)行了。

</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);
}

負(fù)責(zé)對(duì)被 Behavior 控制的視圖進(jìn)行布局,就是將 ViewGrouponLayout 針對(duì)該視圖的部分抽出來(lái)給 Behavior 處理。我們判斷一下如果目標(biāo)視圖高度要填充父視圖,我們就自己將其高度減去 Header View 折疊后的高度。為什么要這么做呢?因?yàn)?CoodinatorLayout 就是一個(gè) FrameLayout,不像 LinearLayout 一樣能自動(dòng)分配各個(gè) View 的高度,因此我們要自己實(shí)現(xiàn)大小控制。

</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;
}

這段就是根據(jù)依賴視圖進(jìn)行調(diào)整的方法,當(dāng)依賴視圖發(fā)生變化時(shí),這個(gè)方法就會(huì)被調(diào)用。這里我把相關(guān)的尺寸數(shù)據(jù)寫到了 dimens.xml 中,通過(guò)當(dāng)前依賴視圖的位移,計(jì)算出一個(gè)位移因數(shù)(取值 0 - 1),對(duì)應(yīng)到依賴視圖的縮放和透明度。

在這個(gè)例子中,依賴視圖的屬性影響到了依賴視圖自己的屬性,這也是可以的,因?yàn)槲覀冎饕蕾嚨木褪?translateY 這個(gè)屬性,其他依賴視圖屬性本質(zhì)就是一個(gè) Computed Property。最后別忘了設(shè)置目標(biāo)視圖的位移,讓其始終跟在 Header View 下面。

</br>
還有兩個(gè)便利函數(shù),比較簡(jiǎn)單:

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

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

下面我們主要來(lái)看看 Nested Scrolling 怎么實(shí)現(xiàn)。

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

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

onStartNestedScroll

用戶按下手指時(shí)觸發(fā),詢問(wèn) NSP 是否要處理這次滑動(dòng)操作,如果返回 true 則表示“我要處理這次滑動(dòng)”,如果返回 false 則表示“我不 care 你的滑動(dòng),你想咋滑就咋滑”,后面的一系列回調(diào)函數(shù)就不會(huì)被調(diào)用了。它有一個(gè)關(guān)鍵的參數(shù),就是滑動(dòng)方向,表明了用戶是垂直滑動(dòng)還是水平滑動(dòng),本例子只需考慮垂直滑動(dòng),因此判斷滑動(dòng)方向?yàn)榇怪睍r(shí)就處理這次滑動(dòng),否則就不 care。

onNestedScrollAccepted

當(dāng) NSP 接受要處理本次滑動(dòng)后,這個(gè)回調(diào)被調(diào)用,我們可以做一些準(zhǔn)備工作,比如讓之前的滑動(dòng)動(dòng)畫結(jié)束。

onNestedPreScroll

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

onNestedScroll

上一個(gè)方法結(jié)束后,NSC 處理剩下的距離。比如上面還剩 10px,這里 NSC 滾動(dòng) 2px 后發(fā)現(xiàn)已經(jīng)到頭了,于是 NSC 結(jié)束其滾動(dòng),調(diào)用該方法,并將 NSC 處理剩下的像素?cái)?shù)作為參數(shù)(dxUnconsumeddyUnconsumed)傳過(guò)來(lái),這里傳過(guò)來(lái)的就是 8px。參數(shù)中還會(huì)有 NSC 處理過(guò)的像素?cái)?shù)(dxConsumeddyConsumed)。這個(gè)方法主要處理一些越界后的滾動(dòng)。

onNestedPreFling

用戶松開(kāi)手指并且會(huì)發(fā)生慣性滾動(dòng)之前調(diào)用。參數(shù)提供了速度信息,我們這里可以根據(jù)速度,決定最終的狀態(tài)是展開(kāi)還是折疊,并且啟動(dòng)滑動(dòng)動(dòng)畫。通過(guò)返回值我們可以通知 NSC 是否自己還要進(jìn)行滑動(dòng)滾動(dòng),一般情況如果面板處于中間態(tài),我們就不讓 NSC 接著滾了,因?yàn)槲覀冞€要用動(dòng)畫把面板完全展開(kāi)或者完全折疊。

onStopNestedScroll

一切滾動(dòng)停止后調(diào)用,如果不會(huì)發(fā)生慣性滾動(dòng),fling 相關(guān)方法不會(huì)調(diào)用,直接執(zhí)行到這里。這里我們做一些清理工作,當(dāng)然有時(shí)也要處理中間態(tài)問(wèn)題。

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

@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);
    }
}

值得注意的是展開(kāi)和折疊兩個(gè)動(dòng)作我分別分配到 onNestedPreScrollonNestedScroll 中處理了,為什么這么做呢。我來(lái)解釋一下,當(dāng) Header 完全展開(kāi)時(shí),用戶只能向上滑動(dòng),此時(shí) onNestedPreScroll 會(huì)先調(diào)用,我們判斷滾動(dòng)方向,如果是向上滾動(dòng),我們?cè)倏疵姘宓奈恢茫绻梢员徽郫B,那么我們就改變 Header 的 translateY,并且消耗掉相應(yīng)的像素?cái)?shù)。如果 Header 完全折疊了,NSC 就可以繼續(xù)滾動(dòng)了。

任何情況下用戶向下滑動(dòng)都不會(huì)走 onNestedPreScroll,因?yàn)槲覀冊(cè)谶@個(gè)方法一開(kāi)始就短路掉了,因此直接到 onNestedScroll,如果 NSC 還可以滾動(dòng),那么 dyUnconsumed 就是 0,我們就什么都不需要做了,此時(shí)用戶要滾動(dòng) NSC,一旦 dyUnconsumed 有數(shù)值了,則說(shuō)明 NSC 滾到頭了,而如果此時(shí)正向下滾動(dòng),我們就有機(jī)會(huì)再處理 Header 位移了。這里為什么不放到 onNestedPreScroll 處理呢?因?yàn)槿绻?Header 完全折疊了,RecyclerView 又可以向下滾動(dòng),這時(shí)我們就不能決定是讓 Header 位移還是 RecyclerView 滾動(dòng)了,只有讓 RecyclerView 向下滾動(dòng)到頭才能保證唯一性。

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

最后這個(gè)類還有一個(gè)方法:

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;
}

用來(lái)判斷是否處于中間態(tài),如果處于中間態(tài),我們需要根據(jù)滑動(dòng)速度決定最終切換到哪個(gè)狀態(tài),這里滾動(dòng)我們使用 Scroller 配合 Handler 來(lái)實(shí)現(xiàn)。這個(gè)函數(shù)的返回值將會(huì)被作為 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;
        }
    }
};

很簡(jiǎn)單就不解釋了。


OK,以上就是 HeaderScrollingBehavior 的全部?jī)?nèi)容了。

實(shí)現(xiàn) HeaderFloatBehavior

相信大家有了上面的經(jīng)驗(yàn),這個(gè)類寫起來(lái)就很簡(jiǎn)單了。我們只需要實(shí)現(xiàn) layoutDependsOnonDependentViewChanged 就行了。
下面是 onDependentViewChanged 的代碼:

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

總結(jié)

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

- EOF -

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

推薦閱讀更多精彩內(nèi)容