前言&效果預覽
最近幾個周末基本在研究 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 效果的,需要關注重寫 onStartNestedScroll
和 onNestedPreScroll
等一系列帶 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
輔助類來幫助處理的大部分邏輯,它們之間關系如下

實現(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
系列方法,但我們這里可以先忽略一下
NestedScrollView
的 NestedScrollingChild
接口實現(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
來處理的,所以這里可以得出一個結論,CoordiantorLayout
是 Behavior
的一個代理類,所以 Behavior
實際上也是一個 NestedScrollingParent
,另外結合 NestedScrollingChild
實現(xiàn)的部分來看,你很容就能搞懂這些方法參數(shù)的實際含義
CoordiantorLayout
, Behavior
和 NestedScrollingParent
三者關系

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 的 LayoutParams
,Behavior
對象定義了特定類型的視圖交互邏輯,譬如 FloatingActionButton
的 Behavior
實現(xiàn)類,只要 FloatingActionButton
是
CoordinatorLayout
的子View,且設置的該 Behavior
(默認已經(jīng)設置了),那么,這個 FAB 就會在 Snackbar
出現(xiàn)的時候上浮,而不至于被遮擋,而這種通過定義 Behavior
的方式就可以控制 View 的某一類的行為,通常會比自定義 View 的方式更解耦更輕便,由此可知,Behavior
是 CoordinatorLayout
的精髓所在
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.OnPreDrawListener
和OnHierarchyChangeListener
,一種是在繪制的之前進行回調,一種是在子View的層級結構發(fā)生變化的時候回調,有這兩種監(jiān)聽就可以在接受到被依賴的View的變化了
監(jiān)聽提供依賴的視圖的位置變化
OnPreDrawListener
在CoordinatorLayout
繪制之前回調,因為在layout
之后,所以可以很容易判斷到某個View的位置是否發(fā)生的改變
class OnPreDrawListener implements ViewTreeObserver.OnPreDrawListener {
@Override
public boolean onPreDraw() {
dispatchOnDependentViewChanged(false);
return true;
}
}
dispatchOnDependentViewChanged
方法,會遍歷根據(jù)依賴關系排序好的子View集合,找到位置改變了的View,并回調依賴這個View的Behavior
的onDependentViewChanged
方法
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)
第一種情況需要重寫 layoutDependsOn
和 onDependentViewChanged
方法
第二種情況需要重寫 onStartNestedScroll
和 onNestedPreScroll
系列方法(上面已經(jīng)提到了哦)
對于第一種情況,我們之前分析依賴的監(jiān)聽的時候相關回調細節(jié)已經(jīng)說完了,Behavior
只需要在
onDependentViewChanged
做相應的處理就好
對于第二種情況,我們在 NestedScoll 的那節(jié)也已經(jīng)把相關回調細節(jié)說了
CoordinatorLayout的事件傳遞
CoordinatorLayout
并不會直接處理觸摸事件,而是盡可能地先交由子View的Behavior
來處理,它的onInterceptTouchEvent
和onTouchEvent
兩個方法最終都是調用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 瀏覽器的主頁的效果圖

可以看到有一共有4種元素的交互,這里分別稱為 Title 元素、Header 元素、Tab 元素和新聞列表元素
在往上拖動列表頁而還沒進入到新聞閱讀狀態(tài)的時候,我們需要一個容器來完全消費掉這個拖動事件,避免列表項向上滾動,同時 Tab 和 Title 則分別從列表頂部和 CoordinatorLayout
頂部出現(xiàn),Header 也有往上偏移一段距離,而到底誰來扮演這個角色呢?我們需要先確定它們之間的依賴關系
確定依賴關系
在編碼之前,首先還需要確定這些元素的依賴關系,看下圖來比較下前后的狀態(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ā)布時間)