Behavior應用--仿知乎日報嵌套滾動效果

越來越多的應用中使用的嵌套滾動的效果,Google也在Material Design中加入了原生支持,CoordinatorLayout、AppbarLayout等控件也能讓我們很方便的實現一些嵌套滾動效果。但是碰到自定義需求時,我們還是需要弄懂CoordinatorLayout這些控件的原理,在此基礎上進行自定義。

Google提供的這套嵌套滾動方案是基於NestedScrollingParent和NestedScrollingChild這兩個接口實現的,像CoordinatorLayout就是實現了NestedScrollingPartent接口。

當我們實現嵌套滾動效果時,我們有2種辦法:
1、自定義view實現NestedScrollingChild或者NestedScrollingParent接口;
2、使用原生控件,對原生控件設置Behavior;
同一個效果,這兩種方法都是可以實現的,看你具體的應用場景,自定選擇,本文講解的是Behavior實現方式。

Behavior是一個抽象類,在我們自定義時,主要關注如下幾個重要方法:

//事件分發攔截
onInterceptTouchEvent(CoordinatorLayout parent, V child, MotionEvent ev)

//child:使用該behavior的view
//dependency:會遍歷查找,你只用在這裏判斷本次傳入的dependency是否符合自己需求,以此返回true or false
layoutDependsOn(CoordinatorLayout parent, V child, View dependency)

//dependency狀態發生變化時,會回調此方法,在這裏可以對child進行操作,實現同步更新的效果。
onDependentViewChanged(CoordinatorLayout parent, V child, View dependency)

//在此寫入自己的邏輯,可以對child進行重佈局
onLayoutChild(CoordinatorLayout parent, V child, int layoutDirection)

//在滑動開始之前,調動此方法。我們實現自己的邏輯設置返回結果(true or false)來決定是否要進行嵌套滑動:當返回值爲true的時候表明CoordinatorLayout 充當NestedScrollingPartent處理這次滑動;若返回false,後續回調將不會觸發,也就無法執行嵌套滾動了。
onStartNestedScroll(CoordinatorLayout coordinatorLayout, V child, View directTargetChild, View target, int nestedScrollAxes)

//onStartNestedScroll返回true纔會回調該方法,參數和onStartNestedScroll一樣,嗯,不知道爲什麼分開。。可以做些初始化工作吧?
onNestedScrollAccepted(CoordinatorLayout coordinatorLayout, V child, View directTargetChild, View target, int nestedScrollAxes)

//嵌套滾動前回調該方法。dx,dy分別是x、y方向上單次滑動(ACTION_DOWN  ACTION_UP之間產生)的距離;consumed數組存放的是child在本次滑動中消耗掉的距離,數據0,1元素分別對應x,y;dy-consumed[1]就是y軸沒有被消耗的距離,這個距離會在後續仍然交由觸發滑動事件的view來消費,x軸同理。如RecyclerView滑動100,執行嵌套滑動,child消費40,那麼RecyclerView將滑動60.
onNestedPreScroll(CoordinatorLayout coordinatorLayout, V child, View target, int dx, int dy, int[] consumed)

//嵌套滾動時調用
onNestedScroll(CoordinatorLayout coordinatorLayout, V child, View target, int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed)

//嵌套滾動完成後調用,進行一些資源釋放回收操作
onStopNestedScroll(CoordinatorLayout coordinatorLayout, V child, View target)

//慣性滾動前調動,依據返回結果決定是否消費慣性滾動
onNestedPreFling(CoordinatorLayout coordinatorLayout, V child, View target, float velocityX, float velocityY)

//慣性滾動回調
onNestedFling(CoordinatorLayout coordinatorLayout, V child, View target, float velocityX, float velocityY, boolean consumed)

基礎知識介紹到此,我們來看看如何實現知乎日報的嵌套滾動效果吧,先看看知乎的效果:

zhihu

我們分析一下這個界面的佈局,可以分爲3部分:
頂部的Toolbar
中間的圖片標題Header
底部的新聞內容Content

再來看看滾動過程,可以分爲2步:
1、Header和Content向上滾動,其中Header滾動速度較慢;Content的頂部到達Toolbar底部時,Toolbar正好透明;
2、Header和Content繼續向上滾動,最終Header被Content覆蓋。

Content移動的距離爲Toolbar的高度 + Header的高度;
Header的滑動速率一直是小於Content,因此Header的滑動距離肯定是小於Content的,他們的速率差其實就是體現在最終滑動距離的差異,因此可以按照百分比來確定Header滑動的距離。(百分比數值影響速率差)

上文介紹Behavior是有dependency這個概念,這個dependency是可以自己對要實現效果的理解來靈活選取的。在這個例子中,我是如下選擇的:
因爲Header總共移動距離header_y是可以預先計算的,設計Content依賴於Header滑動,當Header滑動時,Content的滑動距離都是可以對應計算的。

Toolbar前半段是改變透明度,後半段是移動,這都需要知道Content的狀態,因此選取其dependency爲Content。

具體實現,佈局:

<?xml version="1.0" encoding="utf-8"?>
<android.support.design.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:apps="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <ImageView
        android:id="@+id/iv_header"
        android:layout_width="match_parent"
        android:layout_height="@dimen/news_header_pager_height"
        android:layout_marginTop="@dimen/news_tool_bar_height"
        android:background="@drawable/pic1"
        apps:layout_behavior="com.snick.zzj.myapplication.HeaderViewBehavior"/>

    <!-- AppBarLayout內部實現了NestedScrollingChild接口-->
    <FrameLayout
        android:id="@+id/appbar"
        android:layout_width="match_parent"
        android:layout_height="@dimen/news_tool_bar_height"
        apps:layout_behavior="com.snick.zzj.myapplication.ToolbarBehavior">
        <!-- Toolbar沒有實現NestedScrollingChild接口,因此必須外層嵌套AppBarLayout才能實現嵌套滾動效果-->
        <android.support.v7.widget.Toolbar
            android:id="@+id/toolbar"
            android:layout_width="match_parent"
            android:layout_height="@dimen/news_tool_bar_height"
            android:minHeight="?attr/actionBarSize"
            android:background="@color/colorPrimary">
        </android.support.v7.widget.Toolbar>

    </FrameLayout>

    <android.support.v7.widget.RecyclerView
        android:id="@+id/recycler"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        apps:layout_behavior="com.snick.zzj.myapplication.ContentBehavior">
    </android.support.v7.widget.RecyclerView>

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

我們先來實現HeaderViewBehavior,先貼代碼:

public class HeaderViewBehavior extends CoordinatorLayout.Behavior<ImageView> {
    private static final String TAG = "HeaderViewBehavior";

    private Context context;

    //一定要實現這個構造函數,否則會報Could not inflate Behavior subclass xxx 異常,可查看源碼
    public HeaderViewBehavior(Context context, AttributeSet attrs) {
        super(context, attrs);
        this.context = context;
    }

    @Override
    public boolean onStartNestedScroll(CoordinatorLayout coordinatorLayout, ImageView child, View directTargetChild, View target, int nestedScrollAxes) {
        boolean result = canScroll(child, 0);
        return result;
    }

    //getTranslationY計算的是view對於parent的偏移量
    private boolean canScroll(View child, float pendingDy) {
        int pendingTranslationY = (int) (child.getTranslationY() - pendingDy);
        Log.d(TAG, "canScroll:"+pendingTranslationY+"------"+getHeaderOffsetRange()+"-------"+getHeaderOffsetRangeHideToolBar());
        if (pendingTranslationY >= 0-getHeaderOffsetRange()-getHeaderOffsetRangeHideToolBar() && pendingTranslationY <= 0) {
            return true;
        }
        return false;
    }

    //onNestedPreScroll該方法的會傳入內部View移動的dx,dy,如果你需要消耗一定的dx,dy,
    // 就通過最後一個參數consumed進行指定,例如我要消耗一半的dy,就可以寫consumed[1]=dy/2
    //dy是單次滑動的距離,下次滑動會重新計數
    @Override
    public void onNestedPreScroll(CoordinatorLayout coordinatorLayout, ImageView child, View target, int dx, int dy, int[] consumed) {
        super.onNestedPreScroll(coordinatorLayout, child, target, dx, dy, consumed);
        //dy>0 scroll up;dy<0,scroll down
        float halfOfDis = dy / 4.0f; //消費掉其中的4分之1,不至於滑動效果太靈敏
        //在快速滑動時halfOfDis有可能一次跳變超過20以上,如果原本translationY差19到達頂部,這樣一來就會判斷成無法scroll,造成頂部有縫隙
        if (canScroll(child, halfOfDis)) {
            child.setTranslationY(child.getTranslationY() - halfOfDis);
        } else if(halfOfDis > Math.abs(child.getTranslationY() + getHeaderOffsetRange() + getHeaderOffsetRangeHideToolBar())) {
            Log.d(TAG,"direct to top");
            child.setTranslationY(0-getHeaderOffsetRange()-getHeaderOffsetRangeHideToolBar());
        } else if(halfOfDis < child.getTranslationY()) {
            child.setTranslationY(0);
        }
        //當滑動到頂部時,繼續往上滑應該是不允許滑動,但是向下應該是可以滑動
        //但是我們在onStartNestedScroll中沒法判斷滑動的方向,因此只好在這裏判斷了。

        Log.d(TAG,"Y:"+child.getTranslationY());
        if((dy>0&&child.getTranslationY() == 0-getHeaderOffsetRange()-getHeaderOffsetRangeHideToolBar()) ||
                (dy<0&&child.getTranslationY() == 0))
            consumed[1] = 0;
        else
            consumed[1] = dy;
    }

    //Header偏移量
    private int getHeaderOffsetRange() {
        return context.getResources().getDimensionPixelOffset(R.dimen.header_offset_first);
    }

    //爲了保證Header滑動速率保持一直,第二段的Header移動距離我們計算出來。
    //第一段移動:header移動了header_offset_first的距離,content移動了news_header_pager_height的距離,toolbar透明瞭
    //第二段移動:content移動news_tool_bar_height的距離,toolbar也移動news_tool_bar_height距離,header的距離是可以比例計算的。
    private int getHeaderOffsetRangeHideToolBar() {
        return getHeaderOffsetRange() * context.getResources().getDimensionPixelOffset(R.dimen.news_tool_bar_height)
                / context.getResources().getDimensionPixelOffset(R.dimen.news_header_pager_height);
    }
}

在canScroll方法中處理是否處理嵌套滑動的邏輯;
onNestedPreScroll方法中處理嵌套滑動的邏輯,這裏有2個注意點:
1、在滑動到頂部或者底部時,可能會有最後一組滑動數據無法觸發(可看看代碼中的註釋),我們單獨判斷滑動距離是否超越了頂部或底部邊界,然後直接setTranslationY到頂部或底部;
2、在此例中,Content滑動到頂部時,是不能向上滑,但是可以向下滑,因此在canScroll中需要知道滑動方向,這在onStartNestedScroll中是無法獲取的,因此我放在了onNestedPreScroll中處理:當到達頂部,向上滑時,child並不處理,但是通過consumed數組將該次滑動數據“丟棄”。

再來實現ContentViewBehavior,代碼如下:

public class ContentBehavior extends CoordinatorLayout.Behavior<RecyclerView> {
    private static final String TAG = "ContentBehavior";

    private Context context;

    public ContentBehavior(Context context, AttributeSet attrs) {
        super(context, attrs);
        this.context = context;
    }

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

    @Override
    public boolean onDependentViewChanged(CoordinatorLayout parent, RecyclerView child, View dependency) {
        //初始設置,content要在header之下,因此初始需要有一個Y的偏移
        if(dependency.getTranslationY() == 0)
            child.setTranslationY(getContentInitOffset());
        else {
            child.setTranslationY(getContentInitOffset() +
                    dependency.getTranslationY() * dependency.getHeight() / getHeaderOffsetRange());
        }
        return false;
    }

    private boolean isDependOn(View dependency) {
        return dependency != null && dependency.getId() == R.id.iv_header;
    }

    //Header偏移量
    private int getHeaderOffsetRange() {
        return context.getResources().getDimensionPixelOffset(R.dimen.header_offset_first);
    }

    private int getContentInitOffset() {
        return context.getResources().getDimensionPixelOffset(R.dimen.content_offset_init);
    }

    //爲了保證Header滑動速率保持一直,第二段的Header移動距離我們計算出來。
    //第一段移動:header移動了header_offset_first的距離,content移動了news_header_pager_height的距離,toolbar透明瞭
    //第二段移動:content移動news_tool_bar_height的距離,toolbar也移動news_tool_bar_height距離,header的距離是可以比例計算的。
    private int getHeaderOffsetRangeHideToolBar() {
        return getHeaderOffsetRange() * context.getResources().getDimensionPixelOffset(R.dimen.news_tool_bar_height)
                / context.getResources().getDimensionPixelOffset(R.dimen.news_header_pager_height);
    }
}

主要就是判斷滑動邊界,決定是否進行嵌套滑動;然後獲取滑動距離,自己決定消耗多少。

Toolbar的代碼就不貼了,和Content的類似,比較簡單。

總結下:
有dependency的Behavior實現比較簡單,跟隨dependency的移動做變化就好了;
需要根據滑動狀態來進行滑動的,需要重寫onStartNestedScroll和onNestedPreScroll等方法,比較複雜
同時使用dependency和onNestedPreScroll可以實現更復雜的滾動效果,本例中沒有這麼複雜,無需使用。

貼上最終實現效果圖:

Demo GIF

和知乎的效果還差2部分:
1、fling慣性滑動我還沒有做
2、在Header上滑動時,知乎是可以滑動到,我的Demo無法滑動。maybe知乎是自定義view,我仿知乎日報時注意到這個頁面是個WebView,頂部的Header是一個空白,也許是故意留白,然後疊上一個HeaderView?

最後放上鍊接:
github地址:https://github.com/zzjivan/NestedScrollDemo

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