原創 zhanghao 百度App技術git
百度App在17年的版本中實現2個子view嵌套滾動,用於Feed落地頁(webview呈現文章詳情 + recycle呈現Native評論)。原理是在外層提供一個UI容器(咱們稱之爲」聯動容器」)處理WebView和Recyclerview連貫嵌套滾動。github
當時的聯動容器對子view限制比較大,僅支持WebView和Recyclerview進行聯動滾動,數量也只支持2個子View。web
隨着組件化進程的推動,爲方便各業務解耦,對聯動容器提出了更高的要求,須要支持任意類型、任意數量的子view進行聯動滾動,也就是本文要闡述的多子view嵌套滾動通用解決方案。express
先直觀感覺下聯動容器嵌套滾動的Demo效果:數組
同大多數自定義控件相似,聯動容器也須要處理子view的測量、佈局以及手勢處理。測量和佈局對聯動容器的場景來講很是簡單,手勢處理相對複雜些。bash
從demo效果能夠看出,聯動容器須要處理好和子view嵌套滑動問題。嵌套滑動的處理方案有兩種app
百度App早期版本的聯動容器採用的方案2實現的,下圖爲方案2聯動容器手勢處理流程:ide
筆者對方案2聯動容器的實現代碼作了開源,感興趣的同窗能夠參考: github.com/baiduapp-te… 基於google的NestedScrolling實現多子view嵌套能節省很多開發量,故筆者對多子view嵌套的實現採用方案一。Google在Android 5.0推出了一套NestedScrolling機制,這套機制滾動打破了對以前Android傳統的事件處理的認知,是按照逆向事件傳遞機制來處理嵌套滾動,事件傳遞可參考下圖:工具
網上有不少關於NestedScrolling的文章,若是沒接觸過NestedScrolling的同窗可參考下張鴻洋的這篇文章: blog.csdn.net/lmj62356579…爲了保證聯動容器中子view的任意性,聯動容器需提供完善的接口抽象供子view去實現。下圖爲聯動容器暴露的接口類圖:組件化
ILinkageScroll是置於聯動容器中的子view必需要實現的接口,聯動容器在初始化時若是發現某個子view沒實現該接口,會拋出異常。ILinkageScroll中又會涉及兩個接口:LinkageScrollHandler、ChildLinkageEvent。LinkageScrollHandler接口中的方法聯動容器會在須要時主動調用,以通知子view完成一些功能,好比:獲取子view是否可滾動,獲取子view滾動條相關數據等。
ChildLinkageEvent接口定義了子view的一些事件信息,好比子view的內容滾動到頂部或底部。當發生這些事件後,子view主動調用對應方法,這樣聯動容器收到子view一些事件後會作出相應的反應,保證正常的聯動效果。
上面僅簡單說明了下接口功能,想更加深刻了解的同窗請參考:github.com/baiduapp-te…
接下來咱們詳細分析下聯動容器對手勢處理細節,根據手勢類型,將嵌套滑動分爲兩種狀況來分析: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地址: github.com/baiduapp-te…