1. 程式人生 > >Android巢狀滑動-Behavior方案實戰及細節注意

Android巢狀滑動-Behavior方案實戰及細節注意

筆者在2013年就收到Android巢狀滑動的UI效果需求,當時都是直接從監聽滑動事件分發做起,至今再次收到這種類似的需求,一直以來想更新下之前的實現方式,相對於Behavior封裝過的方案而言畢竟不夠優雅,現就介紹前後兩種方案。

  • 老方案的思路

    這種方式是相關api直接使用,其他的封裝方式(包括behavoir)都是基於此封裝而來,直接重寫父類(ViewGroup)的事件分發機制:dispatchTouchEvent、onInterceptTouchEvent、onTouchEvent等方法,手動事件分發,當屬於邏輯外層滑動時候,進行攔截,滿足一定條件之後,再重新分發事件給相關子巢狀的滾動View。這裡面程式碼實現就不展示出來,有點歷史,思路在此,不過實現中會有些問題。
    例如當重新把move事件分發給子View時,這時子View突然接受到move事件,沒有完整的流程經歷down事件會導致未初始化而不能響應move事件,就是常見的不能連續滑動的根本原因;其次就是攔截事件不要攔截down事件,會導致某個view點選事件不能響應,滑動都應該只是針對move事件攔截。

  • Behavior方式

    在說Behavior之前先簡單提下巢狀滑動在5.0之後新增的Api:NestedScrollingParent、NestedScrollingChild以及相應的Helper類,具體介紹不是重點,分別實現這些介面的父View和子View類就能夠實現父View對子View巢狀滑動的監聽,同時父View和子View之間不一定是直接的上下層關係,子View可以是父view下任意子View,例如NestedScrollView、RecyclerView、CoordinatorLayout(本文重點,下面再講)都分別實現這兩個介面中一個或兩個,當然我們可以自定義ViewGroup實現NestedScrollingParent來監聽子View的巢狀滑動,貼下程式碼:

CustomNestedScrollLinearLayout .class

public class CustomNestedScrollLinearLayout extends LinearLayout implements NestedScrollingParent {
    View mchild, mRecyc, mTitle;

    public CustomNestedScrollLinearLayout(Context context) {
        super(context);
    }

    public CustomNestedScrollLinearLayout
(Context context, @Nullable AttributeSet attrs) { super(context, attrs); } @Override protected void onFinishInflate() { super.onFinishInflate(); mchild = findViewById(R.id.move); mRecyc = findViewById(R.id.recyclerView); mTitle = findViewById(R.id.title); } @Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { super.onMeasure(widthMeasureSpec, heightMeasureSpec); ViewGroup.LayoutParams params = mRecyc.getLayoutParams(); params.height = getMeasuredHeight() - findViewById(R.id.title).getMeasuredHeight(); } @Override public boolean onStartNestedScroll(@NonNull View child, @NonNull View target, int axes) { Log.i("onLayoutChild", "target=" + target.getHeight()); return (axes & ViewCompat.SCROLL_AXIS_VERTICAL) != 0; } @Override public void onNestedPreScroll(@NonNull View target, int dx, int dy, @NonNull int[] consumed) { int bottom = mchild.getBottom(); int chileHeight = mchild.getHeight(); Log.i("onNestedScroll", "onNestedPreScroll dy=" + dy + " bottom=" + bottom); if (dy > 0 && bottom > 0) { int left = bottom - dy; if (left >= 0) { consumed[1] = dy; } else { consumed[1] =bottom; } mchild.offsetTopAndBottom(-consumed[1]); mTitle.offsetTopAndBottom(-consumed[1]); target.offsetTopAndBottom(-consumed[1]); } } @Override public void onNestedScroll(View target, int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed) { super.onNestedScroll(target, dxConsumed, dyConsumed, dxUnconsumed, dyUnconsumed); Log.i("onNestedScroll", "onNestedScroll dyConsumed=" + dyConsumed + " dyUnconsumed=" + dyUnconsumed); if (dyUnconsumed > 0) { return; } int bottom = mchild.getBottom(); int chileHeight = mchild.getHeight(); if (dyUnconsumed < 0 && bottom < chileHeight) { int left = bottom - dyUnconsumed; int consumed; if (left <= chileHeight) { consumed = dyUnconsumed; } else { consumed = -chileHeight + bottom; } mchild.offsetTopAndBottom(-consumed); mTitle.offsetTopAndBottom(-consumed); target.offsetTopAndBottom(-consumed); } } @Override public void onStopNestedScroll(View child) { Log.i("onNestedScroll", "onStopNestedScroll child=" + child.getClass().getSimpleName()); super.onStopNestedScroll(child); } @Override public boolean onNestedPreFling(View target, float velocityX, float velocityY) { Log.i("onNestedScroll", "onNestedPreFling target=" + target.getClass().getSimpleName() + " velocityY=" + velocityY); return super.onNestedPreFling(target, velocityX, velocityY); } }

佈局程式碼:

<?xml version="1.0" encoding="utf-8"?>
<statistics.ymm.com.myapplication.CustomNestedScrollLinearLayout 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:orientation="vertical"
    tools:context=".MainActivity">


    <TextView
        android:id="@+id/move"
        android:layout_width="match_parent"
        android:layout_height="200dp"
        android:background="@color/colorAccent"
        android:gravity="center"
        android:text="Hello World!" />

       <TextView
        android:id="@+id/title"
        android:layout_width="match_parent"
        android:layout_height="20dp"
        android:background="@android:color/darker_gray"
        android:gravity="center"
        android:text="title" />

    <android.support.v7.widget.RecyclerView
        android:id="@+id/recyclerView"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:background="@color/colorPrimary"
        app:layout_behavior="statistics.ymm.com.myapplication.MyBehavior" />


</statistics.ymm.com.myapplication.CustomNestedScrollLinearLayout>

原理本文篇幅不夠,不想寫,之前都是寫關於原理篇,可以百度,今天搞個實戰篇,第一次貼程式碼,拿走就用,不謝。

當然這不是本文重點,確實基礎,上文提到了CoordinatorLayout,其中Behavior是就是CoordinatorLayout的靜態內部類,對其可以簡單理解為在CoordinatorLayout實現NestedScrollingParent2之後,接受到子View的滑動通知之後,把直接通過子View的Behavior來通知回撥(注意是直接子View,因為Behavior是CoordinatorLayout.LayoutParams的元素,只能解析直接子View的Behavoir配置),Behavior提供了很多回調,包括了巢狀滑動相關的介面方法。廢話不多說,上程式碼:

public class MyBehavior extends CoordinatorLayout.Behavior<View> {

    private WeakReference<View> dependentView;

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

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

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

    @Override
    public boolean onDependentViewChanged(CoordinatorLayout parent, View child, View dependency) {
        child.setTranslationY(dependency.getHeight() + dependency.getTranslationY());
        return true;
    }

    @Override
    public boolean onStartNestedScroll(@NonNull CoordinatorLayout coordinatorLayout, @NonNull View 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 View child, @NonNull View target, int dx, int dy, @NonNull int[] consumed, int type) {
        super.onNestedPreScroll(coordinatorLayout, child, target, dx, dy, consumed, type);

        if (dy < 0) {
            return;
        }
        View dependentView = getDependentView();
        float newTranslateY = dependentView.getTranslationY() - dy;
        float minHeaderTranslate = -(dependentView.getHeight());
        Log.i("onLayoutChild", "onNestedPreScroll dy=" + dy + "TranslationY='" + dependentView.getTranslationY());
        if (newTranslateY >= minHeaderTranslate) {
            dependentView.setTranslationY(newTranslateY);
            consumed[1] = dy;
        } else {
            if (dependentView.getTranslationY() >= -minHeaderTranslate) {
                consumed[1] = (int) (dependentView.getTranslationY() - minHeaderTranslate);
            }
            dependentView.setTranslationY(minHeaderTranslate);

        }
    }


    @Override
    public void onNestedScroll(@NonNull CoordinatorLayout coordinatorLayout, @NonNull View child, @NonNull View target, int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed, int type) {

        if (dyUnconsumed > 0) {
            return;
        }
        View dependentView = getDependentView();
        float currentTranslationY = dependentView.getTranslationY();
        float newTranslateY = currentTranslationY - dyUnconsumed;
        final float maxHeaderTranslate = 0;
        Log.i("onLayoutChild", "onNestedScroll dyUnconsumed=" + dyUnconsumed + "currentTranslationY="+currentTranslationY);
        if (newTranslateY <= maxHeaderTranslate) {
            dependentView.setTranslationY(newTranslateY);
        } else {
            dependentView.setTranslationY(maxHeaderTranslate);
        }


        super.onNestedScroll(coordinatorLayout, child, target, dxConsumed, dyConsumed, dxUnconsumed, dyUnconsumed, type);
    }

    @Override
    public boolean onLayoutChild(CoordinatorLayout parent, View child, int layoutDirection) {

        return super.onLayoutChild(parent, child, layoutDirection);
    }

    @Override
    public boolean onMeasureChild(CoordinatorLayout parent, View child, int parentWidthMeasureSpec, int widthUsed, int parentHeightMeasureSpec, int heightUsed) {

        return super.onMeasureChild(parent, child, parentWidthMeasureSpec, widthUsed, parentHeightMeasureSpec, heightUsed);
    }
}

佈局程式碼:

<?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:orientation="vertical"
    tools:context=".MainActivity">

    <TextView
        android:id="@+id/move"
        android:layout_width="match_parent"
        android:layout_height="200dp"
        android:background="@color/colorAccent"
        android:gravity="center"
        android:text="Hello World!" />
    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:orientation="vertical"
        app:layout_behavior="statistics.ymm.com.myapplication.MyBehavior">

        <TextView
            android:id="@+id/title"
            android:layout_width="match_parent"
            android:layout_height="20dp"
            android:background="@android:color/darker_gray"
            android:gravity="center"
            android:text="title"
             />

        <android.support.v7.widget.RecyclerView
            android:id="@+id/recyclerView"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:background="@color/colorPrimary"
  />
    </LinearLayout>
</android.support.design.widget.CoordinatorLayout>

效果圖
這裡寫圖片描述
這裡寫圖片描述

這裡面通過滑動下面的list,會先讓紅色區域的Hello World!先移動,直到消失之後,list才滑動,中間連貫,不中斷,連續滑動,title停在頂層不動。基本滿足個人的需求,直接但是如果上面紅色header過長話,希望能通過滑動header(Helll World!區域)也能滑動整頁,而不是僅僅通過列表滑動來觸發的滑動,這時候巢狀滑動就不夠滿足。上文也提到了Behavior有好多其他回撥介面,要想實現Header滑動導致整頁滑動,故此我們必須監聽Header上面的滑動事件觸發,肯定會想到重寫Header的事件分發,這會顯得麻煩。Behavior就提供了View滑動事件的攔截監聽,直接貼程式碼。

 @Override
    public boolean onInterceptTouchEvent(CoordinatorLayout parent, View child, MotionEvent ev) {
        int dy = (int) ev.getY();
//        Log.i("chuan", "onInterceptTouchEvent=" + ev.getAction() + "dy=" + dy);
        View dependView = getDependentView();
        if (dependView == null) {
            return super.onInterceptTouchEvent(parent, child, ev);
        }

        switch (ev.getAction()) {
            case MotionEvent.ACTION_DOWN:
                downY = (int) ev.getY();
                break;
            case MotionEvent.ACTION_MOVE:
                lastY = dy;
                if (Math.abs(lastY - downY) > 1 && dy < (dependView.getMeasuredHeight() + dependView.getTranslationY())) {
                    return true;
                }
                break;
            default:
                break;
        }

        return super.onInterceptTouchEvent(parent, child, ev);
    }

    int lastY;

    @Override
    public boolean onTouchEvent(CoordinatorLayout parent, View child, MotionEvent ev) {
        acquireVelocityTracker(ev);
        final VelocityTracker verTracker = mVelocityTracker;
        int y = (int) ev.getY();
        switch (ev.getAction()) {
            case MotionEvent.ACTION_MOVE:
                int dy = y - lastY;
//                Log.i("chuan", "onTouchEvent=" + ev.getAction() + "dy=" + dy);
                if (dy < 0) {
                    moveUp(-dy, new int[2]);
                } else {
                    movedown(-dy);
                }
                lastY = y;
                break;
            case MotionEvent.ACTION_CANCEL:
                //自動
                verTracker.computeCurrentVelocity(1000, mMaxVelocity);
                autoFlingBySpeedIfNedd(-verTracker.getYVelocity());
                releaseVelocityTracker();
                break;
            default:
                break;
        }
        return super.onTouchEvent(parent, child, ev);
    }

重寫Behavior中onInterceptTouchEvent等方法,判斷手勢啟動位置,如果Header沒有消失,就攔截Move事件,讓header移動,header移動之後其dependVIew子View就會跟著滑動,從而實現整頁的滑動。

  • 細節
    -CoordinatorLayout中子View 佈局中屬性增加MarginBottom或top會導致下面的依賴view之間有重疊覆蓋。如上文中的Header若新增margin,會導致其依賴的view之間發生重疊,這個應該是CoordinatorLayout在layout子View時候沒有計算上下間距。
    2、多個依賴view之間的佈局,第3個view要減去第二個view的高度。例如上列中佈局可以看到title和list都在一層父佈局中,但是如果希望就是都在CoordinatorLayout中該怎麼實現,佈局如下:
<?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:orientation="vertical"
    tools:context=".MainActivity">


    <TextView
        android:id="@+id/move"
        android:layout_width="match_parent"
        android:layout_height="200dp"
        android:background="@color/colorAccent"
        android:gravity="center"
        android:text="Hello World!" />


  <!--  <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:orientation="vertical"
        app:layout_behavior="statistics.ymm.com.myapplication.MyBehavior">-->

        <TextView
            android:id="@+id/title"
            android:layout_width="match_parent"
            android:layout_height="20dp"
            android:background="@android:color/darker_gray"
            android:gravity="center"
            android:text="title"
            app:layout_behavior="statistics.ymm.com.myapplication.MyBehavior" />

        <android.support.v7.widget.RecyclerView
            android:id="@+id/recyclerView"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:background="@color/colorPrimary"
            app:layout_behavior="statistics.ymm.com.myapplication.Titlebehavior" />
  <!--  </LinearLayout>-->


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

這時候就要增加之後佈局的依賴關係了,title移動是依賴Header,設定MyBehavior配置,而list就要跟著title移動繼續,新增Titlebehavior,讓其依賴title,程式碼如下:

public class Titlebehavior extends CoordinatorLayout.Behavior {
    public Titlebehavior(Context context, AttributeSet attrs) {
        super(context, attrs);
    }

    private WeakReference<View> dependentView;

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

    @Override
    public boolean layoutDependsOn(CoordinatorLayout parent, View child, View dependency) {

        if (dependency != null && dependency.getId() == R.id.title) {
            dependentView = new WeakReference<>(dependency);
            return true;
        }
        return super.layoutDependsOn(parent, child, dependency);
    }

    @Override
    public boolean onDependentViewChanged(CoordinatorLayout parent, View child, View dependency) {

        child.setTranslationY(dependency.getTranslationY());

        return true;
    }


    @Override
    public boolean onLayoutChild(CoordinatorLayout parent, View child, int layoutDirection) {
        CoordinatorLayout.LayoutParams lp = (CoordinatorLayout.LayoutParams) child.getLayoutParams();
        if (lp.height == CoordinatorLayout.LayoutParams.MATCH_PARENT) {
            child.layout(0, (int) TypedValue.applyDimension(1, 20, child.getResources().getDisplayMetrics()), parent.getWidth(), (int) (parent.getHeight()));
            return true;
        }
        return super.onLayoutChild(parent, child, layoutDirection);
    }
}

這個時候要注意onLayoutChild對list控制元件layOut時候要手動減去依賴VIew的高度,也就是title,否則會導致直接覆蓋了title。