ConsecutiveScrollerLayout是我在GitHub開源的一個(gè)Android自定義滑動(dòng)布局啥供,它可以讓多個(gè)滑動(dòng)布局和普通控件在界面上像一個(gè)整體一樣連續(xù)順暢地滑動(dòng)俘侠。
試想我們有這樣一個(gè)需求澈侠,在一個(gè)界面上有輪播圖窜锯、像九宮格一樣的分類布局盗痒、幾個(gè)樣式不一樣的列表腔稀,中間還夾雜著各種廣告圖和展示各類活動(dòng)的布局隧哮,這樣的設(shè)計(jì)在大型的app首頁上非常常見。又比如像咨詢類的文章詳情頁或者電商類的商品詳情頁這種一個(gè)WebView加上原生的評論列表、推薦列表和廣告位稽犁。這種復(fù)雜的布局實(shí)現(xiàn)起來往往比較困難焰望,而且對于頁面的滑動(dòng)流暢性和布局的顯示效率要求較高。在以前我遇到這種復(fù)雜的布局已亥,會(huì)使用我在Github開源的項(xiàng)目GroupedRecyclerViewAdapter 實(shí)現(xiàn)。當(dāng)初設(shè)計(jì)GroupedRecyclerViewAdapter虑椎,是為了能讓RecyclerView方便地實(shí)現(xiàn)二級列表震鹉、分組列表和在一個(gè)RecyclerView上顯示不同的列表。由于GroupedRecyclerViewAdapter支持設(shè)置不同item類型的頭部捆姜、尾部和子項(xiàng)传趾,所有它能在一個(gè)RecyclerView上顯示多種不同的布局和列表,也符合實(shí)現(xiàn)復(fù)雜布局的需求娇未。但是由于GroupedRecyclerViewAdapter并非為這種復(fù)雜布局設(shè)計(jì)的墨缘,用它來實(shí)現(xiàn)這種布局,需要使用者在GroupedRecyclerViewAdapter的子類上管理好頁面的數(shù)據(jù)和各種類型布局的顯示邏輯零抬,顯得臃腫又麻煩。如果不把它整合在一個(gè)RecyclerView上宽涌,而是使用布局的嵌套實(shí)現(xiàn)平夜,不僅嚴(yán)重影響布局的性能,而且解決滑動(dòng)沖突也是個(gè)令人頭疼的問題卸亮。盡管Google為了更好地解決滑動(dòng)布局間的滑動(dòng)沖突問題忽妒,在Android 5.0的時(shí)候推出了NestedScrolling機(jī)制,不過要自己來處理各種滑動(dòng)問題兼贸,依然不是一件容易的事情段直。
無論多么復(fù)雜的頁面,它都是由一個(gè)個(gè)小控件組成的溶诞。如果能有一個(gè)布局容器幫我們處理好布局內(nèi)所有的子View的滑動(dòng)問題鸯檬,使得無論是普通控件還是滑動(dòng)布局,在這個(gè)容器里都能像一個(gè)整體一樣滑動(dòng)螺垢,滑動(dòng)它就好像是滑動(dòng)一個(gè)普通的ScrollView一樣喧务。那么我們是否就可以不用再關(guān)心布局的滑動(dòng)沖突和滑動(dòng)性能問題。無論多么復(fù)雜的布局枉圃,我們都只需要考慮布局的各個(gè)小部分該用什么控件就用什么控件功茴,任何復(fù)雜的布局都將不再復(fù)雜。ConsecutiveScrollerLayout正是基于這樣的需求而設(shè)計(jì)的孽亲。
設(shè)計(jì)思路
在構(gòu)思ConsecutiveScrollerLayout時(shí)坎穿,我是考慮使用NestedScrolling機(jī)制實(shí)現(xiàn)的,但是后來我放棄了這種方案,主要原因有二:
1玲昧、NestedScrolling機(jī)制主要是協(xié)調(diào)父布局和子布局的滑動(dòng)沖突栖茉,分發(fā)滑動(dòng)事件,至于布局的滑動(dòng)是由它們自己各自完成的酌呆。這不符合我希望把ConsecutiveScrollerLayout的所有子View當(dāng)作一個(gè)滑動(dòng)整體的構(gòu)思衡载,我希望把子View的內(nèi)容視作是ConsecutiveScrollerLayout內(nèi)容的一部分,無論是ConsecutiveScrollerLayout自身還是它的子View隙袁,都由ConsecutiveScrollerLayout來統(tǒng)一處理滑動(dòng)事件痰娱。
2、NestedScrolling機(jī)制要求父布局實(shí)現(xiàn)NestedScrollingParent接口菩收,所有可滑動(dòng)的子View實(shí)現(xiàn)NestedScrollingChild接口梨睁。而我希望ConsecutiveScrollerLayout在使用上盡可能的沒有限制,任何View放進(jìn)它都可以很好的工作娜饵,而且子View無需關(guān)心它是怎么滑動(dòng)的坡贺。
否決了NestedScrolling機(jī)制后,我嘗試從View的內(nèi)容滑動(dòng)的相關(guān)方法來尋找突破點(diǎn)箱舞。我發(fā)現(xiàn)Android幾乎所有的View都是通過scrollBy() -> scrollTo()方法滑動(dòng)View的內(nèi)容遍坟,而且大部分的滑動(dòng)布局也是直接或者間接調(diào)用這個(gè)方法來實(shí)現(xiàn)滑動(dòng)的。所以這兩個(gè)方法是處理布局滑動(dòng)的入口晴股,通過重寫這兩個(gè)方法可以重新定義布局的滑動(dòng)邏輯愿伴。
具體的思路是通過攔截可滑動(dòng)的子view的滑動(dòng)事件,使它無法自己滑動(dòng)电湘,而把事件統(tǒng)一交由ConsecutiveScrollerLayout處理隔节,ConsecutiveScrollerLayout重寫scrollBy()、scrollTo()方法寂呛,在scrollTo()方法中通過計(jì)算分發(fā)滑動(dòng)的偏移量怎诫,決定是由自身還是具體的子View消費(fèi)滑動(dòng)的距離,調(diào)用自身的super.scrollTo()和子View的scrollBy()來滑動(dòng)自身和子View的內(nèi)容贷痪。
說了這么多幻妓,下面讓我們通過代碼,分析一下ConsecutiveScrollerLayout是如何實(shí)現(xiàn)的呢诬。下面給出的代碼是源碼的一些主要片段涌哲,刪除了一些與設(shè)計(jì)思路和主要流程無關(guān)的處理細(xì)節(jié),便于大家更好的理解它的設(shè)計(jì)和實(shí)現(xiàn)原理尚镰。
效果圖
在開始前阀圾,先讓大家看一下ConsecutiveScrollerLayout實(shí)現(xiàn)的效果。
onMeasure狗唉、onLayout
ConsecutiveScrollerLayout繼承自ViewGroup初烘,一個(gè)自定義布局總是免不了重寫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;
// 聯(lián)動(dòng)容器可滾動(dòng)最大距離
mScrollRange += child.getHeight();
}
// 聯(lián)動(dòng)容器可滾動(dòng)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從上到下排列,就像一個(gè)垂直的LinearLayout一樣吗铐。getNonGoneChildren()方法過濾掉隱藏的子view东亦,隱藏的子view不參與布局。上面的mScrollRange變量是布局自身可滑動(dòng)的范圍唬渗,它等于所有子view的高度減去布局自身的內(nèi)容顯示高度典阵。在后面,它將用于計(jì)算布局的滑動(dòng)偏移和邊距限制镊逝。
攔截滑動(dòng)事件
前面說過ConsecutiveScrollerLayout會(huì)攔截它的可滑動(dòng)的子view的滑動(dòng)事件壮啊,由自己來處理所有的滑動(dòng)。下面是它攔截事件的實(shí)現(xiàn)撑蒜。
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
if (ev.getAction() == MotionEvent.ACTION_MOVE) {
// 需要攔截事件
if (isIntercept(ev)) {
return true;
}
}
return super.onInterceptTouchEvent(ev);
}
如果是滑動(dòng)事件(ACTION_MOVE)歹啼,判斷是否需要攔截事件,攔截則直接返回true座菠,讓事件交由ConsecutiveScrollerLayout的onTouchEvent方法處理狸眼。判斷是否需要攔截的關(guān)鍵是isIntercept(ev)方法。
/**
* 判斷是否需要攔截事件
*/
private boolean isIntercept(MotionEvent ev) {
// 根據(jù)觸摸點(diǎn)獲取當(dāng)前觸摸的子view
View target = getTouchTarget((int) ev.getRawX(), (int) ev.getRawY());
if (target != null) {
// 判斷子view是否允許父布局?jǐn)r截事件
ViewGroup.LayoutParams lp = target.getLayoutParams();
if (lp instanceof LayoutParams) {
if (!((LayoutParams) lp).isConsecutive) {
return false;
}
}
// 判斷子view是否可以垂直滑動(dòng)
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是否可以垂直滑動(dòng)份企,如果可以垂直滑動(dòng),就攔截事件巡莹,讓事件由ConsecutiveScrollerLayout自己處理。如果不是甜紫,就不攔截降宅,一般不能滑動(dòng)的view不會(huì)消費(fèi)滑動(dòng)事件,所以事件最終會(huì)由ConsecutiveScrollerLayout所消費(fèi)囚霸。之所以不直接攔截腰根,是為了能讓子view盡可能的獲得事件處理和分發(fā)給下面的view的機(jī)會(huì)。
這里有一個(gè)isConsecutive的LayoutParams屬性拓型,它是ConsecutiveScrollerLayout.LayoutParams的自定義屬性额嘿,用于表示一個(gè)子view是否允許ConsecutiveScrollerLayout攔截它的滑動(dòng)事件,默認(rèn)為true劣挫。如果把它設(shè)置為false册养,父布局將不會(huì)攔截這個(gè)子view的事件,而是完全交由子view處理压固。這使得子view有了自己處理滑動(dòng)事件的機(jī)會(huì)和分發(fā)事件的主動(dòng)權(quán)球拦。這對于實(shí)現(xiàn)一些需要實(shí)現(xiàn)局部區(qū)域內(nèi)滑動(dòng)的特殊需求十分有用。我在GitHub中提供的demo和使用介紹中對isConsecutive有詳細(xì)的說明,在這就不做過多介紹了坎炼。
滑動(dòng)處理
把事件攔截后愧膀,就要在onTouchEvent方法中處理滑動(dòng)事件。
@Override
public boolean onTouchEvent(MotionEvent ev) {
switch (ev.getAction()) {
case MotionEvent.ACTION_DOWN:
// 記錄觸摸點(diǎn)
mTouchY = (int) ev.getY();
// 追蹤滑動(dòng)速度
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;
// 滑動(dòng)布局
scrollBy(0, -dy);
// 追蹤滑動(dòng)速度
initVelocityTrackerIfNotExists();
mVelocityTracker.addMovement(ev);
break;
case MotionEvent.ACTION_CANCEL:
case MotionEvent.ACTION_UP:
mTouchY = 0;
if (mVelocityTracker != null) {
// 處理慣性滑動(dòng)
mVelocityTracker.computeCurrentVelocity(1000, mMaximumVelocity);
int yVelocity = (int) mVelocityTracker.getYVelocity();
recycleVelocityTracker();
fling(-yVelocity);
}
break;
}
return true;
}
// 慣性滑動(dòng)
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();
// 滑動(dòng)布局
dispatchScroll(curY);
invalidate();
}
}
onTouchEvent方法的邏輯非常簡單谣光,就是根據(jù)手指的滑動(dòng)距離通過view的scrollBy方法滑動(dòng)布局內(nèi)容檩淋,同時(shí)通過VelocityTracker追蹤手指的滑動(dòng)速度,使用Scroller配合computeScroll()方法實(shí)現(xiàn)慣性滑動(dòng)。
滑動(dòng)距離的分發(fā)
在處理慣性滑動(dòng)是時(shí)候飒泻,我們調(diào)用了dispatchScroll()方法主守,這個(gè)方法是整個(gè)ConsecutiveScrollerLayout的核心,它決定了應(yīng)該由誰來消費(fèi)這次滑動(dòng)熬芜,應(yīng)該滑動(dòng)那個(gè)布局。其實(shí)ConsecutiveScrollerLayout的scrollBy()和scrollTo()方法最終都是調(diào)用它來處理滑動(dòng)的分發(fā)的福稳。
@Override
public void scrollBy(int x, int y) {
scrollTo(0, mOwnScrollY + y);
}
@Override
public void scrollTo(int x, int y) {
//所有的scroll操作都交由dispatchScroll()來分發(fā)處理
dispatchScroll(y);
}
private void dispatchScroll(int y) {
int offset = y - mOwnScrollY;
if (mOwnScrollY < y) {
// 向上滑動(dòng)
scrollUp(offset);
} else if (mOwnScrollY > y) {
// 向下滑動(dòng)
scrollDown(offset);
}
}
這里有個(gè)mOwnScrollY屬性涎拉,是用于記錄ConsecutiveScrollerLayout的整體滑動(dòng)距離的,相當(dāng)于View的mScrollY屬性的圆。
dispatchScroll()方法把滑動(dòng)分成向上和向下兩部分處理鼓拧。讓我們先看向上滑動(dòng)部分的處理。
private void scrollUp(int offset) {
int scrollOffset = 0; // 消費(fèi)的滑動(dòng)記錄
int remainder = offset; // 未消費(fèi)的滑動(dòng)距離
do {
scrollOffset = 0;
// 是否滑動(dòng)到底部
if (!isScrollBottom()) {
// 找到當(dāng)前顯示的第一個(gè)View
View firstVisibleView = findFirstVisibleView();
if (firstVisibleView != null) {
awakenScrollBars();
// 獲取View滑動(dòng)到自身底部的偏移量
int bottomOffset = ScrollUtils.getScrollBottomOffset(firstVisibleView);
if (bottomOffset > 0) {
// 如果bottomOffset大于0越妈,表示這個(gè)view還沒有滑動(dòng)到自身的底部季俩,那么就由這個(gè)view來消費(fèi)這次的滑動(dòng)距離。
int childOldScrollY = ScrollUtils.computeVerticalScrollOffset(firstVisibleView);
// 計(jì)算需要滑動(dòng)的距離
scrollOffset = Math.min(remainder, bottomOffset);
// 滑動(dòng)子view
scrollChild(firstVisibleView, scrollOffset);
// 計(jì)算真正的滑動(dòng)距離
scrollOffset = ScrollUtils.computeVerticalScrollOffset(firstVisibleView) - childOldScrollY;
} else {
// 如果子view已經(jīng)滑動(dòng)到自身的底部梅掠,就由父布局消費(fèi)滑動(dòng)距離酌住,直到把這個(gè)子view滑出屏幕
int selfOldScrollY = getScrollY();
// 計(jì)算需要滑動(dòng)的距離
scrollOffset = Math.min(remainder,
firstVisibleView.getBottom() - getPaddingTop() - getScrollY());
// 滑動(dòng)父布局
scrollSelf(getScrollY() + scrollOffset);
// 計(jì)算真正的滑動(dòng)距離
scrollOffset = getScrollY() - selfOldScrollY;
}
// 計(jì)算消費(fèi)的滑動(dòng)距離,如果還沒有消費(fèi)完阎抒,就繼續(xù)循環(huán)消費(fèi)酪我。
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);
}
向上滑動(dòng)的處理邏輯是,先找到當(dāng)前顯示的第一個(gè)子view且叁,判斷它的內(nèi)容是否已經(jīng)滑動(dòng)到它的底部都哭,如果沒有,則由它來消費(fèi)滑動(dòng)距離逞带。如果已經(jīng)滑動(dòng)到它的底部欺矫,則由ConsecutiveScrollerLayout來消費(fèi)滑動(dòng)距離,直到把這個(gè)子view滑出屏幕展氓。這樣下一次獲取顯示的第一個(gè)view就是它的下一個(gè)view了穆趴,重復(fù)以上的操作,直到把ConsecutiveScrollerLayout和所有的子view都滑動(dòng)到底部带饱,這樣就整體都滑動(dòng)到底部了毡代。
這里使用了一個(gè)while循環(huán)操作阅羹,這樣做是因?yàn)橐淮位瑒?dòng)距離,可能會(huì)由多個(gè)對象來消費(fèi)教寂,比如需要滑動(dòng)50px的距離捏鱼,但是當(dāng)前顯示的第一個(gè)子view還需要10px滑動(dòng)到自己的底部,那么這個(gè)子view會(huì)消費(fèi)10px的距離酪耕,剩下40px的距離就要進(jìn)行下一次的分發(fā)导梆,找到需要消費(fèi)它的對象,以此類推迂烁。
向下滑動(dòng)的處理跟向上滑動(dòng)是一摸一樣的看尼,只是判斷的對象和滑動(dòng)的方向不同。
private void scrollDown(int offset) {
int scrollOffset = 0; // 消費(fèi)的滑動(dòng)記錄
int remainder = offset; // 未消費(fèi)的滑動(dòng)距離
do {
scrollOffset = 0;
// 是否滑動(dòng)到頂部
if (!isScrollTop()) {
// 找到當(dāng)前顯示的最后一個(gè)View
View lastVisibleView = findLastVisibleView();
if (lastVisibleView != null) {
awakenScrollBars();
// 獲取View滑動(dòng)到自身頂部的偏移量
int childScrollOffset = ScrollUtils.getScrollTopOffset(lastVisibleView);
if (childScrollOffset < 0) {
// 如果childScrollOffset大于0盟步,表示這個(gè)view還沒有滑動(dòng)到自身的頂部藏斩,那么就由這個(gè)view來消費(fèi)這次的滑動(dòng)距離。
int childOldScrollY = ScrollUtils.computeVerticalScrollOffset(lastVisibleView);
// 計(jì)算需要滑動(dòng)的距離
scrollOffset = Math.max(remainder, childScrollOffset);
// 滑動(dòng)子view
scrollChild(lastVisibleView, scrollOffset);
// 計(jì)算真正的滑動(dòng)距離
scrollOffset = ScrollUtils.computeVerticalScrollOffset(lastVisibleView) - childOldScrollY;
} else {
// 如果子view已經(jīng)滑動(dòng)到自身的頂部却盘,就由父布局消費(fèi)滑動(dòng)距離狰域,直到把這個(gè)子view完全滑動(dòng)進(jìn)屏幕
int scrollY = getScrollY();
// 計(jì)算需要滑動(dòng)的距離
scrollOffset = Math.max(remainder,
lastVisibleView.getTop() + getPaddingBottom() - scrollY - getHeight());
// 滑動(dòng)父布局
scrollSelf(scrollY + scrollOffset);
// 計(jì)算真正的滑動(dòng)距離
scrollOffset = getScrollY() - scrollY;
}
// 計(jì)算消費(fèi)的滑動(dòng)距離,如果還沒有消費(fèi)完黄橘,就繼續(xù)循環(huán)消費(fèi)兆览。
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;
}
到這里,關(guān)于ConsecutiveScrollerLayout到實(shí)現(xiàn)思路和核心代碼就分析完了塞关。由于篇幅問題抬探,我把對布局吸頂功能的分析寫了另一篇文章:Android滑動(dòng)布局ConsecutiveScrollerLayout實(shí)現(xiàn)布局吸頂功能
另外我還寫了一篇文章是專門介紹ConsecutiveScrollerLayout的使用的,有興趣的朋友可以看一下:Android持續(xù)滑動(dòng)布局ConsecutiveScrollerLayout的使用
下面給出ConsecutiveScrollerLayout到項(xiàng)目地址帆赢,如果你喜歡我的作品小压,或者這個(gè)布局對你有所幫助,請給我點(diǎn)個(gè)star唄椰于!