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

AndyJennifer發表於2019-08-02

前言

在上篇文章自定義View事件之進階篇(三)-CoordinatorLayout與Behavior中,我們介紹了CoordainatorLayout下的Behavior機制,為了幫助大家更好的理解並運用Behavior,現在我們通過一個Demo,來鞏固我們之前學習的知識點。

該部落格中涉及到的示例,在NestedScrollingDemo專案中都有實現,大家可以按需自取。

效果展示

先看一下我們需要實現的效果吧,如下圖所示:

例子展示.gif

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

從Demo效果來看,這是非常簡單的巢狀滑動。如果採用我們之前所學的NestedScrollingParent2NestedScrollingChild2實現介面的方式。我們能非常迅速的解決問題。但是如果採用自定義Behavior的話,那麼就稍微有點難度了。不過不用擔心,只要一步一步慢慢分析,就總能解決問題的。

RecyclerView佈局與測量的分析

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

佈局關係.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方法才會呼叫。

除了考慮以上因數以外,我們還需要考慮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。

相關文章