Android CoordinatorLayout之自定義Behavior

一、認識CoordinatorLayout

CoordinatorLayout作爲support:design庫裏的核心控件,在它出現之前,要實現View之間嵌套滑動等交互操作可不是件容易的事,複雜、難度大,基本繞不開View的事件機制,CoordinatorLayout很大程度上解決了這個痛點,方便我們實現各種炫酷的交互效果。

如果你還沒用過CoordinatorLayout,可先了解它的基本用法

CoordinatorLayout爲何如此強大呢?因爲它的內部類Behavior,這也是CoordinatorLayout的精髓所在。

二、不可不知的Behavior

使用CoordinatorLayout時,會在xml文件中用它作爲根佈局,並給相應的子View添加一個類似app:layout_behavior="@string/appbar_scrolling_view_behavior"的屬性,當然屬性值也可以是其它的。進一步可以發現@string/appbar_scrolling_view_behavior的值是android.support.design.widget.AppBarLayout$ScrollingViewBehavior,不就是support包下一個類的路徑嘛!玄機就在這裏,通過CoordinatorLayout之所以可以實現炫酷的交互效果,Behavior功不可沒。既然如此,我們也可以自定義Behavior,來定製我們想要的效果。

要自定義Behavior,首先認識下它:

public static abstract class Behavior<V extends View> {

    public Behavior() {
    }

    public Behavior(Context context, AttributeSet attrs) {
    }
   //省略了若干方法
}

其中有一個泛型,它的作用是指定要使用這個BehaviorView的類型,可以是ButtonTextView等等。如果希望所有的View都可以使用則指定泛型爲View即可。

自定義Behavior可以選擇重寫以下的幾個方法有:

  • onInterceptTouchEvent():是否攔截觸摸事件
  • onTouchEvent():處理觸摸事件
  • layoutDependsOn():確定使用BehaviorView要依賴的View的類型
  • onDependentViewChanged():當被依賴的View狀態改變時回調
  • onDependentViewRemoved():當被依賴的View移除時回調
  • onMeasureChild():測量使用BehaviorView尺寸
  • onLayoutChild():確定使用BehaviorView位置
  • onStartNestedScroll():嵌套滑動開始(ACTION_DOWN),確定Behavior是否要監聽此次事件
  • onStopNestedScroll():嵌套滑動結束(ACTION_UPACTION_CANCEL
  • onNestedScroll():嵌套滑動進行中,要監聽的子 View的滑動事件已經被消費
  • onNestedPreScroll():嵌套滑動進行中,要監聽的子 View將要滑動,滑動事件即將被消費(但最終被誰消費,可以通過代碼控制)
  • onNestedFling():要監聽的子 View在快速滑動中
  • onNestedPreFling():要監聽的子View即將快速滑動

三、實踐

通常自定義Behavior分爲兩種情況:

  • 某個View依賴另一個View,監聽其位置、尺寸等狀態的變化。
  • 某個View監聽CoordinatorLayout內實現了NestedScrollingChild接口的子View的滑動狀態變化(也是一種依賴關係)。

先看第一種情況,我們要實現的效果如下:
在這裏插入圖片描述
向上滑動列表時,title(TextView)自動下滑,當title全部顯示時,列表頂部和title底部恰好重合,繼續上滑列表時title固定;下滑列表時,當列表頂部和title底部重合時,title開始自動上滑直到完全隱藏。

首先我們定義一個SampleTitleBehavior

public class SampleTitleBehavior extends CoordinatorLayout.Behavior<View> {
    // 列表頂部和title底部重合時,列表的滑動距離。
    private float deltaY;

    public SampleTitleBehavior() {
    }

    public SampleTitleBehavior(Context context, AttributeSet attrs) {
        super(context, attrs);
    }

    @Override
    public boolean layoutDependsOn(CoordinatorLayout parent, View child, View dependency) {
        return dependency instanceof RecyclerView;
    }

    @Override
    public boolean onDependentViewChanged(CoordinatorLayout parent, View child, View dependency) {
        if (deltaY == 0) {
            deltaY = dependency.getY() - child.getHeight();
        }

        float dy = dependency.getY() - child.getHeight();
        dy = dy < 0 ? 0 : dy;
        float y = -(dy / deltaY) * child.getHeight();
        child.setTranslationY(y);

        return true;
    }
}

注意不要忘了重寫兩個參數的構造函數,否則無法在xml文件中使用該Behavior,我們重寫了兩個方法:

  • layoutDependsOn():使用該BehaviorView要監聽哪個類型的View的狀態變化。其中參數parant代表CoordinatorLayoutchild代表使用該BehaviorViewdependency代表要監聽的View。這裏要監聽RecyclerView
  • onDependentViewChanged():當被監聽的View狀態變化時會調用該方法,參數和上一個方法一致。所以我們重寫該方法,當RecyclerView的位置變化時,進而改變title的位置。

一般情況這兩個方法是一組,這樣一個簡單的Behavior就完成了,使用也很簡單,仿照系統的用法,先在strings.xml中記錄其全包名路徑(當然不是必須的,下一遍會講到):

<string name="behavior_sample_title">com.othershe.behaviortest.test1.SampleTitleBehavior</string>

然後是佈局文件

<?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"
    tools:context="com.othershe.behaviortest.test1.TestActivity1">

    <android.support.design.widget.AppBarLayout
        android:id="@+id/app_bar"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        app:elevation="0dp">

        <android.support.design.widget.CollapsingToolbarLayout
            android:id="@+id/toolbar_layout"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            app:contentScrim="#00ffffff"
            app:layout_scrollFlags="scroll|exitUntilCollapsed">

            <ImageView
                android:layout_width="match_parent"
                android:layout_height="200dp"
                android:background="@mipmap/bg"
                android:fitsSystemWindows="true"
                android:scaleType="fitXY"
                app:layout_collapseMode="parallax"
                app:layout_collapseParallaxMultiplier="0.7" />

        </android.support.design.widget.CollapsingToolbarLayout>

    </android.support.design.widget.AppBarLayout>

    <android.support.v7.widget.RecyclerView
        android:id="@+id/my_list"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        app:layout_behavior="@string/appbar_scrolling_view_behavior" />

    <TextView
        android:id="@+id/title"
        android:layout_width="match_parent"
        android:layout_height="50dp"
        android:background="#ff0000"
        android:gravity="center"
        android:text="Hello World"
        android:textColor="#ffffff"
        android:textSize="18sp"
        app:layout_behavior="@string/behavior_sample_title" />

</android.support.design.widget.CoordinatorLayout>

我們給TextView設置了該Behavior

除了實現title的位置變化,要實現透明度變化也是很簡單的,對SampleTitleBehavior做如下修改即可:

@Override
public boolean onDependentViewChanged(CoordinatorLayout parent, View child, View dependency) {
    if (deltaY == 0) {
        deltaY = dependency.getY() - child.getHeight();
    }

    float dy = dependency.getY() - child.getHeight();
    dy = dy < 0 ? 0 : dy;
    float alpha = 1 - (dy / deltaY);
    child.setAlpha(alpha);

    return true;
}

修改後的效果如下:
在這裏插入圖片描述
第二種情況,我們的目標效果如下:
在這裏插入圖片描述
簡單解釋一下,該佈局由RecylerView列表和一個TextView組成,其中RecylerView實現了NestedScrollingChild接口,所以TextView監聽RecylerView的滑動狀態。開始向上滑動列表時TextView和列表整體上移,直到TextView全部隱藏停止,再次上滑則列表內容上移。之後連續下滑列表當其第一個item全部顯示時列表滑動停止,再次下滑列表時TextView跟隨列表整體下移,直到TextView全部顯示。(有點繞,上手體會下…)

這裏涉及兩個自定義Behavior,第一個實現垂直方向滑動列表時,TextView上移或下移的功能,但此時TextView會覆蓋在RecyclerView上其實CoordinatorLayout有種FrameLayout的即視感),所以第二個的作用就是解決這個問題,實現RecyclerView固定在TextView下邊並跟隨TextView移動,可以發現這兩個View是相互依賴的。

先看第一個Behavior,代碼如下:

public class SampleHeaderBehavior extends CoordinatorLayout.Behavior<TextView> {

    // 界面整體向上滑動,達到列表可滑動的臨界點
    private boolean upReach;
    // 列表向上滑動後,再向下滑動,達到界面整體可滑動的臨界點
    private boolean downReach;
    // 列表上一個全部可見的item位置
    private int lastPosition = -1;

    public SampleHeaderBehavior() {
    }

    public SampleHeaderBehavior(Context context, AttributeSet attrs) {
        super(context, attrs);
    }

    @Override
    public boolean onInterceptTouchEvent(CoordinatorLayout parent, TextView child, MotionEvent ev) {
        switch (ev.getAction()) {
            case MotionEvent.ACTION_DOWN:
                downReach = false;
                upReach = false;
                break;
        }
        return super.onInterceptTouchEvent(parent, child, ev);
    }

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

    @Override
    public void onNestedPreScroll(@NonNull CoordinatorLayout coordinatorLayout, @NonNull TextView child, @NonNull View target, int dx, int dy, @NonNull int[] consumed, int type) {
        super.onNestedPreScroll(coordinatorLayout, child, target, dx, dy, consumed, type);
        if (target instanceof RecyclerView) {
            RecyclerView list = (RecyclerView) target;
            // 列表第一個全部可見Item的位置
            int pos = ((LinearLayoutManager) list.getLayoutManager()).findFirstCompletelyVisibleItemPosition();
            if (pos == 0 && pos < lastPosition) {
                downReach = true;
            }
            // 整體可以滑動,否則RecyclerView消費滑動事件
            if (canScroll(child, dy) && pos == 0) {
                float finalY = child.getTranslationY() - dy;
                if (finalY < -child.getHeight()) {
                    finalY = -child.getHeight();
                    upReach = true;
                } else if (finalY > 0) {
                    finalY = 0;
                }
                child.setTranslationY(finalY);
                // 讓CoordinatorLayout消費滑動事件
                consumed[1] = dy;
            }
            lastPosition = pos;
        }
    }

    private boolean canScroll(View child, float scrollY) {
        if (scrollY > 0 && child.getTranslationY() == -child.getHeight() && !upReach) {
            return false;
        }

        if (downReach) {
            return false;
        }
        return true;
    }
}

這裏主要關注這兩個重寫的方法(這裏涉及NestedScrolling機制,下一篇會講到):

  • onStartNestedScroll():表示是否監聽此次RecylerView的滑動事件,這裏我們只監聽其垂直方向的滑動事件
  • onNestedPreScroll():處理監聽到的滑動事件,實現整體滑動和列表單獨滑動(header是否完全隱藏是滑動的臨界點)。

第二個Behavior就簡單了,就是第一種情況,當header位置變化時,改變列表y座標,代碼如下:

public class RecyclerViewBehavior extends CoordinatorLayout.Behavior<RecyclerView> {

    public RecyclerViewBehavior() {
    }

    public RecyclerViewBehavior(Context context, AttributeSet attrs) {
        super(context, attrs);
    }

    @Override
    public boolean layoutDependsOn(CoordinatorLayout parent, RecyclerView child, View dependency) {
        return dependency instanceof TextView;
    }

    @Override
    public boolean onDependentViewChanged(CoordinatorLayout parent, RecyclerView child, View dependency) {
        //計算列表y座標,最小爲0
        float y = dependency.getHeight() + dependency.getTranslationY();
        if (y < 0) {
            y = 0;
        }
        child.setY(y);
        return true;
    }
}

Behavior加到strings.xml中:

<string name="behavior_sample_header">com.othershe.behaviortest.test2.SampleHeaderBehavior</string>
<string name="behavior_recyclerview">com.othershe.behaviortest.test2.RecyclerViewBehavior</string>

在佈局文件中的使用:

<?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"
    tools:context="com.othershe.behaviortest.test2.TestActivity2">

    <TextView
        android:id="@+id/header"
        android:layout_width="match_parent"
        android:layout_height="200dp"
        android:background="#ff0000"
        android:gravity="center"
        android:text="Hello World"
        android:textColor="#ffffff"
        android:textSize="18sp"
        app:layout_behavior="@string/behavior_sample_header" />

    <android.support.v7.widget.RecyclerView
        android:id="@+id/my_list"
        android:layout_width="match_parent"
        app:layout_behavior="@string/behavior_recyclerview"
        android:layout_height="wrap_content" />

</android.support.design.widget.CoordinatorLayout>

自定義Behavior的基本用法就這些了,主要就是確定View之間的依賴關係(也可以理解爲監聽關係),當被依賴View的狀態變化時,相應View的狀態進而改變。

掌握了自定義Behavior,可以嘗試實現更復雜的交互效果,如下demo(原理參考了自定義Behavior的藝術探索-仿UC瀏覽器主頁),並添加了header滑動手勢、列表下滑展開header的操作:
在這裏插入圖片描述
再進一步簡化修改,就實現了類似Android版蝦米音樂播放頁的手勢效果:
在這裏插入圖片描述
簡單的分析一下最後一個效果,界面由header、title、list三部分組成,初始狀態如下:
在這裏插入圖片描述
title此時在屏幕頂部外,則其初始y座標爲-titleHeight;header在屏幕頂部,相當於其默認y座標爲0;list在header下邊,則其初始y座標是headerHeight。初始狀態上滑header或list則list上移、title下移,同時header向上偏移,最大偏移值headerOffset。當header達到最大偏移值時title全部顯示其底部和list頂部重合,list和title的位移結束,此時title下移距離爲titleHeight,其y座標爲0,即y座標的變化範圍從-titleHeight到0;而list的上移距離爲headerHeight - titleHeight,此時其y值爲titleHeight,y座標的變化範圍從headerHeight到headerHeight - titleHeight(下滑過程也類似,就不分析了)。上滑結束狀態如下:
在這裏插入圖片描述
可以發現我們是以header向上偏移是否結束爲臨界點,來決定list、title是否繼續位移,所以可以用header作爲被依賴對象,在滑動過程中,計算header的translationY和最大偏移值headerOffset的比例進而計算title和list的y座標來完成位移,剩下就是編寫Behavior了。這裏有一點需要注意,list的高度在界面初始化後已經完成測量,上滑時根據header的偏移改變list的y座標使其移動,會出現list顯示不全的問題!

還記得第一個demo嗎,也是header+list的形式,但沒有這個問題,可以參考一下哦,其佈局文件中使用了AppBarLayout和它下邊的Behavior名爲appbar_scrolling_view_behaviorRecyclerView,其實AppBarLayout也使用了一個Behavior,只不過是通過註解來設置的(後邊會講到),它繼承自ViewOffsetBehavior,由於ViewOffsetBehavior是包私有的,我們拷貝一份,讓我們headerBehavior也繼承ViewOffsetBehavior,上邊appbar_scrolling_view_behavior對應的Behavior繼承自HeaderScrollingViewBehavior,它同樣也是私有的,拷貝一份,讓listBehavior繼承自它,這樣問題就解決了!這裏只是簡單的原理分析,代碼就不貼了,有興趣的可以看源碼!

下一篇,CoordinatorLayout之源碼解析,更深入的學習Behavior,有助理解最後的demo,再見!

demo地址!

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章