百度App在17年的版本中實現2個子view嵌套滾動,用於Feed落地頁(webview呈現文章詳情 + recycle呈現Native評論)。原理是在外層提供一個UI容器(咱們稱之爲」聯動容器」)處理WebView和Recyclerview連貫嵌套滾動。java
當時的聯動容器對子view限制比較大,僅支持WebView和Recyclerview進行聯動滾動,數量也只支持2個子View。 git
隨着組件化進程的推動,爲方便各業務解耦,對聯動容器提出了更高的要求,須要支持任意類型、任意數量的子view進行聯動滾動,也就是本文要闡述的多子view嵌套滾動通用解決方案。 github
先直觀感覺下聯動容器嵌套滾動的Demo效果:web
同大多數自定義控件相似,聯動容器也須要處理子view的測量、佈局以及手勢處理。測量和佈局對聯動容器的場景來講很是簡單,手勢處理相對複雜些。 express
從demo效果能夠看出,聯動容器須要處理好和子view嵌套滑動問題。嵌套滑動的處理方案有兩種數組
百度App早期版本的聯動容器採用的方案2實現的,下圖爲方案2聯動容器手勢處理流程:微信
筆者對方案2聯動容器的實現代碼作了開源,感興趣的同窗能夠參考:https://github.com/baiduapp-t...。
基於google的NestedScrolling實現多子view嵌套能節省很多開發量,故筆者對多子view嵌套的實現採用方案一。app
Google在Android 5.0推出了一套NestedScrolling機制,這套機制滾動打破了對以前Android傳統的事件處理的認知,是按照逆向事件傳遞機制來處理嵌套滾動,事件傳遞可參考下圖:ide
網上有不少關於NestedScrolling的文章,若是沒接觸過NestedScrolling的同窗可參考下張鴻洋的這篇文章:https://blog.csdn.net/lmj6235...工具
爲了保證聯動容器中子view的任意性,聯動容器需提供完善的接口抽象供子view去實現。下圖爲聯動容器暴露的接口類圖:
ILinkageScroll是置於聯動容器中的子view必需要實現的接口,聯動容器在初始化時若是發現某個子view沒實現該接口,會拋出異常。
ILinkageScroll中又會涉及兩個接口:LinkageScrollHandler、ChildLinkageEvent。
LinkageScrollHandler接口中的方法聯動容器會在須要時主動調用,以通知子view完成一些功能,好比:獲取子view是否可滾動,獲取子view滾動條相關數據等。
ChildLinkageEvent接口定義了子view的一些事件信息,好比子view的內容滾動到頂部或底部。當發生這些事件後,子view主動調用對應方法,這樣聯動容器收到子view一些事件後會作出相應的反應,保證正常的聯動效果。
上面僅簡單說明了下接口功能,想更加深刻了解的同窗請參考:https://github.com/baiduapp-t...
接下來咱們詳細分析下聯動容器對手勢處理細節,根據手勢類型,將嵌套滑動分爲兩種狀況來分析:1. scroll手勢;2. fling手勢;
先給出scroll手勢處理的核心代碼:
public class ELinkageScrollLayout extends ViewGroup implements NestedScrollingParent { @Override public void onNestedPreScroll(View target, int dx, int dy, int[] consumed) { boolean moveUp = dy > 0; boolean moveDown = !moveUp; int scrollY = getScrollY(); int topEdge = target.getTop(); LinkageScrollHandler targetScrollHandler = ((ILinkageScroll)target).provideScrollHandler(); if (scrollY == topEdge) { // 聯動容器scrollY與當前子view的top座標重合 if ((moveDown && !targetScrollHandler.canScrollVertically(-1)) || (moveUp && !targetScrollHandler.canScrollVertically(1))) { // 在對應的滑動方向上,若是子view不能垂直滑動,則由聯動容器消費滾動距離 scrollBy(0, dy); consumed[1] = dy; } } else if (scrollY > topEdge) { // 聯動容器scrollY大於當前子view的top座標,也就是說,子view頭部已經滑出聯動容器 if (moveUp) { // 若是手指上滑,則由聯動容器消費滾動距離 scrollBy(0, dy); consumed[1] = dy; } if (moveDown) { // 若是手指下滑,聯動容器會先消費部分距離,此時聯動容器的scrollY會不斷減少, // 直到等於子view的top座標後,剩餘的滑動距離則由子view繼續消費。 int end = scrollY + dy; int deltaY; deltaY = end > topEdge ? dy : (topEdge - scrollY); scrollBy(0, deltaY); consumed[1] = deltaY; } } else if (scrollY < topEdge) { // 聯動容器scrollY小於當前子view的top座標,也就是說,子view尚未徹底露出 if (moveDown) { // 若是手指下滑,則由聯動容器消費滾動距離 scrollBy(0, dy); consumed[1] = dy; } if (moveUp) { // 若是手指上滑,聯動容器會先消費部分距離,此時聯動容器的scrollY會不斷增大, // 直到等於子view的top座標後,剩餘的滑動距離則由子view繼續消費。 int end = scrollY + dy; int deltaY; deltaY = end < topEdge ? dy : (topEdge - scrollY); scrollBy(0, deltaY); consumed[1] = deltaY; } } } @Override public void scrollBy(int x, int y) { // 邊界檢查 int scrollY = getScrollY(); int deltaY; if (y < 0) { deltaY = (scrollY + y) < 0 ? (-scrollY) : y; } else { deltaY = (scrollY + y) > mScrollRange ? (mScrollRange - scrollY) : y; } if (deltaY != 0) { super.scrollBy(x, deltaY); } } }
onNestedPreScroll()回調是google嵌套滑動機制NestedScrollingParent接口中的方法。當子view滾動時,會先經過此方法詢問父view是否消費這段滾動距離,父view根據自身狀況決定是否消費以及消費多少,並將消費的距離放入數組consumed中,子view再根據數組中的內容決定本身的滾動距離。
代碼註釋比較詳細,這裏總體再作個解釋:經過對子view的上邊沿閾值和聯動容器的scrollY進行比較,處理了3種case下的滾動狀況。
第10行,當scrollY == topEdge時,只要子view沒有滾動到頂或者底,都由子view正常消費滾動距離,不然由聯動容器消費滾動距離,並將消費的距離經過consumed變量通知子view,子view會根據consumed變量中的內容決定本身的滑動距離。
第17行,當scrollY > topEdge時,也就是說當觸摸的子view頭部已經滑出聯動容器,此時若是手指向上滑動,滑動距離所有由聯動容器消費,若是手指向下滑動,聯動容器會先消費部分距離,當聯動容器的scrollY達到topEdge後,剩餘的滑動距離由子view繼續消費。
第32行,當scrollY < topEdge這個和上一個第17行判斷相似,這裏不作過多解釋。scroll手勢處理流程圖以下:
聯動容器對fling手勢的處理大體思路以下:若是聯動容器的scrollY等於子view的top座標,則由子view自身處理fling手勢,不然由聯動容器處理fling手勢。
並且在一次完整的fling週期中,聯動容器和各子view將會交替去完成滑動行爲,直到速度降爲0,聯動容器須要處理好交替滑動時的速度銜接,保證整個fling的流暢行。接下來看下詳細實現:
public class ELinkageScrollLayout extends ViewGroup implements NestedScrollingParent { @Override public boolean onNestedPreFling(View target, float velocityX, float velocityY) { int scrollY = getScrollY(); int targetTop = target.getTop(); mFlingOrientation = velocityY > 0 ? FLING_ORIENTATION_UP : FLING_ORIENTATION_DOWN; if (scrollY == targetTop) { // 當聯動容器的scrollY等於子view的top座標,則由子view自身處理fling手勢 // 跟蹤velocity,當target滾動到頂或底,保證parent繼續fling trackVelocity(velocityY); return false; } else { // 由聯動容器消費fling手勢 parentFling(velocityY); return true; } } }
onNestedPreFling()回調是google嵌套滑動機制NestedScrollingParent接口中的方法。當子view發生fling行爲時,會先經過此方法詢問父view是否要消費此次fling手勢,若是返回true,表示父view要消費此次fling手勢,反之不消費。
第6行根據velocityY正負值記錄本次的fling的方向;
第7行,當聯動容器scrollY值等於觸摸子view的top值,fling手勢由子view處理,同時聯動容器對本次fling手勢的速度進行追蹤,目的是當子view內容滾到頂或者底時,可以得到剩餘速度以讓聯動容器繼續fling;
第12行,由聯動容器消費本次fling手勢。下面看下聯動容器和子view交替fling的細節:
public class ELinkageScrollLayout extends ViewGroup implements NestedScrollingParent { @Override public void computeScroll() { if (mScroller.computeScrollOffset()) { int y = mScroller.getCurrY(); y = y < 0 ? 0 : y; y = y > mScrollRange ? mScrollRange : y; // 獲取聯動容器下個滾動邊界值,若是達到邊界值,速度會傳給下個子view,讓子view繼續快速滑動 int edge = getNextEdge(); // 邊界檢查 if (mFlingOrientation == FLING_ORIENTATION_UP) { y = y > edge ? edge : y; } // 邊界檢查 if (mFlingOrientation == FLING_ORIENTATION_DOWN) { y = y < edge ? edge : y; } // 聯動容器滾動子view scrollTo(x, y); int scrollY = getScrollY(); // 聯動容器最新的scrollY是否達到了邊界值 if (scrollY == edge) { // 獲取剩餘的速度 int velocity = (int) mScroller.getCurrVelocity(); if (mFlingOrientation == FLING_ORIENTATION_UP) { velocity = velocity > 0? velocity : - velocity; } if (mFlingOrientation == FLING_ORIENTATION_DOWN) { velocity = velocity < 0? velocity : - velocity; } // 獲取top爲edge的子view View target = getTargetByEdge(edge); // 子view根據剩餘的速度繼續fling ((ILinkageScroll) target).provideScrollHandler() .flingContent(target, velocity); trackVelocity(velocity); } invalidate(); } } /** * 根據fling的方向獲取下一個滾動邊界, * 內部會判斷下一個子View是否isScrollable, * 若是爲false,會順延取下一個target的edge。 */ private int getNextEdge() { int scrollY = getScrollY(); if (mFlingOrientation == FLING_ORIENTATION_UP) { for (View target : mLinkageChildren) { LinkageScrollHandler handler = ((ILinkageScroll)target).provideScrollHandler(); int topEdge = target.getTop(); if (topEdge > scrollY && isTargetScrollable(target) && handler.canScrollVertically(1)) { return topEdge; } } } else if (mFlingOrientation == FLING_ORIENTATION_DOWN) { for (View target : mLinkageChildren) { LinkageScrollHandler handler = ((ILinkageScroll)target).provideScrollHandler(); int bottomEdge = target.getBottom(); if (bottomEdge >= scrollY && isTargetScrollable(target) && handler.canScrollVertically(-1)) { return target.getTop(); } } } return mFlingOrientation == FLING_ORIENTATION_UP ? mScrollRange : 0; } /** * child view的滾動事件 */ private ChildLinkageEvent mChildLinkageEvent = new ChildLinkageEvent() { @Override public void onContentScrollToTop(View target) { // 子view內容滾動到頂部回調 if (mVelocityScroller.computeScrollOffset()) { // 從速度追蹤器中獲取剩餘速度 float currVelocity = mVelocityScroller.getCurrVelocity(); currVelocity = currVelocity < 0 ? currVelocity : - currVelocity; mVelocityScroller.abortAnimation(); // 聯動容器根據剩餘速度繼續fling parentFling(currVelocity); } } @Override public void onContentScrollToBottom(View target) { // 子view內容滾動到底部回調 if (mVelocityScroller.computeScrollOffset()) { // 從速度追蹤器中獲取剩餘速度 float currVelocity = mVelocityScroller.getCurrVelocity(); currVelocity = currVelocity > 0 ? currVelocity : - currVelocity; mVelocityScroller.abortAnimation(); // 聯動容器根據剩餘速度繼續fling parentFling(currVelocity); } } }; }
fling的速度傳遞分爲:
先看速度從聯動容器向子view傳遞。核心代碼在computeScroll()回調方法中。第9行,獲取聯動容器下一個滾動邊界值,若是達到下一個滾動邊界值,聯動容器須要將剩餘速度傳給下個子view,讓其繼續滾動。
第46行,getNextEdge()方法內部總體邏輯:遍歷全部子view,將聯動容器當前的scrollY與子view的top/bottom進行比較來獲取下一個滑動邊界。
第34行,當聯動容器檢測到滑動到下個邊界時,則調用ILinkageScroll.flingContent()讓子view根據剩餘速度繼續滾動。
再看速度從子view向聯動容器傳遞,核心代碼在第76行。當子view內容滾動到頂或者底,會回調onContentScrollToTop()方法或者onContentScrollToBottom()方法,聯動容器收到回調後,在第86行和第98行,繼續執行後續滾動。fling手勢處理流程圖以下:
對於內容可滾動的頁面,ScrollBar則是一個不可或缺的UI組件,因此,ScrollBar也是聯動容器必需要實現的功能。
好在Android系統對滾動條的抽象很是友好,自定義控件只須要重寫View中的幾個方法,Android系統就能幫助你正確繪製出滾動條。咱們先看下View中的相關方法:
/** * <p>Compute the vertical offset of the vertical scrollbar's thumb within the horizontal range. This value is used to compute the position * of the thumb within the scrollbar's track.</p> * * <p>The range is expressed in arbitrary units that must be the same as the units used by {@link #computeVerticalScrollRange()} and * {@link #computeVerticalScrollExtent()}.</p> * * @return the vertical offset of the scrollbar's thumb */ protected int computeVerticalScrollOffset() { return mScrollY; } /** * <p>Compute the vertical extent of the vertical scrollbar's thumb within the vertical range. This value is used to compute the length * of the thumb within the scrollbar's track.</p> * * <p>The range is expressed in arbitrary units that must be the same as the units used by {@link #computeVerticalScrollRange()} and * {@link #computeVerticalScrollOffset()}.</p> * * @return the vertical extent of the scrollbar's thumb */ protected int computeVerticalScrollExtent() { return getHeight(); } /** * <p>Compute the vertical range that the vertical scrollbar represents.</p> * * <p>The range is expressed in arbitrary units that must be the same as the units used by {@link #computeVerticalScrollExtent()} and * {@link #computeVerticalScrollOffset()}.</p> * * @return the total vertical range represented by the vertical scrollbar */ protected int computeVerticalScrollRange() { return getHeight(); }
對於垂直Scrollbar,咱們只須要重寫computeVerticalScrollOffset(),computeVerticalScrollExtent(),computeVerticalScrollRange()這三個方法便可。Android對這三個方法註釋已經很是詳細了,這裏再簡單解釋下:
computeVerticalScrollOffset()表示當前頁面內容滾動的偏移值,這個值是用來控制Scrollbar的位置。缺省值爲當前頁面Y方向上的滾動值。
computeVerticalScrollExtent()表示滾動條的範圍,也就是滾動條在垂直方向上所能觸及的最大界限,這個值也會被系統用來計算滾動條的長度。缺省值是View的實際高度。
computeVerticalScrollRange()表示整個頁面內容可滾動的數值範圍,缺省值爲View的實際高度。
須要注意的是:offset,extent,range三個值在單位上必須保持一致。
聯動容器是由系統中可滾動的子view組成的,這些子view(ListView、RecyclerView、WebView)確定都實現了ScrollBar功能,那麼聯動容器實現ScrollBar就很是簡單了,聯動容器只需拿到全部子view的offset,extent,range值,而後再根據聯動容器的滑動邏輯把全部子view的這些值轉換成聯動容器對應的offset,extent,range便可。接口設計以下:
public interface LinkageScrollHandler { // ...省略無關代碼 /** * get scrollbar extent value * * @return extent */ int getVerticalScrollExtent(); /** * get scrollbar offset value * * @return extent */ int getVerticalScrollOffset(); /** * get scrollbar range value * * @return extent */ int getVerticalScrollRange(); }
LinkageScrollHandler接口在3.2小節解釋過,這裏不在贅述。這裏面三個方法由子view去實現,聯動容器會經過這三個方法獲取子view與滾動條相關的值。下面看下聯動容器中關於ScrollBar的詳細邏輯:
public class ELinkageScrollLayout extends ViewGroup implements NestedScrollingParent { /** 構造方法 */ public ELinkageScrollLayout(Context context, AttributeSet attrs, int defStyleAttr) { // ...省略了無關代碼 // 確保聯動容器調用onDraw()方法 setWillNotDraw(false); // enable vertical scrollbar setVerticalScrollBarEnabled(true); } /** child view的滾動事件 */ private ChildLinkageEvent mChildLinkageEvent = new ChildLinkageEvent() { // ...省略了無關代碼 @Override public void onContentScroll(View target) { // 收到子view滾動事件,顯示滾動條 awakenScrollBars(); } } @Override protected int computeVerticalScrollExtent() { // 使用缺省的extent值 return super.computeVerticalScrollExtent(); } @Override protected int computeVerticalScrollRange() { int range = 0; // 遍歷全部子view,獲取子view的Range for (View child : mLinkageChildren) { ILinkageScroll linkageScroll = (ILinkageScroll) child; int childRange = linkageScroll.provideScrollHandler().getVerticalScrollRange(); range += childRange; } return range; } @Override protected int computeVerticalScrollOffset() { int offset = 0; // 遍歷全部子view,獲取子view的offset for (View child : mLinkageChildren) { ILinkageScroll linkageScroll = (ILinkageScroll) child; int childOffset = linkageScroll.provideScrollHandler().getVerticalScrollOffset(); offset += childOffset; } // 加上聯動容器自身在Y方向上的滾動偏移 offset += getScrollY(); return offset; } }
以上就是聯動容器實現ScrollBar的核心代碼,註釋也很是詳細,這裏再重點強調幾點:
系統爲了提升效率,ViewGroup默認不調用onDraw()方法,這樣就不會走ScrollBar的繪製邏輯。因此在第6行,須要調用setWillNotDraw(false)打開ViewGroup繪製流程;
第16行,收到子view的滾動回調,調用awakenScrollBars()觸發滾動條的繪製;
對於extent,直接使用缺省的extent,即聯動容器的高度;
對於range,對全部子view的range進行求和,最後獲得值即爲聯動容器的range;
對於offset,一樣先對全部子view的offset進行求和,以後還須要加上聯動容器自身的scrollY值,最終獲得的值即爲聯動容器的offset。
你們能夠返回到文章開頭,再看下Demo中滾動條的效果,相比於市面上其它使用相似聯動技術的App,本文對滾動條的實現很是接近原生了。
聯動容器執行fling操做時,藉助OverScroller工具類完成的。代碼以下:
private void parentFling(float velocityY) { // ... 省略了無關代碼 mScroller.fling(0, getScrollY(), 0, (int) velocityY, 0, 0, Integer.MIN_VALUE, Integer.MAX_VALUE); invalidate(); }
藉助OverScroller.fling()方法完成聯動容器的fling行爲,這段代碼在小米手機上運行聯動會出現問題,mScroller.getCurrVelocity()一直是0。
緣由是小米手機Rom重寫了OverScroller,當fling()方法第三個參數傳0時,OverScroller.mCurrVelocity一直爲NaN,致使沒法計算出正確剩餘速度。
爲了解決小米手機的問題,咱們須要將第三個參數傳個非0值,這裏給1便可。
private void parentFling(float velocityY) { // ... 省略了無關代碼 mScroller.fling(0, getScrollY(), 1, (int) velocityY, 0, 0, Integer.MIN_VALUE, Integer.MAX_VALUE); invalidate(); }
多子view嵌套實現原理並不複雜,對手勢處理的邊界條件比較瑣碎,須要來回調試完善,歡迎業內的朋友一塊兒交流學習。
Sample地址: https://github.com/baiduapp-t...
本文做者:
zhanghao
在微信-搜索頁面中輸入「百度App技術」,便可關注微信官方帳號;或使用微信識別如下二維碼,亦可關注。