ConsecutiveScrollerLayout是我在GitHub開源的一個Android自定義滑動佈局,它可讓多個滑動佈局和普通控件在界面上像一個總體同樣連續順暢地滑動。java
試想咱們有這樣一個需求,在一個界面上有輪播圖、像九宮格同樣的分類佈局、幾個樣式不同的列表,中間還夾雜着各類廣告圖和展現各種活動的佈局,這樣的設計在大型的app首頁上很是常見。又好比像諮詢類的文章詳情頁或者電商類的商品詳情頁這種一個WebView加上原生的評論列表、推薦列表和廣告位。這種複雜的佈局實現起來每每比較困難,並且對於頁面的滑動流暢性和佈局的顯示效率要求較高。在之前我遇到這種複雜的佈局,會使用我在Github開源的項目GroupedRecyclerViewAdapter 實現。當初設計GroupedRecyclerViewAdapter,是爲了能讓RecyclerView方便地實現二級列表、分組列表和在一個RecyclerView上顯示不一樣的列表。因爲GroupedRecyclerViewAdapter支持設置不一樣item類型的頭部、尾部和子項,全部它能在一個RecyclerView上顯示多種不一樣的佈局和列表,也符合實現複雜佈局的需求。可是因爲GroupedRecyclerViewAdapter並不是爲這種複雜佈局設計的,用它來實現這種佈局,須要使用者在GroupedRecyclerViewAdapter的子類上管理好頁面的數據和各類類型佈局的顯示邏輯,顯得臃腫又麻煩。若是不把它整合在一個RecyclerView上,而是使用佈局的嵌套實現,不只嚴重影響佈局的性能,並且解決滑動衝突也是個使人頭疼的問題。儘管Google爲了更好地解決滑動佈局間的滑動衝突問題,在Android 5.0的時候推出了NestedScrolling機制,不過要本身來處理各類滑動問題,依然不是一件容易的事情。git
不管多麼複雜的頁面,它都是由一個個小控件組成的。若是能有一個佈局容器幫咱們處理好佈局內全部的子View的滑動問題,使得不管是普通控件仍是滑動佈局,在這個容器裏都能像一個總體同樣滑動,滑動它就好像是滑動一個普通的ScrollView同樣。那麼咱們是否就能夠不用再關心佈局的滑動衝突和滑動性能問題。不管多麼複雜的佈局,咱們都只須要考慮佈局的各個小部分該用什麼控件就用什麼控件,任何複雜的佈局都將再也不復雜。ConsecutiveScrollerLayout正是基於這樣的需求而設計的。github
在構思ConsecutiveScrollerLayout時,我是考慮使用NestedScrolling機制實現的,可是後來我放棄了這種方案,主要緣由有二:app
一、NestedScrolling機制主要是協調父佈局和子佈局的滑動衝突,分發滑動事件,至於佈局的滑動是由它們本身各自完成的。這不符合我但願把ConsecutiveScrollerLayout的全部子View看成一個滑動總體的構思,我但願把子View的內容視做是ConsecutiveScrollerLayout內容的一部分,不管是ConsecutiveScrollerLayout自身仍是它的子View,都由ConsecutiveScrollerLayout來統一處理滑動事件。ide
二、NestedScrolling機制要求父佈局實現NestedScrollingParent接口,全部可滑動的子View實現NestedScrollingChild接口。而我但願ConsecutiveScrollerLayout在使用上儘量的沒有限制,任何View放進它均可以很好的工做,並且子View無需關心它是怎麼滑動的。佈局
否決了NestedScrolling機制後,我嘗試從View的內容滑動的相關方法來尋找突破點。我發現Android幾乎全部的View都是經過scrollBy() -> scrollTo()方法滑動View的內容,並且大部分的滑動佈局也是直接或者間接調用這個方法來實現滑動的。因此這兩個方法是處理佈局滑動的入口,經過重寫這兩個方法能夠從新定義佈局的滑動邏輯。post
具體的思路是經過攔截可滑動的子view的滑動事件,使它沒法本身滑動,而把事件統一交由ConsecutiveScrollerLayout處理,ConsecutiveScrollerLayout重寫scrollBy()、scrollTo()方法,在scrollTo()方法中經過計算分發滑動的偏移量,決定是由自身仍是具體的子View消費滑動的距離,調用自身的super.scrollTo()和子View的scrollBy()來滑動自身和子View的內容。性能
說了這麼多,下面讓咱們經過代碼,分析一下ConsecutiveScrollerLayout是如何實現的。下面給出的代碼是源碼的一些主要片斷,刪除了一些與設計思路和主要流程無關的處理細節,便於你們更好的理解它的設計和實現原理。spa
在開始前,先讓你們看一下ConsecutiveScrollerLayout實現的效果。設計
ConsecutiveScrollerLayout繼承自ViewGroup,一個自定義佈局老是免不了重寫onMeasure、onLayout來測量和定位子View。
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
int count = getChildCount();
for (int i = 0; i < count; i++) {
View child = getChildAt(i);
measureChild(child, widthMeasureSpec, heightMeasureSpec);
}
}
@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
mScrollRange = 0;
int childTop = t + getPaddingTop();
int left = l + getPaddingLeft();
List<View> children = getNonGoneChildren();
int count = children.size();
for (int i = 0; i < count; i++) {
View child = children.get(i);
int bottom = childTop + child.getMeasuredHeight();
child.layout(left, childTop, left + child.getMeasuredWidth(), bottom);
childTop = bottom;
// 聯動容器可滾動最大距離
mScrollRange += child.getHeight();
}
// 聯動容器可滾動range
mScrollRange -= getMeasuredHeight() - getPaddingTop() - getPaddingBottom();
}
/** * 返回全部的非GONE子View */
private List<View> getNonGoneChildren() {
List<View> children = new ArrayList<>();
int count = getChildCount();
for (int i = 0; i < count; i++) {
View child = getChildAt(i);
if (child.getVisibility() != GONE) {
children.add(child);
}
}
return children;
}
複製代碼
onMeasured的邏輯很簡單,遍歷測量子vew便可。onLayout是把子view從上到下排列,就像一個垂直的LinearLayout同樣。getNonGoneChildren()方法過濾掉隱藏的子view,隱藏的子view不參與佈局。上面的mScrollRange變量是佈局自身可滑動的範圍,它等於全部子view的高度減去佈局自身的內容顯示高度。在後面,它將用於計算佈局的滑動偏移和邊距限制。
前面說過ConsecutiveScrollerLayout會攔截它的可滑動的子view的滑動事件,由本身來處理全部的滑動。下面是它攔截事件的實現。
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
if (ev.getAction() == MotionEvent.ACTION_MOVE) {
// 須要攔截事件
if (isIntercept(ev)) {
return true;
}
}
return super.onInterceptTouchEvent(ev);
}
複製代碼
若是是滑動事件(ACTION_MOVE),判斷是否須要攔截事件,攔截則直接返回true,讓事件交由ConsecutiveScrollerLayout的onTouchEvent方法處理。判斷是否須要攔截的關鍵是isIntercept(ev)方法。
/** * 判斷是否須要攔截事件 */
private boolean isIntercept(MotionEvent ev) {
// 根據觸摸點獲取當前觸摸的子view
View target = getTouchTarget((int) ev.getRawX(), (int) ev.getRawY());
if (target != null) {
// 判斷子view是否容許父佈局攔截事件
ViewGroup.LayoutParams lp = target.getLayoutParams();
if (lp instanceof LayoutParams) {
if (!((LayoutParams) lp).isConsecutive) {
return false;
}
}
// 判斷子view是否能夠垂直滑動
if (ScrollUtils.canScrollVertically(target)) {
return true;
}
}
return false;
}
public class ScrollUtils {
static boolean canScrollVertically(View view) {
return canScrollVertically(view, 1) || canScrollVertically(view, -1);
}
static boolean canScrollVertically(View view, int direction) {
return view.canScrollVertically(direction);
}
}
複製代碼
判斷是否須要攔截事件,主要是經過判斷觸摸的子view是否能夠垂直滑動,若是能夠垂直滑動,就攔截事件,讓事件由ConsecutiveScrollerLayout本身處理。若是不是,就不攔截,通常不能滑動的view不會消費滑動事件,因此事件最終會由ConsecutiveScrollerLayout所消費。之因此不直接攔截,是爲了能讓子view儘量的得到事件處理和分發給下面的view的機會。
這裏有一個isConsecutive的LayoutParams屬性,它是ConsecutiveScrollerLayout.LayoutParams的自定義屬性,用於表示一個子view是否容許ConsecutiveScrollerLayout攔截它的滑動事件,默認爲true。若是把它設置爲false,父佈局將不會攔截這個子view的事件,而是徹底交由子view處理。這使得子view有了本身處理滑動事件的機會和分發事件的主動權。這對於實現一些須要實現局部區域內滑動的特殊需求十分有用。我在GitHub中提供的demo和使用介紹中對isConsecutive有詳細的說明,在這就不作過多介紹了。
把事件攔截後,就要在onTouchEvent方法中處理滑動事件。
@Override
public boolean onTouchEvent(MotionEvent ev) {
switch (ev.getAction()) {
case MotionEvent.ACTION_DOWN:
// 記錄觸摸點
mTouchY = (int) ev.getY();
// 追蹤滑動速度
initOrResetVelocityTracker();
mVelocityTracker.addMovement(ev);
break;
case MotionEvent.ACTION_MOVE:
if (mTouchY == 0) {
mTouchY = (int) ev.getY();
return true;
}
int y = (int) ev.getY();
int dy = y - mTouchY;
mTouchY = y;
// 滑動佈局
scrollBy(0, -dy);
// 追蹤滑動速度
initVelocityTrackerIfNotExists();
mVelocityTracker.addMovement(ev);
break;
case MotionEvent.ACTION_CANCEL:
case MotionEvent.ACTION_UP:
mTouchY = 0;
if (mVelocityTracker != null) {
// 處理慣性滑動
mVelocityTracker.computeCurrentVelocity(1000, mMaximumVelocity);
int yVelocity = (int) mVelocityTracker.getYVelocity();
recycleVelocityTracker();
fling(-yVelocity);
}
break;
}
return true;
}
// 慣性滑動
private void fling(int velocityY) {
if (Math.abs(velocityY) > mMinimumVelocity) {
mScroller.fling(0, mOwnScrollY,
1, velocityY,
0, 0,
Integer.MIN_VALUE, Integer.MAX_VALUE);
invalidate();
}
}
@Override
public void computeScroll() {
if (mScroller.computeScrollOffset()) {
int curY = mScroller.getCurrY();
// 滑動佈局
dispatchScroll(curY);
invalidate();
}
}
複製代碼
onTouchEvent方法的邏輯很是簡單,就是根據手指的滑動距離經過view的scrollBy方法滑動佈局內容,同時經過VelocityTracker追蹤手指的滑動速度,使用Scroller配合computeScroll()方法實現慣性滑動。
在處理慣性滑動是時候,咱們調用了dispatchScroll()方法,這個方法是整個ConsecutiveScrollerLayout的核心,它決定了應該由誰來消費此次滑動,應該滑動那個佈局。其實ConsecutiveScrollerLayout的scrollBy()和scrollTo()方法最終都是調用它來處理滑動的分發的。
@Override
public void scrollBy(int x, int y) {
scrollTo(0, mOwnScrollY + y);
}
@Override
public void scrollTo(int x, int y) {
//全部的scroll操做都交由dispatchScroll()來分發處理
dispatchScroll(y);
}
private void dispatchScroll(int y) {
int offset = y - mOwnScrollY;
if (mOwnScrollY < y) {
// 向上滑動
scrollUp(offset);
} else if (mOwnScrollY > y) {
// 向下滑動
scrollDown(offset);
}
}
複製代碼
這裏有個mOwnScrollY屬性,是用於記錄ConsecutiveScrollerLayout的總體滑動距離的,至關於View的mScrollY屬性。
dispatchScroll()方法把滑動分紅向上和向下兩部分處理。讓咱們先看向上滑動部分的處理。
private void scrollUp(int offset) {
int scrollOffset = 0; // 消費的滑動記錄
int remainder = offset; // 未消費的滑動距離
do {
scrollOffset = 0;
// 是否滑動到底部
if (!isScrollBottom()) {
// 找到當前顯示的第一個View
View firstVisibleView = findFirstVisibleView();
if (firstVisibleView != null) {
awakenScrollBars();
// 獲取View滑動到自身底部的偏移量
int bottomOffset = ScrollUtils.getScrollBottomOffset(firstVisibleView);
if (bottomOffset > 0) {
// 若是bottomOffset大於0,表示這個view尚未滑動到自身的底部,那麼就由這個view來消費此次的滑動距離。
int childOldScrollY = ScrollUtils.computeVerticalScrollOffset(firstVisibleView);
// 計算須要滑動的距離
scrollOffset = Math.min(remainder, bottomOffset);
// 滑動子view
scrollChild(firstVisibleView, scrollOffset);
// 計算真正的滑動距離
scrollOffset = ScrollUtils.computeVerticalScrollOffset(firstVisibleView) - childOldScrollY;
} else {
// 若是子view已經滑動到自身的底部,就由父佈局消費滑動距離,直到把這個子view滑出屏幕
int selfOldScrollY = getScrollY();
// 計算須要滑動的距離
scrollOffset = Math.min(remainder,
firstVisibleView.getBottom() - getPaddingTop() - getScrollY());
// 滑動父佈局
scrollSelf(getScrollY() + scrollOffset);
// 計算真正的滑動距離
scrollOffset = getScrollY() - selfOldScrollY;
}
// 計算消費的滑動距離,若是尚未消費完,就繼續循環消費。
mOwnScrollY += scrollOffset;
remainder = remainder - scrollOffset;
}
}
} while (scrollOffset > 0 && remainder > 0);
}
public boolean isScrollBottom() {
List<View> children = getNonGoneChildren();
if (children.size() > 0) {
View child = children.get(children.size() - 1);
return getScrollY() >= mScrollRange && !child.canScrollVertically(1);
}
return true;
}
public View findFirstVisibleView() {
int offset = getScrollY() + getPaddingTop();
List<View> children = getNonGoneChildren();
int count = children.size();
for (int i = 0; i < count; i++) {
View child = children.get(i);
if (child.getTop() <= offset && child.getBottom() > offset) {
return child;
}
}
return null;
}
private void scrollSelf(int y) {
int scrollY = y;
// 邊界檢測
if (scrollY < 0) {
scrollY = 0;
} else if (scrollY > mScrollRange) {
scrollY = mScrollRange;
}
super.scrollTo(0, scrollY);
}
private void scrollChild(View child, int y) {
child.scrollBy(0, y);
}
複製代碼
向上滑動的處理邏輯是,先找到當前顯示的第一個子view,判斷它的內容是否已經滑動到它的底部,若是沒有,則由它來消費滑動距離。若是已經滑動到它的底部,則由ConsecutiveScrollerLayout來消費滑動距離,直到把這個子view滑出屏幕。這樣下一次獲取顯示的第一個view就是它的下一個view了,重複以上的操做,直到把ConsecutiveScrollerLayout和全部的子view都滑動到底部,這樣就總體都滑動到底部了。
這裏使用了一個while循環操做,這樣作是由於一次滑動距離,可能會由多個對象來消費,好比須要滑動50px的距離,可是當前顯示的第一個子view還須要10px滑動到本身的底部,那麼這個子view會消費10px的距離,剩下40px的距離就要進行下一次的分發,找到須要消費它的對象,以此類推。
向下滑動的處理跟向上滑動是一摸同樣的,只是判斷的對象和滑動的方向不一樣。
private void scrollDown(int offset) {
int scrollOffset = 0; // 消費的滑動記錄
int remainder = offset; // 未消費的滑動距離
do {
scrollOffset = 0;
// 是否滑動到頂部
if (!isScrollTop()) {
// 找到當前顯示的最後一個View
View lastVisibleView = findLastVisibleView();
if (lastVisibleView != null) {
awakenScrollBars();
// 獲取View滑動到自身頂部的偏移量
int childScrollOffset = ScrollUtils.getScrollTopOffset(lastVisibleView);
if (childScrollOffset < 0) {
// 若是childScrollOffset大於0,表示這個view尚未滑動到自身的頂部,那麼就由這個view來消費此次的滑動距離。
int childOldScrollY = ScrollUtils.computeVerticalScrollOffset(lastVisibleView);
// 計算須要滑動的距離
scrollOffset = Math.max(remainder, childScrollOffset);
// 滑動子view
scrollChild(lastVisibleView, scrollOffset);
// 計算真正的滑動距離
scrollOffset = ScrollUtils.computeVerticalScrollOffset(lastVisibleView) - childOldScrollY;
} else {
// 若是子view已經滑動到自身的頂部,就由父佈局消費滑動距離,直到把這個子view徹底滑動進屏幕
int scrollY = getScrollY();
// 計算須要滑動的距離
scrollOffset = Math.max(remainder,
lastVisibleView.getTop() + getPaddingBottom() - scrollY - getHeight());
// 滑動父佈局
scrollSelf(scrollY + scrollOffset);
// 計算真正的滑動距離
scrollOffset = getScrollY() - scrollY;
}
// 計算消費的滑動距離,若是尚未消費完,就繼續循環消費。
mOwnScrollY += scrollOffset;
remainder = remainder - scrollOffset;
}
}
} while (scrollOffset < 0 && remainder < 0);
}
public boolean isScrollTop() {
List<View> children = getNonGoneChildren();
if (children.size() > 0) {
View child = children.get(0);
return getScrollY() <= 0 && !child.canScrollVertically(-1);
}
return true;
}
public View findLastVisibleView() {
int offset = getHeight() - getPaddingBottom() + getScrollY();
List<View> children = getNonGoneChildren();
int count = children.size();
for (int i = 0; i < count; i++) {
View child = children.get(i);
if (child.getTop() < offset && child.getBottom() >= offset) {
return child;
}
}
return null;
}
複製代碼
到這裏,關於ConsecutiveScrollerLayout到實現思路和核心代碼就分析完了。因爲篇幅問題,我把對佈局吸頂功能的分析寫了另外一篇文章:Android滑動佈局ConsecutiveScrollerLayout實現佈局吸頂功能
另外我還寫了一篇文章是專門介紹ConsecutiveScrollerLayout的使用的,有興趣的朋友能夠看一下:Android持續滑動佈局ConsecutiveScrollerLayout的使用
下面給出ConsecutiveScrollerLayout到項目地址,若是你喜歡個人做品,或者這個佈局對你有所幫助,請給我點個star唄!