自定義Behavior的藝術(shù)探索-仿UC瀏覽器主頁(yè)

前言&效果預(yù)覽

最近幾個(gè)周末基本在研究 CoordinatorLayout 控件和自定義 Behavior 當(dāng)中,這期間看了不少這方面的知識(shí),有關(guān)于
CoordinatorLayout 使用的文章,CoordinatorLayout 的源碼分析文章等等,輕輕松松入門(mén)雖然簡(jiǎn)單,無(wú)耐于網(wǎng)上介紹的一些例子實(shí)在是太簡(jiǎn)單,很多東西都是草草帶過(guò),尤其是關(guān)于 NestedScroll 效果這方面的,最后發(fā)現(xiàn)自己到頭來(lái)其實(shí)還是一頭霧水,當(dāng)然,自己在周末的時(shí)候效率的確不高,干擾因素也多。但無(wú)意中發(fā)現(xiàn)了一篇通過(guò)自定義
View 的方式實(shí)現(xiàn)的仿 UC 瀏覽器主頁(yè)的文章(大家也可以看看,對(duì)于自定義 View 也是有幫助的),頓時(shí)有了使用自定義 Behavior 實(shí)現(xiàn)這樣的效果的想法,而且這種方式在我看來(lái)應(yīng)該會(huì)更簡(jiǎn)單, __但重點(diǎn)是這貨的解耦功能?。。∧闶褂?Behavior 抽象了某個(gè)模塊的 View 的行為,而不再是依賴(lài)于特定的 View ,以后可以隨便地替換這部分的 View ,而你只需要為改變的 View 設(shè)置好對(duì)應(yīng)的 Behavior __ ,于是看了很多這方面的源碼 CoordinatorLayout、NestedScrollView、SwipeDismissBehavior、FloatingActionButton.Behavior、AppBarLayout.Behavior 等,也是有所頓悟,于是有了今天的這篇文章。憶當(dāng)年,自己也曾經(jīng)在 UC 瀏覽器實(shí)習(xí)過(guò)大半年的時(shí)間,UC 也是自己一直除了 QQ 從塞班時(shí)代至今一直使用的 APP 了,只怪自己當(dāng)時(shí)有點(diǎn)作死。。。。咳咳,扯多了,還是直接來(lái)看效果吧,因?yàn)槲恼卤容^長(zhǎng),不先放個(gè)效果圖,估計(jì)沒(méi)多少人能耐心看完(即使放了,估計(jì)也沒(méi)多少能撐著看完,文章特長(zhǎng)...要不直接看 代碼?)

效果圖
效果圖

揭開(kāi) NestedScrolling 的原理

網(wǎng)上不少寫(xiě)文章寫(xiě)到自定義Behavior的實(shí)現(xiàn)方式有兩種形式,其中一種是實(shí)現(xiàn) NestedScrolling 效果的,需要關(guān)注重寫(xiě) onStartNestedScrollonNestedPreScroll 等一系列帶 Nested 字段的方法,當(dāng)你一看這樣的一個(gè)方法
onNestedScroll(CoordinatorLayout coordinatorLayout, V child, View target,int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed) 是有多少個(gè)參數(shù)的時(shí)候,你通常會(huì)一臉懵逼,就算你搞懂了這里的每個(gè)參數(shù)的意思,你還是會(huì)有所疑問(wèn),這樣的一大堆方法是在什么時(shí)候調(diào)用的,這個(gè)時(shí)候,你首先需要弄懂的是 Android5.0 開(kāi)始提供的支持嵌套滑動(dòng)效果的機(jī)制

NestedScrolling 提供了一套父 View 和子 View 滑動(dòng)交互機(jī)制。要完成這樣的交互,父 View 需要實(shí)現(xiàn) NestedScrollingParent 接口,而子 View 需要實(shí)現(xiàn) NestedScrollingChild 接口,系統(tǒng)提供的 NestedScrollView 控件就實(shí)現(xiàn)了這兩個(gè)接口,千萬(wàn)不要被這兩個(gè)接口這么多的方法唬住了,這兩個(gè)接口都有一個(gè)
NestedScrolling[Parent,Children]Helper 輔助類(lèi)來(lái)幫助處理的大部分邏輯,它們之間關(guān)系如下

NestedScrollView
NestedScrollView

實(shí)現(xiàn) NestedScrollingChild 接口

實(shí)際上 NestedScrollingChildHelper 輔助類(lèi)已經(jīng)實(shí)現(xiàn)好了 Child 和 Parent 交互的邏輯。原來(lái)的 View 的處理滑動(dòng)
事件的邏輯大體上不需要改變。

需要做的就是,如果要準(zhǔn)備開(kāi)始滑動(dòng)了,需要告訴 Parent,Child 要準(zhǔn)備進(jìn)入滑動(dòng)狀態(tài)了,調(diào)用
startNestedScroll()。Child 在滑動(dòng)之前,先問(wèn)一下你的 Parent 是否需要滑動(dòng),也就是調(diào)用
dispatchNestedPreScroll()。如果父類(lèi)消耗了部分滑動(dòng)事件,Child 需要重新計(jì)算一下父類(lèi)消耗后剩下給 Child 的滑動(dòng)距離余量。然后,Child 自己進(jìn)行余下的滑動(dòng)。最后,如果滑動(dòng)距離還有剩余,Child 就再問(wèn)一下,Parent 是否需要在繼續(xù)滑動(dòng)你剩下的距離,也就是調(diào)用 dispatchNestedScroll(),大概就是這么一回事,當(dāng)然還還會(huì)有和
scroll 類(lèi)似的 fling 系列方法,但我們這里可以先忽略一下

NestedScrollViewNestedScrollingChild 接口實(shí)現(xiàn)都是交給輔助類(lèi) NestedScrollingChildHelper 來(lái)處理的,是否需要進(jìn)行額外的一些操作要根據(jù)實(shí)際情況來(lái)定

// NestedScrollingChild
public NestedScrollView(Context context, AttributeSet attrs, int defStyleAttr) {
    //...
    mParentHelper = new NestedScrollingParentHelper(this);
    mChildHelper = new NestedScrollingChildHelper(this);
    //...
    setNestedScrollingEnabled(true);
}

@Override
public void setNestedScrollingEnabled(boolean enabled) {
    mChildHelper.setNestedScrollingEnabled(enabled);
}

@Override
public boolean isNestedScrollingEnabled() {
    return mChildHelper.isNestedScrollingEnabled();
}

//在初始化滾動(dòng)操作的時(shí)候調(diào)用,一般在 MotionEvent#ACTION_DOWN 的時(shí)候調(diào)用
@Override
public boolean startNestedScroll(int axes) {
    return mChildHelper.startNestedScroll(axes);
}

@Override
public void stopNestedScroll() {
    mChildHelper.stopNestedScroll();
}

@Override
public boolean hasNestedScrollingParent() {
    return mChildHelper.hasNestedScrollingParent();
}

//參數(shù)和dispatchNestedPreScroll方法的返回有關(guān)聯(lián)
@Override
public boolean dispatchNestedScroll(int dxConsumed, int dyConsumed, int dxUnconsumed,int dyUnconsumed, int[] offsetInWindow) {
    return mChildHelper.dispatchNestedScroll(dxConsumed, dyConsumed, dxUnconsumed, dyUnconsumed,offsetInWindow);
}

//在消費(fèi)滾動(dòng)事件之前調(diào)用,提供一個(gè)讓ViewParent實(shí)現(xiàn)聯(lián)合滾動(dòng)的機(jī)會(huì),因此ViewParent可以消費(fèi)一部分或者全部的滑動(dòng)事件,參數(shù)consumed會(huì)記錄ViewParent所消費(fèi)掉的事件
@Override
public boolean dispatchNestedPreScroll(int dx, int dy, int[] consumed, int[] offsetInWindow) {
    return mChildHelper.dispatchNestedPreScroll(dx, dy, consumed, offsetInWindow);
}

@Override
public boolean dispatchNestedFling(float velocityX, float velocityY, boolean consumed) {
    return mChildHelper.dispatchNestedFling(velocityX, velocityY, consumed);
}

@Override
public boolean dispatchNestedPreFling(float velocityX, float velocityY) {
    return mChildHelper.dispatchNestedPreFling(velocityX, velocityY);
}

實(shí)現(xiàn) NestedScrollingChild 接口挺簡(jiǎn)單的不是嗎?但還需要我們決定什么時(shí)候進(jìn)行調(diào)用,和調(diào)用那些方法

startNestedScroll 和 stopNestedScroll 的調(diào)用

startNestedScroll配合stopNestedScroll使用,startNestedScroll會(huì)再接收到ACTION_DOWN的時(shí)候調(diào)用,stopNestedScroll會(huì)在接收到ACTION_UP|ACTION_CANCEL的時(shí)候調(diào)用,NestedScrollView中的偽代碼是這樣

onInterceptTouchEvent | onTouchEvent (MotionEvent ev){

   case MotionEvent.ACTION_DOWN:
      startNestedScroll(ViewCompat.SCROLL_AXIS_VERTICAL);
   break;
   case  MotionEvent.ACTION_CANCEL | MotionEvent.ACTION_UP:
      stopNestedScroll();
   break;
}

NestedScrollingChildHelper 處理 startNestedScroll 方法,可以看出可能會(huì)調(diào)用 Parent 的 onStartNestedScroll
onNestedScrollAccepted 方法,只要 Parent 愿意優(yōu)先處理這次的滑動(dòng)事件,在結(jié)束的時(shí)候 Parent 還會(huì)收到
onStopNestedScroll 回調(diào)

public boolean startNestedScroll(int axes) {
    if (hasNestedScrollingParent()) {
        // Already in progress
        return true;
    }
    if (isNestedScrollingEnabled()) {
        ViewParent p = mView.getParent();
        View child = mView;
        while (p != null) {
            if (ViewParentCompat.onStartNestedScroll(p, child, mView, axes)) {
                mNestedScrollingParent = p;
                ViewParentCompat.onNestedScrollAccepted(p, child, mView, axes);
                return true;
            }
            if (p instanceof View) {
                child = (View) p;
            }
            p = p.getParent();
        }
    }
    return false;
}

public void stopNestedScroll() {
    if (mNestedScrollingParent != null) {
        ViewParentCompat.onStopNestedScroll(mNestedScrollingParent, mView);
        mNestedScrollingParent = null;
    }
}

dispatchNestedPreScroll 的調(diào)用

在消費(fèi)滾動(dòng)事件之前調(diào)用,提供一個(gè)讓 Parent 實(shí)現(xiàn)聯(lián)合滾動(dòng)的機(jī)會(huì),因此 Parent 可以消費(fèi)一部分或者全部的滑動(dòng)事件,注意參數(shù) consumed 會(huì)記錄了 Parent 所消費(fèi)掉的事件

onTouchEvent (MotionEvent ev){
    //...
   case MotionEvent.ACTION_MOVE:
   //...
     final int y = (int) MotionEventCompat.getY(ev, activePointerIndex);
     int deltaY = mLastMotionY - y; //計(jì)算偏移量
     if (dispatchNestedPreScroll(0, deltaY, mScrollConsumed, mScrollOffset)) {
       deltaY -= mScrollConsumed[1]; //減去被消費(fèi)掉的事件
       //...
   }
   //...
   break;
}

NestedScrollingChildHelper 處理 dispatchNestedPreScroll 方法,會(huì)調(diào)用到上一步里記錄的希望優(yōu)先處理 Scroll
事件的 Parent 的 onNestedPreScroll 方法

public boolean dispatchNestedPreScroll(int dx, int dy, int[] consumed, int[] offsetInWindow) {
    if (isNestedScrollingEnabled() && mNestedScrollingParent != null) {
        if (dx != 0 || dy != 0) {
            int startX = 0;
            int startY = 0;
            //...
            consumed[0] = 0;
            consumed[1] = 0;
            ViewParentCompat.onNestedPreScroll(mNestedScrollingParent, mView, dx, dy, consumed);
            //...
            return consumed[0] != 0 || consumed[1] != 0;
        } else if (offsetInWindow != null) {
            //...
        }
    }
    return false;
}

dispatchNestedScroll 的調(diào)用

這個(gè)方法是在 Child 自己消費(fèi)完 Scroll 事件后調(diào)用的

onTouchEvent (MotionEvent ev){
    //...
   case MotionEvent.ACTION_MOVE:
   //...
   final int scrolledDeltaY = getScrollY() - oldY; //計(jì)算這個(gè)Child View消費(fèi)掉的Scroll事件
   final int unconsumedY = deltaY - scrolledDeltaY; //計(jì)算的是這個(gè)Child View還沒(méi)有消費(fèi)掉的Scroll事件
   if (dispatchNestedScroll(0, scrolledDeltaY, 0, unconsumedY, mScrollOffset)) {
       mLastMotionY -= mScrollOffset[1];
       vtev.offsetLocation(0, mScrollOffset[1]);//重新調(diào)整事件的位置
       mNestedYOffset += mScrollOffset[1];
   }
   //...
   break;
}

NestedScrollingChildHelper 處理 dispatchNestedScroll 方法,會(huì)調(diào)用到上一步里記錄的希望優(yōu)先處理 Scroll 事件的 Parent 的 onNestedScroll 方法

public boolean dispatchNestedScroll(int dxConsumed, int dyConsumed,
        int dxUnconsumed, int dyUnconsumed, int[] offsetInWindow) {
    if (isNestedScrollingEnabled() && mNestedScrollingParent != null) {
        if (dxConsumed != 0 || dyConsumed != 0 || dxUnconsumed != 0 || dyUnconsumed != 0) {
            int startX = 0;
            int startY = 0;
            //...
            ViewParentCompat.onNestedScroll(mNestedScrollingParent, mView, dxConsumed, dyConsumed, dxUnconsumed, dyUnconsumed);

            //..
            return true;
        } else if (offsetInWindow != null) {
            // No motion, no dispatch. Keep offsetInWindow up to date.
            //..
        }
    }
    return false;
}

實(shí)現(xiàn) NestedScrollingParent 接口

同樣,也有一個(gè) NestedScrollingParentHelper輔助類(lèi)來(lái)幫助 Parent 實(shí)現(xiàn)和 Child 交互的邏輯。滑動(dòng)動(dòng)作是 Child
主動(dòng)發(fā)起
,Parent 就受滑動(dòng)回調(diào)并作出響應(yīng)。從上面的 Child 分析可知,滑動(dòng)開(kāi)始的調(diào)用 startNestedScroll(),Parent收到 onStartNestedScroll() 回調(diào),決定是否需要配合 Child 一起進(jìn)行處理滑動(dòng),如果需要配合,還會(huì)回調(diào)
onNestedScrollAccepted()

每次滑動(dòng)前,Child 先詢(xún)問(wèn) Parent 是否需要滑動(dòng),即 dispatchNestedPreScroll() ,這就回調(diào)到 Parent 的
onNestedPreScroll(),Parent 可以在這個(gè)回調(diào)中消費(fèi)掉 Child 的 Scroll 事件,也就是優(yōu)先于 Child 滑動(dòng)

Child 滑動(dòng)以后,會(huì)調(diào)用 dispatchNestedScroll() ,回調(diào)到 Parent 的 onNestedScroll() ,這里就是 Child 滑動(dòng)后,剩下的給 Parent 處理,也就是后于 Child 滑動(dòng)

最后,滑動(dòng)結(jié)束 Child 調(diào)用 stopNestedScroll,回調(diào) Parent 的 onStopNestedScroll() 表示本次處理結(jié)束

現(xiàn)在我們來(lái)看看 NestedScrollingParent 的實(shí)現(xiàn)細(xì)節(jié),這里以 CoordinatorLayout 來(lái)分析而不再是
NestedScrollView ,因?yàn)樗攀沁@篇文章的主角

在這之前,首先簡(jiǎn)單介紹下 Behavior 這個(gè)對(duì)象,你可以在 XML 中定義它就會(huì)在 CoordinaryLayout 中解析實(shí)例化到目標(biāo)子 View 的 LayoutParams 或者獲取到 CoordinaryLayout 子 View 的 LayoutParams 對(duì)象通過(guò) setter 方法注入,如果你自定義的 Behavior 希望實(shí)現(xiàn) NestedScroll 效果,那么你需要關(guān)注重寫(xiě)以下這些方法

  • onStartNestedScroll : boolean
  • onStopNestedScroll : void
  • onNestedScroll : void
  • onNestedPreScroll : void
  • onNestedFling : void
  • onNestedPreFling : void

你會(huì)發(fā)現(xiàn)以上這些方法對(duì)應(yīng)了 NestedScrollingParent 接口的方法,只是在參數(shù)上有所增加,且都會(huì)在
CoordiantorLayout 實(shí)現(xiàn) NestedScrollingParent 接口的每個(gè)方法中作出相應(yīng)回調(diào),下面來(lái)簡(jiǎn)單走讀下這部分代碼

public class CoordinatorLayout extends ViewGroup implements NestedScrollingParent {
  //.....

//CoordiantorLayout的成員變量
private final NestedScrollingParentHelper mNestedScrollingParentHelper = new NestedScrollingParentHelper(this);

   // 參數(shù)child:當(dāng)前實(shí)現(xiàn)`NestedScrollingParent`的ViewParent包含觸發(fā)嵌套滾動(dòng)的直接子view對(duì)象
   // 參數(shù)target:觸發(fā)嵌套滾動(dòng)的view  (在這里如果不涉及多層嵌套的話,child和target)是相同的
   // 參數(shù)nestedScrollAxes:就是嵌套滾動(dòng)的滾動(dòng)方向了.垂直或水平方法
   //返回參數(shù)代表當(dāng)前ViewParent是否可以觸發(fā)嵌套滾動(dòng)操作
   //CoordiantorLayout的實(shí)現(xiàn)上是交由子View的Behavior來(lái)決定,并回調(diào)了各個(gè)acceptNestedScroll方法,告訴它們處理結(jié)果
    public boolean onStartNestedScroll(View child, View target, int nestedScrollAxes) {
        boolean handled = false;

        final int childCount = getChildCount();
        for (int i = 0; i < childCount; i++) {
            final View view = getChildAt(i);
            final LayoutParams lp = (LayoutParams) view.getLayoutParams();
            final Behavior viewBehavior = lp.getBehavior();
            if (viewBehavior != null) {
                final boolean accepted = viewBehavior.onStartNestedScroll(this, view, child, target,nestedScrollAxes);
                handled |= accepted;
                lp.acceptNestedScroll(accepted);
            } else {
                lp.acceptNestedScroll(false);
            }
        }
        return handled;
    }
    //onStartNestedScroll返回true才會(huì)觸發(fā)這個(gè)方法
    //參數(shù)和onStartNestedScroll方法一樣
    //按照官方文檔的指示,CoordiantorLayout有一個(gè)NestedScrollingParentHelper類(lèi)型的成員變量,并把這個(gè)方法交由它處理
    //同樣,這里也是需要CoordiantorLayout遍歷子View,對(duì)可以嵌套滾動(dòng)的子View回調(diào)Behavior#onNestedScrollAccepted方法
    public void onNestedScrollAccepted(View child, View target, int nestedScrollAxes) {
        mNestedScrollingParentHelper.onNestedScrollAccepted(child, target, nestedScrollAxes);
        mNestedScrollingDirectChild = child;
        mNestedScrollingTarget = target;

        final int childCount = getChildCount();
        for (int i = 0; i < childCount; i++) {
            final View view = getChildAt(i);
            final LayoutParams lp = (LayoutParams) view.getLayoutParams();
            if (!lp.isNestedScrollAccepted()) {
                continue;
            }

            final Behavior viewBehavior = lp.getBehavior();
            if (viewBehavior != null) {
                viewBehavior.onNestedScrollAccepted(this, view, child, target, nestedScrollAxes);
            }
        }
    }

    //嵌套滾動(dòng)的結(jié)束,做一些資源回收操作等...
    //為可以嵌套滾動(dòng)的子View回調(diào)Behavior#onStopNestedScroll方法
    public void onStopNestedScroll(View target) {
        mNestedScrollingParentHelper.onStopNestedScroll(target);

        final int childCount = getChildCount();
        for (int i = 0; i < childCount; i++) {
            final View view = getChildAt(i);
            final LayoutParams lp = (LayoutParams) view.getLayoutParams();
            if (!lp.isNestedScrollAccepted()) {
                continue;
            }

            final Behavior viewBehavior = lp.getBehavior();
            if (viewBehavior != null) {
                viewBehavior.onStopNestedScroll(this, view, target);
            }
            lp.resetNestedScroll();
            lp.resetChangedAfterNestedScroll();
        }

        mNestedScrollingDirectChild = null;
        mNestedScrollingTarget = null;
    }
    //進(jìn)行嵌套滾動(dòng)
    // 參數(shù)dxConsumed:表示target已經(jīng)消費(fèi)的x方向的距離
    // 參數(shù)dyConsumed:表示target已經(jīng)消費(fèi)的x方向的距離
    // 參數(shù)dxUnconsumed:表示x方向剩下的滑動(dòng)距離
    // 參數(shù)dyUnconsumed:表示y方向剩下的滑動(dòng)距離
    // 可以嵌套滾動(dòng)的子View回調(diào)Behavior#onNestedScroll方法
    public void onNestedScroll(View target, int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed) {
        final int childCount = getChildCount();
        boolean accepted = false;

        for (int i = 0; i < childCount; i++) {
            final View view = getChildAt(i);
            final LayoutParams lp = (LayoutParams) view.getLayoutParams();
            if (!lp.isNestedScrollAccepted()) {
                continue;
            }

            final Behavior viewBehavior = lp.getBehavior();
            if (viewBehavior != null) {
                viewBehavior.onNestedScroll(this, view, target, dxConsumed, dyConsumed,dxUnconsumed, dyUnconsumed);
                accepted = true;
            }
        }

        if (accepted) {
            dispatchOnDependentViewChanged(true);
        }
    }
    //發(fā)生嵌套滾動(dòng)之前回調(diào)
    // 參數(shù)dx:表示target本次滾動(dòng)產(chǎn)生的x方向的滾動(dòng)總距離
    // 參數(shù)dy:表示target本次滾動(dòng)產(chǎn)生的y方向的滾動(dòng)總距離
    // 參數(shù)consumed:表示父布局要消費(fèi)的滾動(dòng)距離,consumed[0]和consumed[1]分別表示父布局在x和y方向上消費(fèi)的距離.
    public void onNestedPreScroll(View target, int dx, int dy, int[] consumed) {
        int xConsumed = 0;
        int yConsumed = 0;
        boolean accepted = false;

        final int childCount = getChildCount();
        for (int i = 0; i < childCount; i++) {
            final View view = getChildAt(i);
            final LayoutParams lp = (LayoutParams) view.getLayoutParams();
            if (!lp.isNestedScrollAccepted()) {
                continue;
            }

            final Behavior viewBehavior = lp.getBehavior();
            if (viewBehavior != null) {
                mTempIntPair[0] = mTempIntPair[1] = 0;
                viewBehavior.onNestedPreScroll(this, view, target, dx, dy, mTempIntPair);

                xConsumed = dx > 0 ? Math.max(xConsumed, mTempIntPair[0]): Math.min(xConsumed, mTempIntPair[0]);
                yConsumed = dy > 0 ? Math.max(yConsumed, mTempIntPair[1]): Math.min(yConsumed, mTempIntPair[1]);

                accepted = true;
            }
        }

        consumed[0] = xConsumed;
        consumed[1] = yConsumed;

        if (accepted) {
            dispatchOnDependentViewChanged(true);
        }
    }

    // @param velocityX 水平方向速度
    // @param velocityY 垂直方向速度
    // @param consumed 子View是否消費(fèi)fling操作
    // @return true if this parent consumed or otherwise reacted to the fling
    public boolean onNestedFling(View target, float velocityX, float velocityY, boolean consumed) {
        boolean handled = false;

        final int childCount = getChildCount();
        for (int i = 0; i < childCount; i++) {
            final View view = getChildAt(i);
            final LayoutParams lp = (LayoutParams) view.getLayoutParams();
            if (!lp.isNestedScrollAccepted()) {
                continue;
            }

            final Behavior viewBehavior = lp.getBehavior();
            if (viewBehavior != null) {
                handled |= viewBehavior.onNestedFling(this, view, target, velocityX, velocityY,consumed);
            }
        }
        if (handled) {
            dispatchOnDependentViewChanged(true);
        }
        return handled;
    }

    public boolean onNestedPreFling(View target, float velocityX, float velocityY) {
        boolean handled = false;

        final int childCount = getChildCount();
        for (int i = 0; i < childCount; i++) {
            final View view = getChildAt(i);
            final LayoutParams lp = (LayoutParams) view.getLayoutParams();
            if (!lp.isNestedScrollAccepted()) {
                continue;
            }

            final Behavior viewBehavior = lp.getBehavior();
            if (viewBehavior != null) {
                handled |= viewBehavior.onNestedPreFling(this, view, target, velocityX, velocityY);
            }
        }
        return handled;
    }
    //支持嵌套滾動(dòng)的方向
    public int getNestedScrollAxes() {
        return mNestedScrollingParentHelper.getNestedScrollAxes();
    }
}

你會(huì)發(fā)現(xiàn) CoordiantorLayout 收到來(lái)自 NestedScrollingChild 的各種回調(diào)后,都是交由需要響應(yīng)的 Behavior 來(lái)處理的,所以這里可以得出一個(gè)結(jié)論,CoordiantorLayoutBehavior 的一個(gè)代理類(lèi),所以 Behavior 實(shí)際上也是一個(gè) NestedScrollingParent ,另外結(jié)合 NestedScrollingChild 實(shí)現(xiàn)的部分來(lái)看,你很容就能搞懂這些方法參數(shù)的實(shí)際含義

CoordiantorLayout , BehaviorNestedScrollingParent 三者關(guān)系

NstesdScroll
NstesdScroll

NestedScroll 小結(jié)

NestedScroll 的機(jī)制的簡(jiǎn)版是這樣的,當(dāng)子 View 在處理滑動(dòng)事件之前,先告訴自己的父 View 是否需要先處理這次滑動(dòng)事件,父 View 處理完之后,告訴子 View 它處理了多少滑動(dòng)距離,剩下的還是交給子 View 自己來(lái)處理

你也可以實(shí)現(xiàn)這樣的一套機(jī)制,父 View 攔截所有事件,然后分發(fā)給需要的子 View 來(lái)處理,然后剩余的自己來(lái)處理。但是這樣就做會(huì)使得邏輯處理更復(fù)雜,因?yàn)槭录膫鬟f本來(lái)就由外先內(nèi)傳遞到子 View ,處理機(jī)制是由內(nèi)向外,由子 View 先來(lái)處理事件本來(lái)就是遵守默認(rèn)規(guī)則的,這樣更自然且坑更少,不知道自己說(shuō)得對(duì)不對(duì),歡迎打臉( ̄ε(# ̄)☆╰╮( ̄▽?zhuān)?//)

CoordinatorLayout 的源碼走讀和如何自定義 Behavior

上面在分析 NestedScrollingParent 接口的時(shí)候已經(jīng)簡(jiǎn)單提到了 CoordinatorLayout 這個(gè)控件,至于這個(gè)控件是用來(lái)做什么的?CoordinatorLayout 內(nèi)部有個(gè) Behavior 對(duì)象,這個(gè) Behavior 對(duì)象可以通過(guò)外部 setter 或者在 xml
中指定的方式注入到 CoordinatorLayout 的某個(gè)子 View 的 LayoutParams,Behavior 對(duì)象定義了特定類(lèi)型的視圖交互邏輯,譬如 FloatingActionButtonBehavior 實(shí)現(xiàn)類(lèi),只要 FloatingActionButton
CoordinatorLayout 的子View,且設(shè)置的該 Behavior(默認(rèn)已經(jīng)設(shè)置了),那么,這個(gè) FAB 就會(huì)在 Snackbar
出現(xiàn)的時(shí)候上浮,而不至于被遮擋,而這種通過(guò)定義 Behavior 的方式就可以控制 View 的某一類(lèi)的行為,通常會(huì)比自定義 View 的方式更解耦更輕便,由此可知,BehaviorCoordinatorLayout 的精髓所在

Behavior 的解析和實(shí)例化

簡(jiǎn)單來(lái)看看 Behavior 是如何從 xml 中解析的,通過(guò)檢測(cè) xxx:behavior 屬性,通過(guò)全限定名或者相對(duì)路徑的形式指定路徑,最后通過(guò)反射來(lái)新建實(shí)例,默認(rèn)的構(gòu)造器是 Behavior(Context context, AttributeSet attrs) ,如果你需要配置額外的參數(shù),可以在外部構(gòu)造好 Behavior 并通過(guò) setter 的方式注入到 LayoutParams 或者獲取到解析好的
Behavior 進(jìn)行額外的參數(shù)設(shè)定

LayoutParams(Context context, AttributeSet attrs) {
    super(context, attrs);
    final TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.CoordinatorLayout_LayoutParams);
    //....
    mBehaviorResolved = a.hasValue(R.styleable.CoordinatorLayout_LayoutParams_layout_behavior); //通過(guò)apps:behavior屬性·
    if (mBehaviorResolved) {
        mBehavior = parseBehavior(context, attrs, a.getString(R.styleable.CoordinatorLayout_LayoutParams_layout_behavior));
    }
    a.recycle();
}

static Behavior parseBehavior(Context context, AttributeSet attrs, String name) {
    if (TextUtils.isEmpty(name)) {
        return null;
    }
    final String fullName;
    if (name.startsWith(".")) {
        // Relative to the app package. Prepend the app package name.
        fullName = context.getPackageName() + name;
    } else if (name.indexOf('.') >= 0) {
        // Fully qualified package name.
        fullName = name;
    } else {
        // Assume stock behavior in this package (if we have one)
        fullName = !TextUtils.isEmpty(WIDGET_PACKAGE_NAME) ? (WIDGET_PACKAGE_NAME + '.' + name) : name;
    }
    try {
        ///...
        if (c == null) {
            final Class<Behavior> clazz = (Class<Behavior>) Class.forName(fullName, true, context.getClassLoader());
            c = clazz.getConstructor(CONSTRUCTOR_PARAMS);
            c.setAccessible(true);
            //...
        }
        return c.newInstance(context, attrs);
    } catch (Exception e) {
        throw new RuntimeException("Could not inflate Behavior subclass " + fullName, e);
    }
}

兩種關(guān)系和兩種形式

View 之間的依賴(lài)關(guān)系

CoordinatorLayout 的子 View 可以扮演著不同角色,一種是被依賴(lài)的,而另外一種則是主動(dòng)尋找依賴(lài)的 View ,被依賴(lài)的 View 并不會(huì)感知到自己被依賴(lài),被依賴(lài)的 View 也有可能是尋找依賴(lài)的 View

這種依賴(lài)關(guān)系的建立由 CoordinatorLayout#LayoutParam 來(lái)指定,假設(shè)此時(shí)有兩個(gè) View:A 和 B,那么有兩種情況會(huì)導(dǎo)致依賴(lài)關(guān)系

  • A 的 anchor 是 B
  • A 的 behavior 對(duì) B 有依賴(lài)

LayoutParams 中關(guān)于依賴(lài)的判斷的依據(jù)的代碼如下

LayoutParams.class

boolean dependsOn(CoordinatorLayout parent, View child, View dependency) {
    return dependency == mAnchorDirectChild|| (mBehavior != null && mBehavior.layoutDependsOn(parent, child, dependency));
}

依賴(lài)判斷通過(guò)兩個(gè)條件判斷,一個(gè)生效即可,最容易理解的是根據(jù) Behavior#layoutDependsOn 方法指定,例如
FAB 依賴(lài) Snackbar

Behavior.java

@Override
public boolean layoutDependsOn(CoordinatorLayout parent, FloatingActionButton child, View dependency) {
    return Build.VERSION.SDK_INT >= 11 && dependency instanceof Snackbar.SnackbarLayout;
}

另外一個(gè)可以看到是通過(guò) mAnchorDirectChild 來(lái)判斷,首先要知道 AnchorView 的 ID 是通過(guò) setter 或者 xml 的
anchor 屬性形式指定,但是為了不需要每次都根據(jù)ID通過(guò) findViewById 去解析出 AnchorView,所以會(huì)使用
mAnchorView 變量緩存好,需要注意的是這個(gè) AnchorView 不可以是 CoordinatorLayout ,另外也不可以是當(dāng)前
View 的一個(gè)子 View ,變量 mAnchorDirectChild 記錄的就是 AnchorView 的所屬的ViewGroup或自身(當(dāng)它直接ViewParent是CoordinatorLayout的時(shí)候),關(guān)于 AnchorView的作用,也可以在 FAB 配合AppBarLayout使用的時(shí)候,AppBarLayout 會(huì)作為 FAB 的 AnchorView,就可以在 AppBarLayout 打開(kāi)或者收縮狀態(tài)的時(shí)候顯示或者隱藏 FAB,自己這方面的實(shí)踐比較少,在這也可以先忽略并不影響后續(xù)分析,大家感興趣的可以通過(guò)看相關(guān)代碼一探究竟

根據(jù)這種依賴(lài)關(guān)系,CoordinatorLayout 中維護(hù)了一個(gè) mDependencySortedChildren 列表,里面含有所有的子 View,按依賴(lài)關(guān)系排序,被依賴(lài)者排在前面,會(huì)在每次測(cè)量前重新排序,確保處理的順序是 **被依賴(lài)的 View 會(huì)先被
measure 和 layout **

final Comparator<View> mLayoutDependencyComparator = new Comparator<View>() {
    @Override
    public int compare(View lhs, View rhs) {
        if (lhs == rhs) {
            return 0;
        } else if (((LayoutParams) lhs.getLayoutParams()).dependsOn(CoordinatorLayout.this, lhs, rhs)) { //lhs 依賴(lài) rhs,lhs>rhs
            return 1;
        } else if (((LayoutParams) rhs.getLayoutParams()).dependsOn(CoordinatorLayout.this, rhs, lhs)) { // rhs 依賴(lài) lhs ,lhs<rhs
            return -1;
        } else {
            return 0;
        }
    }
};

selectionSort 方法使用的就是 mLayoutDependencyComparator 來(lái)處理,list 參數(shù)是所有子 View 的集合,這里使用了選擇排序法,遞增的方式,所以最后被依賴(lài)的 View 會(huì)排在最前

private static void selectionSort(final List<View> list, final Comparator<View> comparator) {
    if (list == null || list.size() < 2) { //只有一個(gè)的時(shí)候當(dāng)然不需要排序了
        return;
    }
    final View[] array = new View[list.size()];
    list.toArray(array);
    final int count = array.length;
    for (int i = 0; i < count; i++) {
        int min = i;
        for (int j = i + 1; j < count; j++) {
            if (comparator.compare(array[j], array[min]) < 0) {
                min = j;
            }
        }
        if (i != min) {
            // 把小的交換到前面
            final View minItem = array[min];
            array[min] = array[i];
            array[i] = minItem;
        }
    }
    list.clear();
    for (int i = 0; i < count; i++) {
        list.add(array[i]);
    }
}

這里有個(gè)疑問(wèn)?為什么不直接使用 Collections#sort(List<T> list, Comparator<? super T> comparator) 的方式來(lái)排序呢?我的想法是考慮到可能會(huì)出現(xiàn)這樣的一種情況,A 依賴(lài) B,B 依賴(lài) C,C 依賴(lài) A,這時(shí)候 Comparator 比較的時(shí)候,A > B,B > C,C > A,這就違背了 Comparator 所要求的傳遞性(根據(jù)傳遞性原則,A 應(yīng)該大于 C ),所以沒(méi)有使用 sort 方法來(lái)排序,不知道自己說(shuō)得是否正確,有知道的一定要告訴我,經(jīng)過(guò)選擇排序的方式的結(jié)果是 [C,B,A] ,所以雖然 C 依賴(lài) A,但也可能先處理了 C,這就如果你使用到這樣的依賴(lài)關(guān)系的時(shí)候就需要謹(jǐn)慎且注意了,例如你在 C 處理 onMeasureChild 的時(shí)候,你并不能得到 C 依賴(lài)的 A 的測(cè)量結(jié)果,因?yàn)?C 先于 A 處理了

依賴(lài)的監(jiān)聽(tīng)

這種依賴(lài)關(guān)系確定后又有什么作用呢?當(dāng)然是在主動(dòng)尋找依賴(lài)的View,在其依賴(lài)的View發(fā)生變化的時(shí)候,自己能夠知道啦,也就是如果CoordinatorLayout內(nèi)的A依賴(lài)B,在B的大小位置等發(fā)生狀態(tài)的時(shí)候,A可以監(jiān)聽(tīng)到,并作出響應(yīng),CoordinatorLayout又是怎么實(shí)現(xiàn)的呢?

CoordinatorLayout本身注冊(cè)了兩種監(jiān)聽(tīng)器,ViewTreeObserver.OnPreDrawListenerOnHierarchyChangeListener,一種是在繪制的之前進(jìn)行回調(diào),一種是在子View的層級(jí)結(jié)構(gòu)發(fā)生變化的時(shí)候回調(diào),有這兩種監(jiān)聽(tīng)就可以在接受到被依賴(lài)的View的變化了

監(jiān)聽(tīng)提供依賴(lài)的視圖的位置變化

OnPreDrawListenerCoordinatorLayout繪制之前回調(diào),因?yàn)樵?code>layout之后,所以可以很容易判斷到某個(gè)View的位置是否發(fā)生的改變

class OnPreDrawListener implements ViewTreeObserver.OnPreDrawListener {
    @Override
    public boolean onPreDraw() {
        dispatchOnDependentViewChanged(false);
        return true;
    }
}

dispatchOnDependentViewChanged方法,會(huì)遍歷根據(jù)依賴(lài)關(guān)系排序好的子View集合,找到位置改變了的View,并回調(diào)依賴(lài)這個(gè)View的BehavioronDependentViewChanged方法


void dispatchOnDependentViewChanged(final boolean fromNestedScroll) {
    final int layoutDirection = ViewCompat.getLayoutDirection(this);
    final int childCount = mDependencySortedChildren.size();
    for (int i = 0; i < childCount; i++) {
        final View child = mDependencySortedChildren.get(i);
        final LayoutParams lp = (LayoutParams) child.getLayoutParams();
        // Check child views before for anchor
        //...
        // Did it change? if not continue
        final Rect oldRect = mTempRect1;
        final Rect newRect = mTempRect2;
        getLastChildRect(child, oldRect);
        getChildRect(child, true, newRect);
        if (oldRect.equals(newRect)) { //比較前后兩次位置變化,位置沒(méi)發(fā)生改變就進(jìn)入下次循環(huán)得了
            continue;
        }
        recordLastChildRect(child, newRect);
        // 如果改變了,往后面位置中找到依賴(lài)當(dāng)前View的Behavior來(lái)進(jìn)行回調(diào)
        for (int j = i + 1; j < childCount; j++) {
            final View checkChild = mDependencySortedChildren.get(j);
            final LayoutParams checkLp = (LayoutParams) checkChild.getLayoutParams();
            final Behavior b = checkLp.getBehavior();

            if (b != null && b.layoutDependsOn(this, checkChild, child)) {
                if (!fromNestedScroll && checkLp.getChangedAfterNestedScroll()) {
                    // If this is not from a nested scroll and we have already been changed from a nested scroll, skip the dispatch and reset the flag
                    checkLp.resetChangedAfterNestedScroll();
                    continue;
                }
                final boolean handled = b.onDependentViewChanged(this, checkChild, child);
                if (fromNestedScroll) {
                    // If this is from a nested scroll, set the flag so that we may skip any resulting onPreDraw dispatch (if needed)
                    checkLp.setChangedAfterNestedScroll(handled);
                }
            }
        }
    }
}

監(jiān)聽(tīng)提供依賴(lài)的View的添加和移除

HierarchyChangeListener在View的添加和移除都會(huì)回調(diào)

private class HierarchyChangeListener implements OnHierarchyChangeListener {
    //...
    @Override
    public void onChildViewRemoved(View parent, View child) {
        dispatchDependentViewRemoved(child);
        //..
    }
}

根據(jù)情況回調(diào)Behavior#onDependentViewRemoved

void dispatchDependentViewRemoved(View view) {
    final int childCount = mDependencySortedChildren.size();
    boolean viewSeen = false;
    for (int i = 0; i < childCount; i++) {
        final View child = mDependencySortedChildren.get(i);
        if (child == view) {
            // 只需要判斷后續(xù)位置的View是否依賴(lài)當(dāng)前View并回調(diào)
            viewSeen = true;
            continue;
        }
        if (viewSeen) {
            CoordinatorLayout.LayoutParams lp = (CoordinatorLayout.LayoutParams)child.getLayoutParams();
            CoordinatorLayout.Behavior b = lp.getBehavior();
            if (b != null && lp.dependsOn(this, child, view)) {
                b.onDependentViewRemoved(this, child, view);
            }
        }
    }
}

自定義 Behavior 的兩種目的

我們可以按照兩種目的來(lái)實(shí)現(xiàn)自己的 Behavior,當(dāng)然也可以?xún)煞N都實(shí)現(xiàn)啦

  • 某個(gè) view 監(jiān)聽(tīng)另一個(gè) view 的狀態(tài)變化,例如大小、位置、顯示狀態(tài)等

  • 某個(gè) view 監(jiān)聽(tīng) CoordinatorLayout 內(nèi)的 NestedScrollingChild 的接口實(shí)現(xiàn)類(lèi)的滑動(dòng)狀態(tài)

第一種情況需要重寫(xiě) layoutDependsOnonDependentViewChanged 方法

第二種情況需要重寫(xiě) onStartNestedScrollonNestedPreScroll 系列方法(上面已經(jīng)提到了哦)

對(duì)于第一種情況,我們之前分析依賴(lài)的監(jiān)聽(tīng)的時(shí)候相關(guān)回調(diào)細(xì)節(jié)已經(jīng)說(shuō)完了,Behavior 只需要在
onDependentViewChanged 做相應(yīng)的處理就好

對(duì)于第二種情況,我們?cè)?NestedScoll 的那節(jié)也已經(jīng)把相關(guān)回調(diào)細(xì)節(jié)說(shuō)了

CoordinatorLayout的事件傳遞

CoordinatorLayout并不會(huì)直接處理觸摸事件,而是盡可能地先交由子View的Behavior來(lái)處理,它的onInterceptTouchEventonTouchEvent兩個(gè)方法最終都是調(diào)用performIntercept方法,用來(lái)分發(fā)不同的事件類(lèi)型分發(fā)給對(duì)應(yīng)的子View的Behavior處理

//處理攔截或者自己的觸摸事件
private boolean performIntercept(MotionEvent ev, final int type) {
    boolean intercepted = false;
    boolean newBlock = false;

    MotionEvent cancelEvent = null;

    final int action = MotionEventCompat.getActionMasked(ev);

    final List<View> topmostChildList = mTempList1;
    getTopSortedChildren(topmostChildList); //在5.0以上,按照z屬性來(lái)排序,以下,則是按照添加順序或者自定義的繪制順序來(lái)排列

    // Let topmost child views inspect first
    final int childCount = topmostChildList.size();
    for (int i = 0; i < childCount; i++) {
        final View child = topmostChildList.get(i);
        final LayoutParams lp = (LayoutParams) child.getLayoutParams();
        final Behavior b = lp.getBehavior();
        // 如果有一個(gè)behavior對(duì)事件進(jìn)行了攔截,就發(fā)送Cancel事件給后續(xù)的所有Behavior。假設(shè)之前還沒(méi)有Intercept發(fā)生,那么所有的事件都平等地對(duì)所有含有behavior的view進(jìn)行分發(fā),現(xiàn)在intercept忽然出現(xiàn),那么相應(yīng)的我們就要對(duì)除了Intercept的view發(fā)出Cancel
        if ((intercepted || newBlock) && action != MotionEvent.ACTION_DOWN) {
            // Cancel all behaviors beneath the one that intercepted.
            // If the event is "down" then we don't have anything to cancel yet.
            if (b != null) {
                if (cancelEvent == null) {
                    final long now = SystemClock.uptimeMillis();
                    cancelEvent = MotionEvent.obtain(now, now, MotionEvent.ACTION_CANCEL, 0.0f, 0.0f, 0);
                }
                switch (type) {
                    case TYPE_ON_INTERCEPT:
                        b.onInterceptTouchEvent(this, child, cancelEvent);
                        break;
                    case TYPE_ON_TOUCH:
                        b.onTouchEvent(this, child, cancelEvent);
                        break;
                }
            }
            continue;
        }

        if (!intercepted && b != null) {
            switch (type) {
                case TYPE_ON_INTERCEPT:
                    intercepted = b.onInterceptTouchEvent(this, child, ev);
                    break;
                case TYPE_ON_TOUCH:
                    intercepted = b.onTouchEvent(this, child, ev);  
                    break;
            }
            if (intercepted) {
                mBehaviorTouchView = child; //記錄當(dāng)前需要處理事件的View
            }
        }

        // Don't keep going if we're not allowing interaction below this.
        // Setting newBlock will make sure we cancel the rest of the behaviors.
        final boolean wasBlocking = lp.didBlockInteraction();
        final boolean isBlocking = lp.isBlockingInteractionBelow(this, child); //behaviors是否攔截事件
        newBlock = isBlocking && !wasBlocking;
        if (isBlocking && !newBlock) {
            // Stop here since we don't have anything more to cancel - we already did
            // when the behavior first started blocking things below this point.
            break;
        }
    }
    topmostChildList.clear();
    return intercepted;
}

小結(jié)

以上,基本可以理清 CoordinatorLayout 的機(jī)制,一個(gè) View 如何監(jiān)聽(tīng)到依賴(lài) View 的變化,和 CoordinatorLayout 中的 NestedScrollingChild 實(shí)現(xiàn) NestedScroll 的機(jī)制,觸摸事件又是如何被 Behavior 攔截和處理,另外還有測(cè)量和布局我在這里并沒(méi)有提及,但基本就是按照依賴(lài)關(guān)系排序,遍歷子 View,詢(xún)問(wèn)它們的
Behavior 是否需要處理,大家可以翻翻源碼,這樣可以有更深刻的體會(huì),有了這些知識(shí),我們基本就可以根據(jù)需求來(lái)自定義自己的 Behavior 了,下面也帶大家來(lái)實(shí)踐下我是如何用自定義 Behavior 實(shí)現(xiàn) UC 主頁(yè)的

UC 主頁(yè)實(shí)現(xiàn)分析

先來(lái)看看 UC 瀏覽器的主頁(yè)的效果圖

UC主頁(yè)效果
UC主頁(yè)效果

可以看到有一共有4種元素的交互,這里分別稱(chēng)為 Title 元素、Header 元素、Tab 元素和新聞列表元素

在往上拖動(dòng)列表頁(yè)而還沒(méi)進(jìn)入到新聞閱讀狀態(tài)的時(shí)候,我們需要一個(gè)容器來(lái)完全消費(fèi)掉這個(gè)拖動(dòng)事件,避免列表項(xiàng)向上滾動(dòng),同時(shí) Tab 和 Title 則分別從列表頂部和 CoordinatorLayout 頂部出現(xiàn),Header 也有往上偏移一段距離,而到底誰(shuí)來(lái)扮演這個(gè)角色呢?我們需要先確定它們之間的依賴(lài)關(guān)系

確定依賴(lài)關(guān)系

在編碼之前,首先還需要確定這些元素的依賴(lài)關(guān)系,看下圖來(lái)比較下前后的狀態(tài)

狀態(tài)變化
狀態(tài)變化

根據(jù)前后效果的對(duì)比圖,我們可以使 Header 作為唯一被依賴(lài)的 View 來(lái)處理,列表容器和 Tab 容器隨著 Header 上移動(dòng)而上移動(dòng),Title 隨著 Header 的上移動(dòng)而下移出現(xiàn),在這個(gè)完整的過(guò)程中,我們定義 Header 一共向上移動(dòng)了
offestHeader 的高度,Title 向下偏移了 Title 這個(gè)容器的高度,Tab 則向上偏移了 Tab 這個(gè)容器的高度,而列表偏移的高度是 [offestHeader - Title容器高度 - Tab容器高度]

實(shí)現(xiàn)頭部和列表的 NestedScroll 效果

首先考慮列表頁(yè),因?yàn)榱斜眄?yè)可以左右切換,所以這里使用 ViewPager 作為列表頁(yè)的容器,列表頁(yè)需要放置在
Header 之下,且隨著 Header 的上移收縮,列表頁(yè)也需要上移,在這里我們首先需要解決兩個(gè)問(wèn)題

  • 1.列表頁(yè)置于 Header 之下
  • 2.列表頁(yè)上移留白問(wèn)題

首先來(lái)解決第一個(gè)問(wèn)題-列表頁(yè)置于 Header 之下,CoordinatorLayout 繼承來(lái)自 ViewGroup,默認(rèn)的布局行為更像是一個(gè) FrameLayout,不是 RelativeLayout 所以并不能用 layout_below 等屬性來(lái)控制它的相對(duì)位置,而某些情況下,我們可以給 Header 的高度設(shè)定一個(gè)準(zhǔn)確值,例如 250dip ,那么我們的的列表頁(yè)的 marginTop 設(shè)置為
250dip 就好了,但是通常,我們的 Header 高度是不定的,所以我們需要一種能夠適配這種變化的方法,所以我能想到的就是重寫(xiě)列表頁(yè)的 layout 過(guò)程,Behavior 提供了 onLayoutChild 方法可以讓我們實(shí)現(xiàn),很好;接著來(lái)思考列表頁(yè)上移留白問(wèn)題,這是因?yàn)樵?CoordinatorLayout 測(cè)量布局完成后,記此時(shí)列表高度為 H,但隨著 Header 上移 H2 個(gè)高度的時(shí)候,列表也隨著移動(dòng)一定高度,但是列表高度還是 H,效果不言而喻,所以,我們需要在子 View
測(cè)量的時(shí)候,添加上列表的最大偏移量 [H2 - Title容器高度 - Tab容器高度],下面來(lái)看代碼,其實(shí)這就和系統(tǒng)
AppBarLayout 下的滾動(dòng)列表處理一樣的,我們會(huì)在 AppBarLayout 下放置的 View 設(shè)定一個(gè)這樣的
app:layout_behavior="@string/appbar_scrolling_view_behavior" Behavior 屬性,所以提供已經(jīng)提供了這樣的一個(gè)基類(lèi)來(lái)處理了,只不過(guò)它是包級(jí)私有,需要我們另外 copy 一份出來(lái),來(lái)看看代碼吧,繼承自同樣 sdk 提供的包級(jí)私有的ViewOffsetBehavior類(lèi),ViewOffsetBehavior使用ViewOffsetHelper` 方便對(duì) View 進(jìn)行偏移處理,代碼不多且功能也沒(méi)使用到,所以就不貼了,可以自己看


public abstract class HeaderScrollingViewBehavior extends ViewOffsetBehavior<View> {
    private final Rect mTempRect1 = new Rect();
    private final Rect mTempRect2 = new Rect();

    private int mVerticalLayoutGap = 0;
    private int mOverlayTop;

    public HeaderScrollingViewBehavior() {
    }

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

    @Override
    public boolean onMeasureChild(CoordinatorLayout parent, View child, int parentWidthMeasureSpec, int widthUsed, int parentHeightMeasureSpec, int heightUsed) {
        final int childLpHeight = child.getLayoutParams().height;
        if (childLpHeight == ViewGroup.LayoutParams.MATCH_PARENT || childLpHeight == ViewGroup.LayoutParams.WRAP_CONTENT) {
            // If the menu's height is set to match_parent/wrap_content then measure it with the maximum visible height
            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;
                    }
                }
                if (ViewCompat.isLaidOut(header)) {
                    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);

                    // Now measure the scrolling view with the correct height
                    parent.onMeasureChild(child, parentWidthMeasureSpec, widthUsed, heightMeasureSpec, heightUsed);

                    return true;
                }
            }
        }
        return false;
    }

    @Override
    protected void layoutChild(final CoordinatorLayout parent, final View child, final 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);

            final Rect out = mTempRect2;
            GravityCompat.apply(resolveGravity(lp.gravity), child.getMeasuredWidth(), child.getMeasuredHeight(), available, out, layoutDirection);

            final int overlap = getOverlapPixelsForOffset(header);

            child.layout(out.left, out.top - overlap, out.right, out.bottom - overlap);
            mVerticalLayoutGap = out.top - header.getBottom();
        } else {
            // If we don't have a dependency, let super handle it
            super.layoutChild(parent, child, layoutDirection);
            mVerticalLayoutGap = 0;
        }
    }

    float getOverlapRatioForOffset(final View header) {
        return 1f;
    }

    final int getOverlapPixelsForOffset(final View header) {
        return mOverlayTop == 0 ? 0 : MathUtils.constrain(Math.round(getOverlapRatioForOffset(header) * mOverlayTop), 0, mOverlayTop);

    }

    private static int resolveGravity(int gravity) {
        return gravity == Gravity.NO_GRAVITY ? GravityCompat.START | Gravity.TOP : gravity;
    }

    //需要子類(lèi)來(lái)實(shí)現(xiàn),從CoordinatorLayout中找到第一個(gè)child view依賴(lài)的View
    protected abstract View findFirstDependency(List<View> views);

    //返回Header可以收縮的范圍,默認(rèn)為Header高度,完全隱藏
    protected int getScrollRange(View v) {
        return v.getMeasuredHeight();
    }

    /**
     * The gap between the top of the scrolling view and the bottom of the header layout in pixels.
     */
    final int getVerticalLayoutGap() {
        return mVerticalLayoutGap;
    }

    /**
     * Set the distance that this view should overlap any {@link AppBarLayout}.
     *
     * @param overlayTop the distance in px
     */
    public final void setOverlayTop(int overlayTop) {
        mOverlayTop = overlayTop;
    }

    /**
     * Returns the distance that this view should overlap any {@link AppBarLayout}.
     */
    public final int getOverlayTop() {
        return mOverlayTop;
    }

}

這個(gè)基類(lèi)的代碼還是很好理解的,因?yàn)橹熬驼f(shuō)過(guò)了,正常來(lái)說(shuō)被依賴(lài)的 View 會(huì)優(yōu)先于依賴(lài)它的 View 處理,所以需要依賴(lài)的 View 可以在 measure/layout 的時(shí)候,找到依賴(lài)的 View 并獲取到它的測(cè)量/布局的信息,這里的處理就是依靠著這種關(guān)系來(lái)實(shí)現(xiàn)的

我們的實(shí)現(xiàn)類(lèi),需要重寫(xiě)的除了抽象方法 findFirstDependency 外,還需要重寫(xiě) getScrollRange,我們把 Header
的 Id id_uc_news_header_pager 定義在 ids.xml 資源文件內(nèi),方便依賴(lài)的判斷;至于縮放的高度,根據(jù) 結(jié)果圖 得知是 Header高度 - Title高度 - Tab高度,把 Title 高度 uc_news_header_title_height 和 Tab 視圖的高度
uc_news_tabs_height 也定義在 dimens.xml,得出如下代碼


public class UcNewsContentBehavior extends HeaderScrollingViewBehavior {
    //省略構(gòu)造信息
    @Override
    public boolean layoutDependsOn(CoordinatorLayout parent, View child, View dependency) {
        return isDependOn(dependency);
    }

    @Override
    public boolean onDependentViewChanged(CoordinatorLayout parent, View child, View dependency) {
        //省略,還未講到
    }
    //通過(guò)ID判讀,找到第一個(gè)依賴(lài)
    @Override
    protected View findFirstDependency(List<View> views) {
        for (int i = 0, z = views.size(); i < z; i++) {
            View view = views.get(i);
            if (isDependOn(view))
                return view;
        }
        return null;
    }

    @Override
    protected int getScrollRange(View v) {
        if (isDependOn(v)) {
            return Math.max(0, v.getMeasuredHeight() - getFinalHeight());
        } else {
            return super.getScrollRange(v);
        }
    }

    private int getFinalHeight() {
        return DemoApplication.getAppContext().getResources().getDimensionPixelOffset(R.dimen.uc_news_tabs_height) + DemoApplication.getAppContext().getResources().getDimensionPixelOffset(R.dimen.uc_news_header_title_height);
    }
    //依賴(lài)的判斷
    private boolean isDependOn(View dependency) {
        return dependency != null && dependency.getId() == R.id.id_uc_news_header_pager;
    }
}

好了,列表頁(yè)初始狀態(tài)完成了,接著列表頁(yè)需要根據(jù) Header 的上移而上移,上移使用 TranslationY 屬性來(lái)控制即可,在 dimens.xml 中定義好 Header 的偏移范圍值 uc_news_header_pager_offset ,當(dāng) Header 偏移了
uc_news_header_pager_offset 的時(shí)候,列表頁(yè)的向上偏移值應(yīng)該是 getScrollRange() 方法計(jì)算出的結(jié)果,那么,在接受到 onDependentViewChanged 的時(shí)候,列表頁(yè)的 TranslationY 計(jì)算公式為:header.getTranslationY() / H(uc_news_header_pager_offset) * getScrollRange

列表頁(yè)的Behavior最終代碼如下:


//列表頁(yè)的Behavior
public class UcNewsContentBehavior extends HeaderScrollingViewBehavior {
    //...省略構(gòu)造信息
    @Override
    public boolean layoutDependsOn(CoordinatorLayout parent, View child, View dependency) {
        return isDependOn(dependency);
    }
    @Override
    public boolean onDependentViewChanged(CoordinatorLayout parent, View child, View dependency) {
        offsetChildAsNeeded(parent, child, dependency);
        return false;
    }

    private void offsetChildAsNeeded(CoordinatorLayout parent, View child, View dependency) {
        child.setTranslationY((int) (-dependency.getTranslationY() / (getHeaderOffsetRange() * 1.0f) * getScrollRange(dependency)));

    }

    @Override
    protected View findFirstDependency(List<View> views) {
        for (int i = 0, z = views.size(); i < z; i++) {
            View view = views.get(i);
            if (isDependOn(view))
                return view;
        }
        return null;
    }

    @Override
    protected int getScrollRange(View v) {
        if (isDependOn(v)) {
            return Math.max(0, v.getMeasuredHeight() - getFinalHeight());
        } else {
            return super.getScrollRange(v);
        }
    }

    private int getHeaderOffsetRange() {
        return DemoApplication.getAppContext().getResources().getDimensionPixelOffset(R.dimen.uc_news_header_pager_offset);
    }

    private int getFinalHeight() {
        return DemoApplication.getAppContext().getResources().getDimensionPixelOffset(R.dimen.uc_news_tabs_height) + DemoApplication.getAppContext().getResources().getDimensionPixelOffset(R.dimen.uc_news_header_title_height);
    }
    //依賴(lài)的判斷
    private boolean isDependOn(View dependency) {
        return dependency != null && dependency.getId() == R.id.id_uc_news_header_pager;
    }
}

第一個(gè)難啃的骨頭終于搞定,接著是來(lái)自 Header 的挑戰(zhàn)

Header 的滾動(dòng)事件來(lái)源于列表頁(yè)中的 NestedScrollingChild,所以 Header 的 Behavior 需要重寫(xiě)于 NestedScroll
相關(guān)的方法,不僅僅需要攔截 Scroll 事件還需要攔截 Fling 事件,通過(guò)改變 TranslationY 值來(lái)"消費(fèi)"掉這些事件,另外需要為該 Behavior 定義兩種狀態(tài),打開(kāi)和關(guān)閉,而如果在滑動(dòng)中途手指離開(kāi)( ACTION_UP 或者
ACTION_CANCEL ),需要根據(jù)偏移量來(lái)判斷進(jìn)入打開(kāi)還是關(guān)閉狀態(tài),這里我使用 Scroller + Runnalbe 來(lái)進(jìn)行動(dòng)畫(huà)效果,因?yàn)橹苯邮褂?ViewPropertyAnimator 得到的結(jié)果不太理想,具體可以看代碼的注釋?zhuān)筒患?xì)講了

public class UcNewsHeaderPagerBehavior extends ViewOffsetBehavior {
    private static final String TAG = "UcNewsHeaderPager";
    public static final int STATE_OPENED = 0;
    public static final int STATE_CLOSED = 1;
    public static final int DURATION_SHORT = 300;
    public static final int DURATION_LONG = 600;

    private int mCurState = STATE_OPENED;

    private OverScroller mOverScroller;

    //...省略構(gòu)造信息

    private void init() { //構(gòu)造器中調(diào)用
        mOverScroller = new OverScroller(DemoApplication.getAppContext());
    }

    @Override
    protected void layoutChild(CoordinatorLayout parent, View child, int layoutDirection) {
        super.layoutChild(parent, child, layoutDirection);
    }

    @Override
    public boolean onStartNestedScroll(CoordinatorLayout coordinatorLayout, View child, View directTargetChild, View target, int nestedScrollAxes) {
        //攔截垂直方向上的滾動(dòng)事件且當(dāng)前狀態(tài)是打開(kāi)的并且還可以繼續(xù)向上收縮
        return (nestedScrollAxes & ViewCompat.SCROLL_AXIS_VERTICAL) != 0 && canScroll(child, 0) && !isClosed(child);
    }

    private boolean canScroll(View child, float pendingDy) {
        int pendingTranslationY = (int) (child.getTranslationY() - pendingDy);
        if (pendingTranslationY >= getHeaderOffsetRange() && pendingTranslationY <= 0) {
            return true;
        }
        return false;
    }

    @Override
    public boolean onNestedPreFling(CoordinatorLayout coordinatorLayout, View child, View target, float velocityX, float velocityY) {
        // consumed the flinging behavior until Closed
        return !isClosed(child);
    }

    private boolean isClosed(View child) {
        boolean isClosed = child.getTranslationY() == getHeaderOffsetRange();
        return isClosed;
    }

    public boolean isClosed() {
        return mCurState == STATE_CLOSED;
    }

    private void changeState(int newState) {
        if (mCurState != newState) {
            mCurState = newState;
        }
    }

    @Override
    public boolean onInterceptTouchEvent(CoordinatorLayout parent, final View child, MotionEvent ev) {
        if (ev.getAction() == MotionEvent.ACTION_UP && !isClosed()) {
            handleActionUp(parent, child);
        }
        return super.onInterceptTouchEvent(parent, child, ev);
    }

    @Override
    public void onNestedPreScroll(CoordinatorLayout coordinatorLayout, View 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; //消費(fèi)掉其中的4分之1,不至于滑動(dòng)效果太靈敏
        if (!canScroll(child, halfOfDis)) {
            child.setTranslationY(halfOfDis > 0 ? getHeaderOffsetRange() : 0);
        } else {
            child.setTranslationY(child.getTranslationY() - halfOfDis);
        }
        //只要開(kāi)始攔截,就需要把所有Scroll事件消費(fèi)掉
        consumed[1] = dy;
    }

    //Header偏移量
    private int getHeaderOffsetRange() {
        return DemoApplication.getAppContext().getResources().getDimensionPixelOffset(R.dimen.uc_news_header_pager_offset);
    }


    private void handleActionUp(CoordinatorLayout parent, final View child) {
        if (mFlingRunnable != null) {
            child.removeCallbacks(mFlingRunnable);
            mFlingRunnable = null;
        }
        mFlingRunnable = new FlingRunnable(parent, child);
        if (child.getTranslationY() < getHeaderOffsetRange() / 3.0f) {
            mFlingRunnable.scrollToClosed(DURATION_SHORT);
        } else {
            mFlingRunnable.scrollToOpen(DURATION_SHORT);
        }

    }
    //結(jié)束動(dòng)畫(huà)的時(shí)候調(diào)用,并改變狀態(tài)
    private void onFlingFinished(CoordinatorLayout coordinatorLayout, View layout) {
        changeState(isClosed(layout) ? STATE_CLOSED : STATE_OPENED);
    }


    private FlingRunnable mFlingRunnable;

    /**
     * For animation , Why not use {@link android.view.ViewPropertyAnimator } to play animation is of the
     * other {@link android.support.design.widget.CoordinatorLayout.Behavior} that depend on this could not receiving the correct result of
     * {@link View#getTranslationY()} after animation finished for whatever reason that i don't know
     */
    private class FlingRunnable implements Runnable {
        private final CoordinatorLayout mParent;
        private final View mLayout;

        FlingRunnable(CoordinatorLayout parent, View layout) {
            mParent = parent;
            mLayout = layout;
        }

        public void scrollToClosed(int duration) {
            float curTranslationY = ViewCompat.getTranslationY(mLayout);
            float dy = getHeaderOffsetRange() - curTranslationY;
            //這里做了些處理,避免有時(shí)候會(huì)有1-2Px的誤差結(jié)果,導(dǎo)致最終效果不好
            mOverScroller.startScroll(0, Math.round(curTranslationY - 0.1f), 0, Math.round(dy + 0.1f), duration);
            start();
        }

        public void scrollToOpen(int duration) {
            float curTranslationY = ViewCompat.getTranslationY(mLayout);
            mOverScroller.startScroll(0, (int) curTranslationY, 0, (int) -curTranslationY, duration);
            start();
        }

        private void start() {
            if (mOverScroller.computeScrollOffset()) {
                ViewCompat.postOnAnimation(mLayout, mFlingRunnable);
            } else {
                onFlingFinished(mParent, mLayout);
            }
        }

        @Override
        public void run() {
            if (mLayout != null && mOverScroller != null) {
                if (mOverScroller.computeScrollOffset()) {
                    ViewCompat.setTranslationY(mLayout, mOverScroller.getCurrY());
                    ViewCompat.postOnAnimation(mLayout, this);
                } else {
                    onFlingFinished(mParent, mLayout);
                }
            }
        }
    }
}

實(shí)現(xiàn)標(biāo)題視圖和 Tab 視圖跟隨頭部的實(shí)時(shí)移動(dòng)

剩下 Title 和 Tab 的 Behavior ,相對(duì)上兩個(gè)來(lái)說(shuō)是比較簡(jiǎn)單的,都只需要子在 onDependentViewChanged 方法中,根據(jù) Header 的變化而改變 TranslationY 值即可

Title 的 Behavior,為了簡(jiǎn)單,Title 直接設(shè)置 TopMargin 來(lái)使得初始狀態(tài)完全偏移出父容器

public class UcNewsTitleBehavior extends CoordinatorLayout.Behavior<View> {
    //...構(gòu)造信息

    @Override
    public boolean onLayoutChild(CoordinatorLayout parent, View child, int layoutDirection) {
        // FIXME: 16/7/27 不知道為啥在XML設(shè)置-45dip,解析出來(lái)的topMargin少了1個(gè)px,所以這里用代碼設(shè)置一遍
        ((CoordinatorLayout.LayoutParams) child.getLayoutParams()).topMargin = -getTitleHeight();
        parent.onLayoutChild(child, layoutDirection);
        return true;
    }

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

    @Override
    public boolean onDependentViewChanged(CoordinatorLayout parent, View child, View dependency) {
        offsetChildAsNeeded(parent, child, dependency);
        return false;
    }

    private void offsetChildAsNeeded(CoordinatorLayout parent, View child, View dependency) {
        int headerOffsetRange = getHeaderOffsetRange();
        int titleOffsetRange = getTitleHeight();
        if (dependency.getTranslationY() == headerOffsetRange) {
            child.setTranslationY(titleOffsetRange); //直接設(shè)置終值,避免出現(xiàn)誤差
        } else if (dependency.getTranslationY() == 0) {
            child.setTranslationY(0); //直接設(shè)置初始值
        } else {
            //根據(jù)Header的TranslationY值來(lái)改變自身的TranslationY
            child.setTranslationY((int) (dependency.getTranslationY() / (headerOffsetRange * 1.0f) * titleOffsetRange));
        }
    }
    //Header偏移值
    private int getHeaderOffsetRange() {
        return DemoApplication.getAppContext().getResources().getDimensionPixelOffset(R.dimen.uc_news_header_pager_offset);
    }
    //標(biāo)題高度
    private int getTitleHeight() {
        return DemoApplication.getAppContext().getResources().getDimensionPixelOffset(R.dimen.uc_news_header_title_height);
    }
    //依賴(lài)判斷
    private boolean isDependOn(View dependency) {
        return dependency != null && dependency.getId() == R.id.id_uc_news_header_pager;
    }
}

Tab初始狀態(tài)需要放置在Header之下,所以還是繼承自HeaderScrollingViewBehavior,因?yàn)橹付ǖ母叨?,所以LayoutParams得Mode為EXACTLY,所以在測(cè)量的時(shí)候不會(huì)被特殊處理

public class UcNewsTabBehavior extends HeaderScrollingViewBehavior {

    //..省略構(gòu)造信息
    @Override
    public boolean layoutDependsOn(CoordinatorLayout parent, View child, View dependency) {
        return isDependOn(dependency);
    }

    @Override
    public boolean onDependentViewChanged(CoordinatorLayout parent, View child, View dependency) {
        offsetChildAsNeeded(parent, child, dependency);
        return false;
    }

    private void offsetChildAsNeeded(CoordinatorLayout parent, View child, View dependency) {
        float offsetRange = dependency.getTop() + getFinalHeight() - child.getTop();
        int headerOffsetRange = getHeaderOffsetRange();
        if (dependency.getTranslationY() == headerOffsetRange) {
            child.setTranslationY(offsetRange);  //直接設(shè)置終值,避免出現(xiàn)誤差
        } else if (dependency.getTranslationY() == 0) {
            child.setTranslationY(0);//直接設(shè)置初始值
        } else {
            child.setTranslationY((int) (dependency.getTranslationY() / (getHeaderOffsetRange() * 1.0f) * offsetRange));
        }
    }

    @Override
    protected View findFirstDependency(List<View> views) {
        for (int i = 0, z = views.size(); i < z; i++) {
            View view = views.get(i);
            if (isDependOn(view))
                return view;
        }
        return null;
    }

    private int getHeaderOffsetRange() {
        return DemoApplication.getAppContext().getResources().getDimensionPixelOffset(R.dimen.uc_news_header_pager_offset);
    }

    private int getFinalHeight() {
        return DemoApplication.getAppContext().getResources().getDimensionPixelOffset(R.dimen.uc_news_header_title_height);
    }
    private boolean isDependOn(View dependency) {
        return dependency != null && dependency.getId() == R.id.id_uc_news_header_pager;
    }
}

最后布局代碼就貼了,代碼已經(jīng)上傳到 GITHUB ,可以上去看看且順便給個(gè) star 吧

寫(xiě)在最后

目前來(lái)說(shuō),Demo 還可以有更進(jìn)一步的完善,例如在打開(kāi)模式的情況下,禁止列表頁(yè) ViewPager 的左右滑動(dòng),且設(shè)置選中的 Pager 位置為 0 并列表滾動(dòng)到第一個(gè)位置,每個(gè)列表還可以增加下拉刷新功能等...但是這些都和主題
Behavior 無(wú)關(guān),所以就不再去實(shí)現(xiàn)了

如果你看完了文章且覺(jué)得有用,那么我希望你能順手點(diǎn)個(gè)推薦/喜歡/收藏,寫(xiě)一篇用心的技術(shù)分享文章的確不容易(能抽這么多時(shí)間來(lái)寫(xiě)這篇文章,其實(shí)主要是因?yàn)檫@幾天公寓斷網(wǎng)了、網(wǎng)了、了...這一斷就一星期,所以也拖延了發(fā)布時(shí)間)

那些有用的參考資料

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書(shū)系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容