自定義View事件之進階篇(四)-自定義Behavior實戰

前言

在上篇文章自定義View事件之進階篇(三)-CoordinatorLayout與Behavior中,咱們介紹了CoordainatorLayout下的Behavior機制,爲了幫助你們更好的理解並運用Behavior,如今咱們經過一個Demo,來鞏固咱們以前學習的知識點。java

該博客中涉及到的示例,在NestedScrollingDemo項目中都有實現,你們能夠按需自取。git

效果展現

先看一下咱們須要實現的效果吧,以下圖所示:github

例子展現.gif

友情提示:Demo中涉及到的控件爲CoordinatorLayout、TextView、RecyclerView。文章都會圍繞這三個控件進行講解。windows

從Demo效果來看,這是很是簡單的嵌套滑動。若是採用咱們以前所學的NestedScrollingParent2NestedScrollingChild2實現接口的方式。咱們能很是迅速的解決問題。可是若是採用自定義Behavior的話,那麼就稍微有點難度了。不過不用擔憂,只要一步一步慢慢分析,就總能解決問題的。app

RecyclerView佈局與測量的分析

在Demo中,RecyclerView與TextView開始的佈局關係以下圖所示:ide

佈局關係.jpg

根據在文章自定義View事件之進階篇(三)-CoordinatorLayout與Behavior中咱們所學的知識點,咱們知道CoordinatorLayout對子控件的佈局是相似於FrameLayout的,因此爲了保證RecyclerView在TextView的下方顯示,咱們須要建立屬於RecyclerView的Behavior,並在該Behavior的onLayoutChild方法中處理RecyclerView與TextView的位置關係。函數

除了解決RecyclerView的位置關係之外,在該Demo中,咱們還能夠看出,RecyclerView與TextView之間有着一個聯動的關係(這裏指的是RecyclerView與TextView之間的位置關係,而不是RecyclerView中的內容)。隨着TextView逐漸上移的時候,下方的RecyclerView也跟着往上移動。那麼咱們能夠肯定的是RecyclerView必然是依賴TextView的。也就是說咱們須要重寫Behavior的layoutDependsOnonDependentViewChanged方法。佈局

肯定一個控件(childView1)依賴另一個控件(childView2)的時候,是經過layoutDependsOn(CoordinatorLayout parent, V child, View dependency)這個方法。其中child是依賴對象(childView1),而dependency是被依賴對象(childView2),該方法的返回值是判斷是否依賴對應view。若是返回true。那麼表示依賴。反之不依賴。通常狀況下,在咱們自定義Behavior時,咱們須要重寫該方法。當layoutDependsOn方法返回true時,後面的onDependentViewChangedonDependentViewRemoved方法纔會調用。post

除了考慮以上因數之外,咱們還須要考慮RecyclerView的高度。觀察Demo,咱們能夠看出,RecylerView在移動先後,始終都是填充整個屏幕的。爲了保證RecylerView在移動過程當中,屏幕中不會出現空白(以下圖所示)。咱們也須要在CoordinatorLayout測量該控件的高度以前,讓控件自主的去測量高度。也就是重寫RecylerView對應Behavior中的onMeasureChild方法。學習

空白區域.jpg

RecyclerView的Behavior代碼實現

分析了RecyclerView的Behavior須要重寫的內容後,咱們來看看具體的Behavior實現類HeaderScrollingViewBehavior。爲了幫助你們理解,我將RecyclerView的Behavior拆成了幾個部分,代碼以下所示:

查看該Behavior完整代碼,請點擊--->HeaderScrollingViewBehavior

public class HeaderScrollingViewBehavior extends CoordinatorLayout.Behavior<View> {

    public HeaderScrollingViewBehavior() {}

    public HeaderScrollingViewBehavior(Context context, AttributeSet attrs) {
        super(context, attrs);
    }
    /** * 依賴TextView */
    @Override
    public boolean layoutDependsOn(CoordinatorLayout parent, View child, View dependency) {
        return dependency instanceof TextView;
    }
    //省略部分代碼…
}
複製代碼

注意:在xml引用自定義Behavior時,必定要聲明構造函數。否則在程序的編譯過程當中,會提示知道不到相應的Behavior。

layoutDependsOn方法的邏輯很是簡單。就是判斷依賴的對象是不是TextView。咱們繼續查看該類中的onMeasureChild方法。代碼以下所示:

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

        //獲取當前滾動控件的測量模式
        final int childLpHeight = child.getLayoutParams().height;

        //只有當前滾動控件爲match_parent/wrap_content時才從新測量其高度,由於固定高度不會出現底部空白的狀況
        if (childLpHeight == ViewGroup.LayoutParams.MATCH_PARENT
                || childLpHeight == ViewGroup.LayoutParams.WRAP_CONTENT) {

            //獲取當前child依賴的對象集合
            final List<View> dependencies = parent.getDependencies(child);

            final View header = findFirstDependency(dependencies);
            if (header != null) {
                if (ViewCompat.getFitsSystemWindows(header)
                        && !ViewCompat.getFitsSystemWindows(child)) {
                    // If the header is fitting system windows then we need to also,
                    // otherwise we'll get CoL's compatible measuring
                    ViewCompat.setFitsSystemWindows(child, true);

                    if (ViewCompat.getFitsSystemWindows(child)) {
                        // If the set succeeded, trigger a new layout and return true
                        child.requestLayout();
                        return true;
                    }
                }
                //獲取當前父控件中可用的距離,
                int availableHeight = View.MeasureSpec.getSize(parentHeightMeasureSpec);
                if (availableHeight == 0) {

                    // If the measure spec doesn't specify a size, use the current height
                    availableHeight = parent.getHeight();
                }
                //計算當前滾動控件的高度。
                final int height = availableHeight - header.getMeasuredHeight() + getScrollRange(header);
                final int heightMeasureSpec = View.MeasureSpec.makeMeasureSpec(height,
                        childLpHeight == ViewGroup.LayoutParams.MATCH_PARENT
                                ? View.MeasureSpec.EXACTLY
                                : View.MeasureSpec.AT_MOST);

                //測量當前滾動的View的正確高度
                parent.onMeasureChild(child, parentWidthMeasureSpec,
                        widthUsed, heightMeasureSpec, heightUsed);

                return true;
            }
        }
        return false;
    }
複製代碼

測量邏輯的基本步驟:

  1. 獲取當前控件的測量模式,判斷是否採用的match_parent或者wrap_content。(對於精準模式,咱們不用考慮,控件是否填充屏幕)
  2. 當知足條件1,獲取當前RecyclerView所依賴的header(TextView),根據當前TextView的高度,計算出控件A的實際高度(RecyclerView的父控件可用的高度-TextView的高度+TextView的滾動範圍)

onMeasureChild方法中,我省略了部分方法的介紹,如findFirstDependencygetScrollRange方法。這些方法在NestedScrollingDemo項目中都有實現。你們能夠按需自取。

咱們繼續查看HeaderScrollingViewBehavior類中的onLayoutChild方法,代碼以下所示:

@Override
    public boolean onLayoutChild(CoordinatorLayout parent, View child, int layoutDirection) {
        final List<View> dependencies = parent.getDependencies(child);
        final View header = findFirstDependency(dependencies);

        if (header != null) {
            final CoordinatorLayout.LayoutParams lp =
                    (CoordinatorLayout.LayoutParams) child.getLayoutParams();
            final Rect available = mTempRect1;

           //獲得依賴控件下方的座標。
            available.set(parent.getPaddingLeft() + lp.leftMargin,
                    header.getBottom() + lp.topMargin,
                    parent.getWidth() - parent.getPaddingRight() - lp.rightMargin,
                    parent.getHeight() + header.getBottom()
                            - parent.getPaddingBottom() - lp.bottomMargin);

            //拿到上面計算的座標後,根據當前控件在父控件中設置的gravity,從新計算並獲得控件在父控件中的座標
            final Rect out = mTempRect2;
            GravityCompat.apply(resolveGravity(lp.gravity), child.getMeasuredWidth(),
                    child.getMeasuredHeight(), available, out, layoutDirection);

            //拿到座標後從新佈局
            child.layout(out.left, out.top, out.right, out.bottom);

        } else {
            //若是沒有依賴,則調用父控件來處理佈局
            parent.onLayoutChild(child, layoutDirection);
        }
        return true;
    }
複製代碼

onLayoutChild方法邏輯也不算複雜,根據當前所依賴的header(TextView)的位置,將RecyclerView設置在TextView下方。咱們繼續查看RecyclerView與TextView的聯動處理。也就是onDependentViewChanged方法。代碼以下所示:

@Override
    public boolean onDependentViewChanged(CoordinatorLayout parent, View child, View dependency) {
        final CoordinatorLayout.Behavior behavior =
                ((CoordinatorLayout.LayoutParams) dependency.getLayoutParams()).getBehavior();
        if (behavior instanceof NestedHeaderBehavior) {
            ViewCompat.offsetTopAndBottom(child, dependency.getBottom() - child.getTop() + ((NestedHeaderBehavior) behavior).getOffset());
        }
        //若是當前的控件的位置發生了改變,該返回值必定要返回爲true
        return true;
    }
複製代碼

在該方法中,咱們須要經過TextView的Behavior(NestedHeaderBehavior),並得到TextView的實際偏移量(上述代碼中的getOffset())。經過該偏移量咱們能夠從新設置RecyclerView的位置。固然,改變控件位置的方式有不少種,咱們可使用setTransationYView.offsetTopAndBottom及其餘方式,你們能夠採用本身喜歡的方式。由於涉及到TextView中Behavior的偏移量。那下面咱們就來看看TextView對應Behavior的分析與實現吧。

TextView嵌套滑動的分析

在整個Demo中,TextView的嵌套滑動效果並不複雜。這裏咱們就從向上與向下兩個方向來介紹。

  • 向上滑動: 只有當TextView滑動至屏幕外時,RecyclerView才能處理內部內容的滾動。
  • 向下滑動: 當TextView已經被劃出屏幕且RecylerView中的內容不能繼續向下滑動時,那麼就將TextView滑動至顯示。不然RecyclerView單獨處理內部內容的滾動。

TextView的Behavior代碼實現

在講解TextView的Behavior的代碼實現以前,咱們須要回顧一下在CooordinatoLayout下嵌套方法的傳遞過程,以下圖所示:

嵌套滑動總體流程.jpg

經過回顧流程,在結合本文例子中展現的效果,咱們須要重寫Behavior中的onStartNestedScrollonNestedPreScrollonNestedScroll三個方法。來看TextView的NestedHeaderBehavior實現。代碼以下所示:

查看該Behavior完整代碼,請點擊--->NestedHeaderBehavior

public class NestedHeaderBehavior extends CoordinatorLayout.Behavior<View> {


    private WeakReference<View> mNestedScrollingChildRef;
    private int mOffset;//記錄當前佈局的偏移量

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

    @Override
    public boolean onLayoutChild(CoordinatorLayout parent, View child, int layoutDirection) {
        mNestedScrollingChildRef = new WeakReference<>(findScrollingChild(parent));
        return super.onLayoutChild(parent, child, layoutDirection);
    }
    //省略部分代碼…
}
複製代碼

TextView中NestedHeaderBehavior類的聲明與RecyclerView中的Behavior基本同樣。由於咱們須要將偏移量傳遞給RecyclerView,因此在NestedHeaderBehavior的onLayoutChild方法中,咱們去建立了關於RecyclerView的弱引用,並設置了mOffset變量來記錄TextViwe每次滑動的偏移量。如何獲取RecyclerView,能夠查看項目中源碼的實現。接下來,咱們繼續查看相關嵌套方法實現。

@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;
    }
複製代碼

onStartNestedScroll方法中,咱們設置了當前控件,只能攔截豎直方向上的嵌套滑動事件。繼續查看onNestedPreScroll方法。代碼以下所示:

@Override
    public void onNestedPreScroll(@NonNull CoordinatorLayout coordinatorLayout, @NonNull View child, @NonNull View target, int dx, int dy, @NonNull int[] consumed, int type) {
        View scrollingChild = mNestedScrollingChildRef.get();
        if (target != scrollingChild) {
            return;
        }
        int currentTop = child.getTop();
        int newTop = currentTop - dy;
        if (dy > 0) {//向上滑動
            //處理在範圍內的滾動與fling
            if (newTop >= -child.getHeight()) {
                Log.i(TAG, "onNestedPreScroll:向上移動" + "currentTop--->" + currentTop + " newTop--->" + newTop);
                consumed[1] = dy;
                mOffset = -dy;
                ViewCompat.offsetTopAndBottom(child, -dy);
                coordinatorLayout.dispatchDependentViewsChanged(child);
            } else { //當超事後,單獨處理
                consumed[1] = child.getHeight() + currentTop;
                mOffset = -consumed[1];
                ViewCompat.offsetTopAndBottom(child, -consumed[1]);
                coordinatorLayout.dispatchDependentViewsChanged(child);
            }
        }
        if (dy < 0) {//向下滑動
            if (newTop <= 0 && !target.canScrollVertically(-1)) {
                Log.i(TAG, "onNestedPreScroll:向下移動" + "currentTop--->" + currentTop + " newTop--->" + newTop);
                consumed[1] = dy;
                mOffset = -dy;
                ViewCompat.offsetTopAndBottom(child, -dy);
                coordinatorLayout.dispatchDependentViewsChanged(child);
            }
        }

    }

複製代碼

onNestedPreScroll方法中的邏輯較爲複雜。不急咱們慢慢分析:

  • 首先咱們獲得當前TextView的Top高度(currentTop)。而後根據當前偏移距離dy,計算出TextView新的Top高度(newTop)。
  • 若是dy>0,也就是向上滑動。咱們判斷偏移後的Top(newTop)高度是否大於的TextView的測量的高度。

由於是向上滑動,當TextView移出屏幕後,經過調用getTop方法獲取的高度確定爲負數。這裏判斷是否大於等於-child.getHeight,表示的是當前TextView沒有超過它的滾動範圍(-child.getHeight到0)。

  1. 若是newTop >= -child.getHeight(),則TextView消耗掉dy,經過ViewCompat.offsetTopAndBottom(child, -dy)來移動當前TextView,接着記錄TextView位置的偏移量(mOffest),最後經過調用CoordinatorLayout下的dispatchDependentViewsChanged方法,通知控件RecyclerView所依賴的TextView發生了改變。那麼RecyclerView收到通知後,就能夠拿着這個偏移量和TextView一塊兒聯動了。
  2. 若是newTop< - child.getHeight(),表示在當前偏移距離dy下,若是TextView會超過它的滾動範圍。那麼咱們就不能使用當前dy來移動TextView。咱們只能滾動剩下的範圍,也就是child.getHeight() +currentTop,(這裏使用加號,是由於滾動範圍爲-child.getHeight0)。
  • 若是dy<0,表示向下滑動,只有在target(RecyclerView)不能向下滑動且TextView已經部分移出屏幕時,咱們的TextView才能向下滑動。這裏的處理方式基本和上滑同樣,這裏就再也不進行介紹了。咱們繼續查看最後的方法onNestedScroll方法。
@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) {//表示已經向下滑動到頭。
            int currentTop = child.getTop();
            int newTop = currentTop - dyUnconsumed;
            if (newTop <= 0) {//若是當前的值在滾動範圍以內。
                Log.i(TAG, "onNestedScroll: " + "dyUnconsumed--> " + dyUnconsumed + " currentTop--->" + currentTop + " newTop--->" + newTop);
                ViewCompat.offsetTopAndBottom(child, -dyUnconsumed);
                mOffset = -dyUnconsumed;
            } else {//若是當前的值大於最大的滾動範圍(0),那麼就直接滾動到-currentTop就好了
                ViewCompat.offsetTopAndBottom(child, -currentTop);
                mOffset = -currentTop;
            }
            coordinatorLayout.dispatchDependentViewsChanged(child);
        }
    }
複製代碼

onNestedScroll方法中,咱們須要處理RecyclerView向下方向上未消耗的距離(dyUnconsumed)。一樣根據當前偏移記錄計算出TextVie新的Top高度,計算出是否超出其滾功範圍範圍。若是沒有超過,則TextView向下偏移距離爲-dyUnconsumed,同時記錄偏移量(mOffset=-dyUnconsumed),最後通知RecyclerView,TextView的位置發生了改變。反之,當前TextView的top的值是多少,那麼TextView就向下偏移多少。

最後

在該文章中,我着重講解了相應Behavior中比較重要的一些方法。一些不是那麼重要的輔助方法,我並無作過多的介紹。建議你們配合NestedScrollingDemo項目中的源碼理解該篇文章,我相信確定是事半功倍的。

最最後

關於嵌套滑動、CoordinatorLayout、Behavior的知識點基本介紹完畢了。我相信你們之後再碰見一些嵌套滑動的問題。都可以輕鬆的解決了。可能不少小夥伴會好奇,爲何沒有接着講AppBarLayout與CollapsingTollbarLayout的原理及使用。其實緣由很是簡單,由於上述的兩個控件的實現原理,實際上是依託於CoordinatorLayout與自定義Behavior罷了。授人以魚,不如授人以漁。AppBarLayout與CollapsingTollbarLayout的使用及原理。就算給你們留的課後思考題吧。謝謝你們對這系列的關注。Thanks。

相關文章
相關標籤/搜索