前言
在上篇文章自定義View事件之進階篇(三)-CoordinatorLayout與Behavior中,我們介紹了CoordainatorLayout下的Behavior機制,為了幫助大家更好的理解並運用Behavior,現在我們通過一個Demo,來鞏固我們之前學習的知識點。
該部落格中涉及到的示例,在NestedScrollingDemo專案中都有實現,大家可以按需自取。
效果展示
先看一下我們需要實現的效果吧,如下圖所示:
友情提示:Demo中涉及到的控制元件為CoordinatorLayout、TextView、RecyclerView。文章都會圍繞這三個控制元件進行講解。
從Demo效果來看,這是非常簡單的巢狀滑動。如果採用我們之前所學的NestedScrollingParent2
與NestedScrollingChild2
實現介面的方式。我們能非常迅速的解決問題。但是如果採用自定義Behavior的話,那麼就稍微有點難度了。不過不用擔心,只要一步一步慢慢分析,就總能解決問題的。
RecyclerView佈局與測量的分析
在Demo中,RecyclerView與TextView開始的佈局關係如下圖所示:
根據在文章自定義View事件之進階篇(三)-CoordinatorLayout與Behavior中我們所學的知識點,我們知道CoordinatorLayout對子控制元件的佈局是類似於FrameLayout的,所以為了保證RecyclerView在TextView的下方顯示,我們需要建立屬於RecyclerView的Behavior,並在該Behavior的onLayoutChild
方法中處理RecyclerView與TextView的位置關係。
除了解決RecyclerView的位置關係以外,在該Demo中,我們還可以看出,RecyclerView與TextView之間有著一個聯動的關係(這裡指的是RecyclerView與TextView之間的位置關係,而不是RecyclerView中的內容)。隨著TextView逐漸上移的時候,下方的RecyclerView也跟著往上移動。那麼我們可以確定的是RecyclerView必然是依賴TextView的。也就是說我們需要重寫Behavior的layoutDependsOn
與onDependentViewChanged
方法。
確定一個控制元件(childView1)依賴另外一個控制元件(childView2)的時候,是通過
layoutDependsOn(CoordinatorLayout parent, V child, View dependency)
這個方法。其中child是依賴物件(childView1),而dependency是被依賴物件(childView2),該方法的返回值是判斷是否依賴對應view。如果返回true。那麼表示依賴。反之不依賴。一般情況下,在我們自定義Behavior時,我們需要重寫該方法。當layoutDependsOn
方法返回true時,後面的onDependentViewChanged
與onDependentViewRemoved
方法才會呼叫。
除了考慮以上因數以外,我們還需要考慮RecyclerView的高度。觀察Demo,我們可以看出,RecylerView在移動前後,始終都是填充整個螢幕的。為了保證RecylerView在移動過程中,螢幕中不會出現空白(如下圖所示)。我們也需要在CoordinatorLayout測量該控制元件的高度之前,讓控制元件自主的去測量高度。也就是重寫RecylerView對應Behavior中的onMeasureChild
方法。
RecyclerView的Behavior程式碼實現
分析了RecyclerView的Behavior需要重寫的內容後,我們來看看具體的Behavior實現類HeaderScrollingViewBehavior
。為了幫助大家理解,我將RecyclerView的Behavior拆成了幾個部分,程式碼如下所示:
檢視該Behavior完整程式碼,請點選--->HeaderScrollingViewBehavior
public class HeaderScrollingViewBehavior extends CoordinatorLayout.Behavior<View> {
public HeaderScrollingViewBehavior() {}
public HeaderScrollingViewBehavior(Context context, AttributeSet attrs) {
super(context, attrs);
}
/**
* 依賴TextView
*/
@Override
public boolean layoutDependsOn(CoordinatorLayout parent, View child, View dependency) {
return dependency instanceof TextView;
}
//省略部分程式碼…
}
複製程式碼
注意:在xml引用自定義Behavior時,一定要宣告建構函式。不然在程式的編譯過程中,會提示知道不到相應的Behavior。
layoutDependsOn
方法的邏輯非常簡單。就是判斷依賴的物件是否是TextView。我們繼續檢視該類中的onMeasureChild
方法。程式碼如下所示:
@Override
public boolean onMeasureChild(CoordinatorLayout parent, View child, int parentWidthMeasureSpec, int widthUsed, int parentHeightMeasureSpec, int heightUsed) {
//獲取當前滾動控制元件的測量模式
final int childLpHeight = child.getLayoutParams().height;
//只有當前滾動控制元件為match_parent/wrap_content時才重新測量其高度,因為固定高度不會出現底部空白的情況
if (childLpHeight == ViewGroup.LayoutParams.MATCH_PARENT
|| childLpHeight == ViewGroup.LayoutParams.WRAP_CONTENT) {
//獲取當前child依賴的物件集合
final List<View> dependencies = parent.getDependencies(child);
final View header = findFirstDependency(dependencies);
if (header != null) {
if (ViewCompat.getFitsSystemWindows(header)
&& !ViewCompat.getFitsSystemWindows(child)) {
// If the header is fitting system windows then we need to also,
// otherwise we'll get CoL's compatible measuring
ViewCompat.setFitsSystemWindows(child, true);
if (ViewCompat.getFitsSystemWindows(child)) {
// If the set succeeded, trigger a new layout and return true
child.requestLayout();
return true;
}
}
//獲取當前父控制元件中可用的距離,
int availableHeight = View.MeasureSpec.getSize(parentHeightMeasureSpec);
if (availableHeight == 0) {
// If the measure spec doesn't specify a size, use the current height
availableHeight = parent.getHeight();
}
//計算當前滾動控制元件的高度。
final int height = availableHeight - header.getMeasuredHeight() + getScrollRange(header);
final int heightMeasureSpec = View.MeasureSpec.makeMeasureSpec(height,
childLpHeight == ViewGroup.LayoutParams.MATCH_PARENT
? View.MeasureSpec.EXACTLY
: View.MeasureSpec.AT_MOST);
//測量當前滾動的View的正確高度
parent.onMeasureChild(child, parentWidthMeasureSpec,
widthUsed, heightMeasureSpec, heightUsed);
return true;
}
}
return false;
}
複製程式碼
測量邏輯的基本步驟:
- 獲取當前控制元件的測量模式,判斷是否採用的
match_parent
或者wrap_content
。(對於精準模式,我們不用考慮,控制元件是否填充螢幕) - 當滿足條件1,獲取當前RecyclerView所依賴的header(TextView),根據當前TextView的高度,計算出控制元件A的實際高度(RecyclerView的父控制元件可用的高度
-
TextView的高度+
TextView的滾動範圍)
在onMeasureChild
方法中,我省略了部分方法的介紹,如findFirstDependency
、getScrollRange
方法。這些方法在NestedScrollingDemo專案中都有實現。大家可以按需自取。
我們繼續檢視HeaderScrollingViewBehavior
類中的onLayoutChild
方法,程式碼如下所示:
@Override
public boolean onLayoutChild(CoordinatorLayout parent, View child, int layoutDirection) {
final List<View> dependencies = parent.getDependencies(child);
final View header = findFirstDependency(dependencies);
if (header != null) {
final CoordinatorLayout.LayoutParams lp =
(CoordinatorLayout.LayoutParams) child.getLayoutParams();
final Rect available = mTempRect1;
//得到依賴控制元件下方的座標。
available.set(parent.getPaddingLeft() + lp.leftMargin,
header.getBottom() + lp.topMargin,
parent.getWidth() - parent.getPaddingRight() - lp.rightMargin,
parent.getHeight() + header.getBottom()
- parent.getPaddingBottom() - lp.bottomMargin);
//拿到上面計算的座標後,根據當前控制元件在父控制元件中設定的gravity,重新計算並得到控制元件在父控制元件中的座標
final Rect out = mTempRect2;
GravityCompat.apply(resolveGravity(lp.gravity), child.getMeasuredWidth(),
child.getMeasuredHeight(), available, out, layoutDirection);
//拿到座標後重新佈局
child.layout(out.left, out.top, out.right, out.bottom);
} else {
//如果沒有依賴,則呼叫父控制元件來處理佈局
parent.onLayoutChild(child, layoutDirection);
}
return true;
}
複製程式碼
onLayoutChild
方法邏輯也不算複雜,根據當前所依賴的header(TextView)的位置,將RecyclerView設定在TextView下方。我們繼續檢視RecyclerView與TextView的聯動處理。也就是onDependentViewChanged
方法。程式碼如下所示:
@Override
public boolean onDependentViewChanged(CoordinatorLayout parent, View child, View dependency) {
final CoordinatorLayout.Behavior behavior =
((CoordinatorLayout.LayoutParams) dependency.getLayoutParams()).getBehavior();
if (behavior instanceof NestedHeaderBehavior) {
ViewCompat.offsetTopAndBottom(child, dependency.getBottom() - child.getTop() + ((NestedHeaderBehavior) behavior).getOffset());
}
//如果當前的控制元件的位置發生了改變,該返回值一定要返回為true
return true;
}
複製程式碼
在該方法中,我們需要通過TextView的Behavior(NestedHeaderBehavior
),並獲得TextView的實際偏移量(上述程式碼中的getOffset()
)。通過該偏移量我們可以重新設定RecyclerView的位置。當然,改變控制元件位置的方式有很多種,我們可以使用setTransationY
或View.offsetTopAndBottom
及其他方式,大家可以採用自己喜歡的方式。因為涉及到TextView中Behavior的偏移量。那下面我們就來看看TextView對應Behavior的分析與實現吧。
TextView巢狀滑動的分析
在整個Demo中,TextView的巢狀滑動效果並不複雜。這裡我們就從向上與向下兩個方向來介紹。
- 向上滑動: 只有當TextView滑動至螢幕外時,RecyclerView才能處理內部內容的滾動。
- 向下滑動: 當TextView已經被劃出螢幕且RecylerView中的內容不能繼續向下滑動時,那麼就將TextView滑動至顯示。否則RecyclerView單獨處理內部內容的滾動。
TextView的Behavior程式碼實現
在講解TextView的Behavior的程式碼實現之前,我們需要回顧一下在CooordinatoLayout下巢狀方法的傳遞過程,如下圖所示:
通過回顧流程,在結合本文例子中展示的效果,我們需要重寫Behavior中的onStartNestedScroll
與onNestedPreScroll
和onNestedScroll
三個方法。來看TextView的NestedHeaderBehavior
實現。程式碼如下所示:
檢視該Behavior完整程式碼,請點選--->NestedHeaderBehavior
public class NestedHeaderBehavior extends CoordinatorLayout.Behavior<View> {
private WeakReference<View> mNestedScrollingChildRef;
private int mOffset;//記錄當前佈局的偏移量
public NestedHeaderBehavior(Context context, AttributeSet attrs) {
super(context, attrs);
}
@Override
public boolean onLayoutChild(CoordinatorLayout parent, View child, int layoutDirection) {
mNestedScrollingChildRef = new WeakReference<>(findScrollingChild(parent));
return super.onLayoutChild(parent, child, layoutDirection);
}
//省略部分程式碼…
}
複製程式碼
TextView中NestedHeaderBehavior類的宣告與RecyclerView中的Behavior基本一樣。因為我們需要將偏移量傳遞給RecyclerView,所以在NestedHeaderBehavior
的onLayoutChild方法中,我們去建立了關於RecyclerView的弱引用,並設定了mOffset
變數來記錄TextViwe每次滑動的偏移量。如何獲取RecyclerView,可以檢視專案中原始碼的實現。接下來,我們繼續檢視相關巢狀方法實現。
@Override
public boolean onStartNestedScroll(@NonNull CoordinatorLayout coordinatorLayout, @NonNull View child, @NonNull View directTargetChild, @NonNull View target, int axes, int type) {
//只要豎直方向上就攔截
return (axes & ViewCompat.SCROLL_AXIS_VERTICAL) != 0;
}
複製程式碼
在onStartNestedScroll
方法中,我們設定了當前控制元件,只能攔截豎直方向上的巢狀滑動事件。繼續檢視onNestedPreScroll
方法。程式碼如下所示:
@Override
public void onNestedPreScroll(@NonNull CoordinatorLayout coordinatorLayout, @NonNull View child, @NonNull View target, int dx, int dy, @NonNull int[] consumed, int type) {
View scrollingChild = mNestedScrollingChildRef.get();
if (target != scrollingChild) {
return;
}
int currentTop = child.getTop();
int newTop = currentTop - dy;
if (dy > 0) {//向上滑動
//處理在範圍內的滾動與fling
if (newTop >= -child.getHeight()) {
Log.i(TAG, "onNestedPreScroll:向上移動" + "currentTop--->" + currentTop + " newTop--->" + newTop);
consumed[1] = dy;
mOffset = -dy;
ViewCompat.offsetTopAndBottom(child, -dy);
coordinatorLayout.dispatchDependentViewsChanged(child);
} else { //當超過後,單獨處理
consumed[1] = child.getHeight() + currentTop;
mOffset = -consumed[1];
ViewCompat.offsetTopAndBottom(child, -consumed[1]);
coordinatorLayout.dispatchDependentViewsChanged(child);
}
}
if (dy < 0) {//向下滑動
if (newTop <= 0 && !target.canScrollVertically(-1)) {
Log.i(TAG, "onNestedPreScroll:向下移動" + "currentTop--->" + currentTop + " newTop--->" + newTop);
consumed[1] = dy;
mOffset = -dy;
ViewCompat.offsetTopAndBottom(child, -dy);
coordinatorLayout.dispatchDependentViewsChanged(child);
}
}
}
複製程式碼
onNestedPreScroll
方法中的邏輯較為複雜。不急我們慢慢分析:
- 首先我們得到當前TextView的Top高度(
currentTop
)。然後根據當前偏移距離dy
,計算出TextView新的Top高度(newTop
)。 - 如果
dy>0
,也就是向上滑動。我們判斷偏移後的Top(newTop
)高度是否大於負
的TextView的測量的高度。
因為是向上滑動,當TextView移出螢幕後,通過呼叫getTop方法獲取的高度肯定為負數。這裡判斷是否大於等於
-child.getHeight
,表示的是當前TextView沒有超過它的滾動範圍(-child.getHeight到0)。
- 如果
newTop >= -child.getHeight()
,則TextView消耗掉dy
,通過ViewCompat.offsetTopAndBottom(child, -dy)
來移動當前TextView,接著記錄TextView位置的偏移量(mOffest
),最後通過呼叫CoordinatorLayout下的dispatchDependentViewsChanged
方法,通知控制元件RecyclerView所依賴的TextView發生了改變。那麼RecyclerView收到通知後,就可以拿著這個偏移量和TextView一起聯動了。 - 如果
newTop< - child.getHeight()
,表示在當前偏移距離dy
下,如果TextView會超過它的滾動範圍。那麼我們就不能使用當前dy
來移動TextView。我們只能滾動剩下的範圍,也就是child.getHeight()+
currentTop,(這裡使用加號,是因為滾動範圍為-child.getHeight
到0
)。
- 如果
dy<0
,表示向下滑動,只有在target(RecyclerView)不能向下滑動且TextView已經部分移出螢幕時,我們的TextView才能向下滑動。這裡的處理方式基本和上滑一樣,這裡就不再進行介紹了。我們繼續檢視最後的方法onNestedScroll
方法。
@Override
public void onNestedScroll(@NonNull CoordinatorLayout coordinatorLayout, @NonNull View child, @NonNull View target, int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed, int type) {
if (dyUnconsumed < 0) {//表示已經向下滑動到頭。
int currentTop = child.getTop();
int newTop = currentTop - dyUnconsumed;
if (newTop <= 0) {//如果當前的值在滾動範圍之內。
Log.i(TAG, "onNestedScroll: " + "dyUnconsumed--> " + dyUnconsumed + " currentTop--->" + currentTop + " newTop--->" + newTop);
ViewCompat.offsetTopAndBottom(child, -dyUnconsumed);
mOffset = -dyUnconsumed;
} else {//如果當前的值大於最大的滾動範圍(0),那麼就直接滾動到-currentTop就行了
ViewCompat.offsetTopAndBottom(child, -currentTop);
mOffset = -currentTop;
}
coordinatorLayout.dispatchDependentViewsChanged(child);
}
}
複製程式碼
onNestedScroll
方法中,我們需要處理RecyclerView向下方向上未消耗的距離(dyUnconsumed
)。同樣根據當前偏移記錄計算出TextVie新的Top高度,計算出是否超出其滾功範圍範圍。如果沒有超過,則TextView向下偏移距離為-dyUnconsumed
,同時記錄偏移量(mOffset=-dyUnconsumed
),最後通知RecyclerView,TextView的位置發生了改變。反之,當前TextView的top的值是多少,那麼TextView就向下偏移多少。
最後
在該文章中,我著重講解了相應Behavior中比較重要的一些方法。一些不是那麼重要的輔助方法,我並沒有做過多的介紹。建議大家配合NestedScrollingDemo專案中的原始碼理解該篇文章,我相信肯定是事半功倍的。
最最後
關於巢狀滑動、CoordinatorLayout、Behavior的知識點基本介紹完畢了。我相信大家以後再遇見一些巢狀滑動的問題。都能夠輕鬆的解決了。可能很多小夥伴會好奇,為什麼沒有接著講AppBarLayout與CollapsingTollbarLayout的原理及使用。其實原因非常簡單,因為上述的兩個控制元件的實現原理,其實是依託於CoordinatorLayout與自定義Behavior罷了。授人以魚,不如授人以漁。AppBarLayout與CollapsingTollbarLayout的使用及原理。就算給大家留的課後思考題吧。謝謝大家對這系列的關注。Thanks。