自定義Behavior的藝術探索-仿UC瀏覽器主頁

前言&效果預覽

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

效果圖
效果圖

揭開 NestedScrolling 的原理

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

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

NestedScrollView
NestedScrollView

實現(xiàn) NestedScrollingChild 接口

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

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

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

// 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();
}

//在初始化滾動操作的時候調用,一般在 MotionEvent#ACTION_DOWN 的時候調用
@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方法的返回有關聯(lián)
@Override
public boolean dispatchNestedScroll(int dxConsumed, int dyConsumed, int dxUnconsumed,int dyUnconsumed, int[] offsetInWindow) {
    return mChildHelper.dispatchNestedScroll(dxConsumed, dyConsumed, dxUnconsumed, dyUnconsumed,offsetInWindow);
}

//在消費滾動事件之前調用,提供一個讓ViewParent實現(xiàn)聯(lián)合滾動的機會,因此ViewParent可以消費一部分或者全部的滑動事件,參數(shù)consumed會記錄ViewParent所消費掉的事件
@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);
}

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

startNestedScroll 和 stopNestedScroll 的調用

startNestedScroll配合stopNestedScroll使用,startNestedScroll會再接收到ACTION_DOWN的時候調用,stopNestedScroll會在接收到ACTION_UP|ACTION_CANCEL的時候調用,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 方法,可以看出可能會調用 Parent 的 onStartNestedScroll
onNestedScrollAccepted 方法,只要 Parent 愿意優(yōu)先處理這次的滑動事件,在結束的時候 Parent 還會收到
onStopNestedScroll 回調

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 的調用

在消費滾動事件之前調用,提供一個讓 Parent 實現(xiàn)聯(lián)合滾動的機會,因此 Parent 可以消費一部分或者全部的滑動事件,注意參數(shù) consumed 會記錄了 Parent 所消費掉的事件

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

NestedScrollingChildHelper 處理 dispatchNestedPreScroll 方法,會調用到上一步里記錄的希望優(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 的調用

這個方法是在 Child 自己消費完 Scroll 事件后調用的

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

NestedScrollingChildHelper 處理 dispatchNestedScroll 方法,會調用到上一步里記錄的希望優(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;
}

實現(xiàn) NestedScrollingParent 接口

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

每次滑動前,Child 先詢問 Parent 是否需要滑動,即 dispatchNestedPreScroll() ,這就回調到 Parent 的
onNestedPreScroll(),Parent 可以在這個回調中消費掉 Child 的 Scroll 事件,也就是優(yōu)先于 Child 滑動

Child 滑動以后,會調用 dispatchNestedScroll() ,回調到 Parent 的 onNestedScroll() ,這里就是 Child 滑動后,剩下的給 Parent 處理,也就是后于 Child 滑動

最后,滑動結束 Child 調用 stopNestedScroll,回調 Parent 的 onStopNestedScroll() 表示本次處理結束

現(xiàn)在我們來看看 NestedScrollingParent 的實現(xiàn)細節(jié),這里以 CoordinatorLayout 來分析而不再是
NestedScrollView ,因為它才是這篇文章的主角

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

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

你會發(fā)現(xiàn)以上這些方法對應了 NestedScrollingParent 接口的方法,只是在參數(shù)上有所增加,且都會在
CoordiantorLayout 實現(xiàn) NestedScrollingParent 接口的每個方法中作出相應回調,下面來簡單走讀下這部分代碼

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

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

   // 參數(shù)child:當前實現(xiàn)`NestedScrollingParent`的ViewParent包含觸發(fā)嵌套滾動的直接子view對象
   // 參數(shù)target:觸發(fā)嵌套滾動的view  (在這里如果不涉及多層嵌套的話,child和target)是相同的
   // 參數(shù)nestedScrollAxes:就是嵌套滾動的滾動方向了.垂直或水平方法
   //返回參數(shù)代表當前ViewParent是否可以觸發(fā)嵌套滾動操作
   //CoordiantorLayout的實現(xiàn)上是交由子View的Behavior來決定,并回調了各個acceptNestedScroll方法,告訴它們處理結果
    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才會觸發(fā)這個方法
    //參數(shù)和onStartNestedScroll方法一樣
    //按照官方文檔的指示,CoordiantorLayout有一個NestedScrollingParentHelper類型的成員變量,并把這個方法交由它處理
    //同樣,這里也是需要CoordiantorLayout遍歷子View,對可以嵌套滾動的子View回調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);
            }
        }
    }

    //嵌套滾動的結束,做一些資源回收操作等...
    //為可以嵌套滾動的子View回調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;
    }
    //進行嵌套滾動
    // 參數(shù)dxConsumed:表示target已經(jīng)消費的x方向的距離
    // 參數(shù)dyConsumed:表示target已經(jīng)消費的x方向的距離
    // 參數(shù)dxUnconsumed:表示x方向剩下的滑動距離
    // 參數(shù)dyUnconsumed:表示y方向剩下的滑動距離
    // 可以嵌套滾動的子View回調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ā)生嵌套滾動之前回調
    // 參數(shù)dx:表示target本次滾動產(chǎn)生的x方向的滾動總距離
    // 參數(shù)dy:表示target本次滾動產(chǎn)生的y方向的滾動總距離
    // 參數(shù)consumed:表示父布局要消費的滾動距離,consumed[0]和consumed[1]分別表示父布局在x和y方向上消費的距離.
    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是否消費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;
    }
    //支持嵌套滾動的方向
    public int getNestedScrollAxes() {
        return mNestedScrollingParentHelper.getNestedScrollAxes();
    }
}

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

CoordiantorLayout , BehaviorNestedScrollingParent 三者關系

NstesdScroll
NstesdScroll

NestedScroll 小結

NestedScroll 的機制的簡版是這樣的,當子 View 在處理滑動事件之前,先告訴自己的父 View 是否需要先處理這次滑動事件,父 View 處理完之后,告訴子 View 它處理了多少滑動距離,剩下的還是交給子 View 自己來處理

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

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

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

Behavior 的解析和實例化

簡單來看看 Behavior 是如何從 xml 中解析的,通過檢測 xxx:behavior 屬性,通過全限定名或者相對路徑的形式指定路徑,最后通過反射來新建實例,默認的構造器是 Behavior(Context context, AttributeSet attrs) ,如果你需要配置額外的參數(shù),可以在外部構造好 Behavior 并通過 setter 的方式注入到 LayoutParams 或者獲取到解析好的
Behavior 進行額外的參數(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); //通過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);
    }
}

兩種關系和兩種形式

View 之間的依賴關系

CoordinatorLayout 的子 View 可以扮演著不同角色,一種是被依賴的,而另外一種則是主動尋找依賴的 View ,被依賴的 View 并不會感知到自己被依賴,被依賴的 View 也有可能是尋找依賴的 View

這種依賴關系的建立由 CoordinatorLayout#LayoutParam 來指定,假設此時有兩個 View:A 和 B,那么有兩種情況會導致依賴關系

  • A 的 anchor 是 B
  • A 的 behavior 對 B 有依賴

LayoutParams 中關于依賴的判斷的依據(jù)的代碼如下

LayoutParams.class

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

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

Behavior.java

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

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

根據(jù)這種依賴關系,CoordinatorLayout 中維護了一個 mDependencySortedChildren 列表,里面含有所有的子 View,按依賴關系排序,被依賴者排在前面,會在每次測量前重新排序,確保處理的順序是 **被依賴的 View 會先被
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 依賴 rhs,lhs>rhs
            return 1;
        } else if (((LayoutParams) rhs.getLayoutParams()).dependsOn(CoordinatorLayout.this, rhs, lhs)) { // rhs 依賴 lhs ,lhs<rhs
            return -1;
        } else {
            return 0;
        }
    }
};

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

private static void selectionSort(final List<View> list, final Comparator<View> comparator) {
    if (list == null || list.size() < 2) { //只有一個的時候當然不需要排序了
        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]);
    }
}

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

依賴的監(jiān)聽

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

CoordinatorLayout本身注冊了兩種監(jiān)聽器,ViewTreeObserver.OnPreDrawListenerOnHierarchyChangeListener,一種是在繪制的之前進行回調,一種是在子View的層級結構發(fā)生變化的時候回調,有這兩種監(jiān)聽就可以在接受到被依賴的View的變化了

監(jiān)聽提供依賴的視圖的位置變化

OnPreDrawListenerCoordinatorLayout繪制之前回調,因為在layout之后,所以可以很容易判斷到某個View的位置是否發(fā)生的改變

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

dispatchOnDependentViewChanged方法,會遍歷根據(jù)依賴關系排序好的子View集合,找到位置改變了的View,并回調依賴這個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)) { //比較前后兩次位置變化,位置沒發(fā)生改變就進入下次循環(huán)得了
            continue;
        }
        recordLastChildRect(child, newRect);
        // 如果改變了,往后面位置中找到依賴當前View的Behavior來進行回調
        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)聽提供依賴的View的添加和移除

HierarchyChangeListener在View的添加和移除都會回調

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

根據(jù)情況回調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是否依賴當前View并回調
            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 的兩種目的

我們可以按照兩種目的來實現(xiàn)自己的 Behavior,當然也可以兩種都實現(xiàn)啦

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

  • 某個 view 監(jiān)聽 CoordinatorLayout 內的 NestedScrollingChild 的接口實現(xiàn)類的滑動狀態(tài)

第一種情況需要重寫 layoutDependsOnonDependentViewChanged 方法

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

對于第一種情況,我們之前分析依賴的監(jiān)聽的時候相關回調細節(jié)已經(jīng)說完了,Behavior 只需要在
onDependentViewChanged 做相應的處理就好

對于第二種情況,我們在 NestedScoll 的那節(jié)也已經(jīng)把相關回調細節(jié)說了

CoordinatorLayout的事件傳遞

CoordinatorLayout并不會直接處理觸摸事件,而是盡可能地先交由子View的Behavior來處理,它的onInterceptTouchEventonTouchEvent兩個方法最終都是調用performIntercept方法,用來分發(fā)不同的事件類型分發(fā)給對應的子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屬性來排序,以下,則是按照添加順序或者自定義的繪制順序來排列

    // 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();
        // 如果有一個behavior對事件進行了攔截,就發(fā)送Cancel事件給后續(xù)的所有Behavior。假設之前還沒有Intercept發(fā)生,那么所有的事件都平等地對所有含有behavior的view進行分發(fā),現(xiàn)在intercept忽然出現(xiàn),那么相應的我們就要對除了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; //記錄當前需要處理事件的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;
}

小結

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

UC 主頁實現(xiàn)分析

先來看看 UC 瀏覽器的主頁的效果圖

UC主頁效果
UC主頁效果

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

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

確定依賴關系

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

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

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

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

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

  • 1.列表頁置于 Header 之下
  • 2.列表頁上移留白問題

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


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;
    }

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

    //返回Header可以收縮的范圍,默認為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;
    }

}

這個基類的代碼還是很好理解的,因為之前就說過了,正常來說被依賴的 View 會優(yōu)先于依賴它的 View 處理,所以需要依賴的 View 可以在 measure/layout 的時候,找到依賴的 View 并獲取到它的測量/布局的信息,這里的處理就是依靠著這種關系來實現(xiàn)的

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


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

    @Override
    public boolean onDependentViewChanged(CoordinatorLayout parent, View child, View dependency) {
        //省略,還未講到
    }
    //通過ID判讀,找到第一個依賴
    @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);
    }
    //依賴的判斷
    private boolean isDependOn(View dependency) {
        return dependency != null && dependency.getId() == R.id.id_uc_news_header_pager;
    }
}

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

列表頁的Behavior最終代碼如下:


//列表頁的Behavior
public class UcNewsContentBehavior extends HeaderScrollingViewBehavior {
    //...省略構造信息
    @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);
    }
    //依賴的判斷
    private boolean isDependOn(View dependency) {
        return dependency != null && dependency.getId() == R.id.id_uc_news_header_pager;
    }
}

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

Header 的滾動事件來源于列表頁中的 NestedScrollingChild,所以 Header 的 Behavior 需要重寫于 NestedScroll
相關的方法,不僅僅需要攔截 Scroll 事件還需要攔截 Fling 事件,通過改變 TranslationY 值來"消費"掉這些事件,另外需要為該 Behavior 定義兩種狀態(tài),打開和關閉,而如果在滑動中途手指離開( ACTION_UP 或者
ACTION_CANCEL ),需要根據(jù)偏移量來判斷進入打開還是關閉狀態(tài),這里我使用 Scroller + Runnalbe 來進行動畫效果,因為直接使用 ViewPropertyAnimator 得到的結果不太理想,具體可以看代碼的注釋,就不細講了

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;

    //...省略構造信息

    private void init() { //構造器中調用
        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) {
        //攔截垂直方向上的滾動事件且當前狀態(tà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; //消費掉其中的4分之1,不至于滑動效果太靈敏
        if (!canScroll(child, halfOfDis)) {
            child.setTranslationY(halfOfDis > 0 ? getHeaderOffsetRange() : 0);
        } else {
            child.setTranslationY(child.getTranslationY() - halfOfDis);
        }
        //只要開始攔截,就需要把所有Scroll事件消費掉
        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);
        }

    }
    //結束動畫的時候調用,并改變狀態(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;
            //這里做了些處理,避免有時候會有1-2Px的誤差結果,導致最終效果不好
            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);
                }
            }
        }
    }
}

實現(xiàn)標題視圖和 Tab 視圖跟隨頭部的實時移動

剩下 Title 和 Tab 的 Behavior ,相對上兩個來說是比較簡單的,都只需要子在 onDependentViewChanged 方法中,根據(jù) Header 的變化而改變 TranslationY 值即可

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

public class UcNewsTitleBehavior extends CoordinatorLayout.Behavior<View> {
    //...構造信息

    @Override
    public boolean onLayoutChild(CoordinatorLayout parent, View child, int layoutDirection) {
        // FIXME: 16/7/27 不知道為啥在XML設置-45dip,解析出來的topMargin少了1個px,所以這里用代碼設置一遍
        ((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); //直接設置終值,避免出現(xiàn)誤差
        } else if (dependency.getTranslationY() == 0) {
            child.setTranslationY(0); //直接設置初始值
        } else {
            //根據(jù)Header的TranslationY值來改變自身的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);
    }
    //標題高度
    private int getTitleHeight() {
        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;
    }
}

Tab初始狀態(tài)需要放置在Header之下,所以還是繼承自HeaderScrollingViewBehavior,因為指定的高度,所以LayoutParams得Mode為EXACTLY,所以在測量的時候不會被特殊處理

public class UcNewsTabBehavior extends HeaderScrollingViewBehavior {

    //..省略構造信息
    @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);  //直接設置終值,避免出現(xiàn)誤差
        } else if (dependency.getTranslationY() == 0) {
            child.setTranslationY(0);//直接設置初始值
        } 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 ,可以上去看看且順便給個 star 吧

寫在最后

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

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

那些有用的參考資料

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯(lián)系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發(fā)布,文章內容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 228,345評論 6 531
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 98,494評論 3 416
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 176,283評論 0 374
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經(jīng)常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,953評論 1 309
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,714評論 6 410
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 55,186評論 1 324
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,255評論 3 441
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,410評論 0 288
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 48,940評論 1 335
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,776評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發(fā)現(xiàn)自己被綠了。 大學時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,976評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,518評論 5 359
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發(fā)生泄漏。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點故事閱讀 44,210評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,642評論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,878評論 1 286
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,654評論 3 391
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,958評論 2 373

推薦閱讀更多精彩內容