本篇文章已授權微信公衆号 guolin_blog (郭霖)獨家釋出
轉載請标明出處:
http://blog.csdn.net/zxt0601/article/details/52956504
本文出自:【張旭童的部落格】
本系列文章相關代碼傳送門:
自定義LayoutManager實作的流式布局
歡迎star,pr,issue。
本系列文章目錄:
掌握自定義LayoutManager(一) 系列開篇 常見誤區、問題、注意事項,常用API。
掌握自定義LayoutManager(二) 實作流式布局
一 概述
在開始之前,我想說,如果需求是每個Item寬高一樣,實作起來複雜度比每個Item寬高不一樣的,要小10+倍。
然而我們今天要實作的流式布局,恰巧就是至少每個Item的寬度不一樣,是以在計算坐标的時候算的我死去活來。先看一下效果圖:

艾瑪,換成妹子圖後貌似好看了許多,我都不認識它了,好吧,項目裡它一般長下面這樣:
往常這種效果,我們一般使用自定義ViewGroup實作,我以前也寫了一個。自定義VG實作流式布局
這不最近再研究自定義LayoutManager麼,想來想去也沒有好的創意,就先拿它開第一刀吧。
(後話:流式布局Item寬度不一,不知不覺給自己挖了個大坑,造成拓展一些功能難度倍增,觀之網上的DEMO,99%Item的大小都是一樣的,so,這個系列的下一篇我計劃 實作一個Item大小一樣 的酷炫LayoutManager。但是最終做成啥樣的效果還沒想好,有朋友看到酷炫的效果可以告訴我,我去高仿一個。)
自定義LayoutManager的步驟:
以本文的流式布局為例,需求是一個垂直滾動的布局,子View以流式排列。先總結一下步驟:
一 實作 generateDefaultLayoutParams()
二 實作 onLayoutChildren()
三 豎直滾動需要 重寫canScrollVertically()和scrollVerticallyBy()
下面我們就一步一步來吧。
二 實作generateDefaultLayoutParams()
如果沒有特殊需求,大部分情況下,我們隻需要如下重寫該方法即可。
@Override
public RecyclerView.LayoutParams generateDefaultLayoutParams() {
return new RecyclerView.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT);
}
RecyclerView.LayoutParams
是繼承自
android.view.ViewGroup.MarginLayoutParams
的,是以可以友善的使用各種margin。
這個方法最終會在
recycler.getViewForPosition(i)
時調用到,在該方法浩長源碼的最下方:
final ViewGroup.LayoutParams lp = holder.itemView.getLayoutParams();
final LayoutParams rvLayoutParams;
if (lp == null) {
//這裡會調用mLayout.generateDefaultLayoutParams()為每個ItemView設定LayoutParams
rvLayoutParams = (LayoutParams) generateDefaultLayoutParams();
holder.itemView.setLayoutParams(rvLayoutParams);
} else if (!checkLayoutParams(lp)) {
rvLayoutParams = (LayoutParams) generateLayoutParams(lp);
holder.itemView.setLayoutParams(rvLayoutParams);
} else {
rvLayoutParams = (LayoutParams) lp;
}
rvLayoutParams.mViewHolder = holder;
rvLayoutParams.mPendingInvalidate = fromScrap && bound;
return holder.itemView;
重寫完這個方法就能編譯通過了,隻不過然并卵,界面上是一片空白,下面我們就走進
onLayoutChildren()
方法 ,為界面添加Item。
注:99%用不到的情況:如果需要存儲一些額外的東西在
LayoutParams
裡,這裡傳回你自定義的
LayoutParams
即可。
當然,你自定義的
LayoutParams
需要繼承自
RecyclerView.LayoutParams
。
三 onLayoutChildren()
該方法是LayoutManager的入口。它會在如下情況下被調用:
1 在RecyclerView初始化時,會被調用兩次。
2 在調用adapter.notifyDataSetChanged()時,會被調用。
3 在調用setAdapter替換Adapter時,會被調用。
4 在RecyclerView執行動畫時,它也會被調用。
即RecyclerView 初始化 、 資料源改變時 都會被調用。
(關于初始化時為什麼會被調用兩次,我在系列第一篇文章裡已經分析過。)
在系列開篇我已經提到,它相當于ViewGroup的onLayout()方法,是以我們需要在裡面layout目前螢幕可見的所有子View,千萬不要layout出所有的子View。本文如下編寫:
private int mVerticalOffset;//豎直偏移量 每次換行時,要根據這個offset判斷
private int mFirstVisiPos;//螢幕可見的第一個View的Position
private int mLastVisiPos;//螢幕可見的最後一個View的Position
@Override
public void onLayoutChildren(RecyclerView.Recycler recycler, RecyclerView.State state) {
if (getItemCount() == ) {//沒有Item,界面空着吧
detachAndScrapAttachedViews(recycler);
return;
}
if (getChildCount() == && state.isPreLayout()) {//state.isPreLayout()是支援動畫的
return;
}
//onLayoutChildren方法在RecyclerView 初始化時 會執行兩遍
detachAndScrapAttachedViews(recycler);
//初始化
mVerticalOffset = ;
mFirstVisiPos = ;
mLastVisiPos = getItemCount();
//初始化時調用 填充childView
fill(recycler, state);
}
這個
fill(recycler, state);
方法将是你自定義LayoutManager之旅一生的敵人,簡單的說它承擔了以下任務:
在考慮滑動位移的情況下:
1 回收所有螢幕不可見的子View
2 layout所有可見的子View
在這一節,我們先看一下它的簡單版本,不考慮滑動位移,不考慮滑動方向等,隻考慮初始化時,從頭至尾,layout所有可見的子View,在下一節我會配合滑動事件放出它的完整版.
int topOffset = getPaddingTop();//布局時的上偏移
int leftOffset = getPaddingLeft();//布局時的左偏移
int lineMaxHeight = ;//每一行最大的高度
int minPos = mFirstVisiPos;//初始化時,我們不清楚究竟要layout多少個子View,是以就假設從0~itemcount-1
mLastVisiPos = getItemCount() - ;
//順序addChildView
for (int i = minPos; i <= mLastVisiPos; i++) {
//找recycler要一個childItemView,我們不管它是從scrap裡取,還是從RecyclerViewPool裡取,亦或是onCreateViewHolder裡拿。
View child = recycler.getViewForPosition(i);
addView(child);
measureChildWithMargins(child, , );
//計算寬度 包括margin
if (leftOffset + getDecoratedMeasurementHorizontal(child) <= getHorizontalSpace()) {//目前行還排列的下
layoutDecoratedWithMargins(child, leftOffset, topOffset, leftOffset + getDecoratedMeasurementHorizontal(child), topOffset + getDecoratedMeasurementVertical(child));
//改變 left lineHeight
leftOffset += getDecoratedMeasurementHorizontal(child);
lineMaxHeight = Math.max(lineMaxHeight, getDecoratedMeasurementVertical(child));
} else {//目前行排列不下
//改變top left lineHeight
leftOffset = getPaddingLeft();
topOffset += lineMaxHeight;
lineMaxHeight = ;
//新起一行的時候要判斷一下邊界
if (topOffset - dy > getHeight() - getPaddingBottom()) {
//越界了 就回收
removeAndRecycleView(child, recycler);
mLastVisiPos = i - ;
} else {
layoutDecoratedWithMargins(child, leftOffset, topOffset, leftOffset + getDecoratedMeasurementHorizontal(child), topOffset + getDecoratedMeasurementVertical(child));
//改變 left lineHeight
leftOffset += getDecoratedMeasurementHorizontal(child);
lineMaxHeight = Math.max(lineMaxHeight, getDecoratedMeasurementVertical(child));
}
}
}
用到的一些工具函數(在系列開篇已介紹過):
//模仿LLM Horizontal 源碼
/**
* 擷取某個childView在水準方向所占的空間
*
* @param view
* @return
*/
public int getDecoratedMeasurementHorizontal(View view) {
final RecyclerView.LayoutParams params = (RecyclerView.LayoutParams)
view.getLayoutParams();
return getDecoratedMeasuredWidth(view) + params.leftMargin
+ params.rightMargin;
}
/**
* 擷取某個childView在豎直方向所占的空間
*
* @param view
* @return
*/
public int getDecoratedMeasurementVertical(View view) {
final RecyclerView.LayoutParams params = (RecyclerView.LayoutParams)
view.getLayoutParams();
return getDecoratedMeasuredHeight(view) + params.topMargin
+ params.bottomMargin;
}
public int getVerticalSpace() {
return getHeight() - getPaddingTop() - getPaddingBottom();
}
public int getHorizontalSpace() {
return getWidth() - getPaddingLeft() - getPaddingRight();
}
如上編寫一個超級簡單的
fill()
方法,運作,你的程式應該就能看到流式布局的效果出現了。
可是千萬别開心,因為痛苦的計算遠沒到來。
如果這些都看不懂,那麼我建議:
一,直接下載下傳完整代碼,配合後面的章節看,看到後面也許前面的就好了解了= =。
二,去學習一下自定義ViewGroup的知識。
此時雖然界面上已經展示了流式布局的效果,可是它并不能滑動,下一節我們讓它動起來。
四,動起來
想讓我們自定義的LayoutManager動起來,最簡單的寫法如下:
@Override
public boolean canScrollVertically() {
return true;
}
@Override
public int scrollVerticallyBy(int dy, RecyclerView.Recycler recycler, RecyclerView.State state) {
int realOffset = dy;//實際滑動的距離, 可能會在邊界處被修複
offsetChildrenVertical(-realOffset);
return realOffset;
}
offsetChildrenVertical(-realOffset);
這句話移動所有的childView.
傳回值會被RecyclerView用來判斷是否達到邊界, 如果傳回值!=傳入的dy,則會有一個邊緣的發光效果,表示到達了邊界。而且傳回值還會被RecyclerView用于計算fling效果。
寫完編譯,哇塞,真的跟随手指滑動了,隻不過能動的總共就我們在上一節layout的那些Item,Item并沒有回收,也沒有新的Item出現。
好了,下面開始正經的寫它吧,
@Override
public int scrollVerticallyBy(int dy, RecyclerView.Recycler recycler, RecyclerView.State state) {
//位移0、沒有子View 當然不移動
if (dy == || getChildCount() == ) {
return ;
}
int realOffset = dy;//實際滑動的距離, 可能會在邊界處被修複
//邊界修複代碼
if (mVerticalOffset + realOffset < ) {//上邊界
realOffset = -mVerticalOffset;
} else if (realOffset > ) {//下邊界
//利用最後一個子View比較修正
View lastChild = getChildAt(getChildCount() - );
if (getPosition(lastChild) == getItemCount() - ) {
int gap = getHeight() - getPaddingBottom() - getDecoratedBottom(lastChild);
if (gap > ) {
realOffset = -gap;
} else if (gap == ) {
realOffset = ;
} else {
realOffset = Math.min(realOffset, -gap);
}
}
}
realOffset = fill(recycler, state, realOffset);//先填充,再位移。
mVerticalOffset += realOffset;//累加實際滑動距離
offsetChildrenVertical(-realOffset);//滑動
return realOffset;
}
這裡用
realOffset
變量儲存實際的位移,也是return 回去的值。大部分情況下它=dy。
在邊界處,為了防止越界,做了一些處理,realOffset 可能不等于dy。
和别的文章不同的是,我參考了LinearLayoutManager的源碼,先考慮滑動位移進行View的回收、填充(
fill()
函數),然後再真正的位移這些子Item。
在
fill()
的過程中
流程:
一 會先考慮到dy,回收界面上不可見的Item。
二 填充布局子View
三 判斷是否将dy都消費掉了,如果消費不掉:例如滑動距離太多,螢幕上的View已經填充完了,仍有空白,那麼就要修正dy給realOffset。
注意事項一:考慮滑動的方向
在填充布局子View的時候,還要考慮滑動的方向,即填充的順序,是從頭至尾填充,還是從尾至頭部填充。
如果是向底部滑動,那麼是順序填充,顯示底端position更大的Item。( dy>0)
如果是向頂部滑動,那麼是逆序填充,顯示頂端positon更小的Item。(dy<0)
注意事項二:流式布局 逆序布局子View的問題
再啰嗦最後一點,我們想象一下這個逆序填充的過程:
正序過程可以自上而下,自左向右layout 子View,每次layout之前判斷目前這一行寬度+子View寬度,是否超過父控件寬度,如果超過了就另起一行。
逆序時,有兩種方案:
1 利用Rect儲存子View邊界
正序排列時,儲存每個子View的Rect,
逆序時,直接拿出來,layout。
2 逆序化
自右向左layout子View,每次layout之前判斷目前這一行寬度+子View寬度,是否超過父控件寬度,
如果超過了就另起一行。并且判斷最後一個子View距離父控件左邊的offset,平移這一行的所有子View,較複雜,采用方案1.
(我個人認為這兩個方案都不太好,希望有朋友能提出更好的方案。)
下面上碼:
private SparseArray<Rect> mItemRects;//key 是View的position,儲存View的bounds ,
/**
* 填充childView的核心方法,應該先填充,再移動。
* 在填充時,預先計算dy的在内,如果View越界,回收掉。
* 一般情況是傳回dy,如果出現View數量不足,則傳回修正後的dy.
*
* @param recycler
* @param state
* @param dy RecyclerView給我們的位移量,+,顯示底端, -,顯示頭部
* @return 修正以後真正的dy(可能剩餘空間不夠移動那麼多了 是以return <|dy|)
*/
private int fill(RecyclerView.Recycler recycler, RecyclerView.State state, int dy) {
int topOffset = getPaddingTop();
//回收越界子View
if (getChildCount() > ) {//滑動時進來的
for (int i = getChildCount() - ; i >= ; i--) {
View child = getChildAt(i);
if (dy > ) {//需要回收目前螢幕,上越界的View
if (getDecoratedBottom(child) - dy < topOffset) {
removeAndRecycleView(child, recycler);
mFirstVisiPos++;
continue;
}
} else if (dy < ) {//回收目前螢幕,下越界的View
if (getDecoratedTop(child) - dy > getHeight() - getPaddingBottom()) {
removeAndRecycleView(child, recycler);
mLastVisiPos--;
continue;
}
}
}
//detachAndScrapAttachedViews(recycler);
}
int leftOffset = getPaddingLeft();
int lineMaxHeight = ;
//布局子View階段
if (dy >= ) {
int minPos = mFirstVisiPos;
mLastVisiPos = getItemCount() - ;
if (getChildCount() > ) {
View lastView = getChildAt(getChildCount() - );
minPos = getPosition(lastView) + ;//從最後一個View+1開始吧
topOffset = getDecoratedTop(lastView);
leftOffset = getDecoratedRight(lastView);
lineMaxHeight = Math.max(lineMaxHeight, getDecoratedMeasurementVertical(lastView));
}
//順序addChildView
for (int i = minPos; i <= mLastVisiPos; i++) {
//找recycler要一個childItemView,我們不管它是從scrap裡取,還是從RecyclerViewPool裡取,亦或是onCreateViewHolder裡拿。
View child = recycler.getViewForPosition(i);
addView(child);
measureChildWithMargins(child, , );
//計算寬度 包括margin
if (leftOffset + getDecoratedMeasurementHorizontal(child) <= getHorizontalSpace()) {//目前行還排列的下
layoutDecoratedWithMargins(child, leftOffset, topOffset, leftOffset + getDecoratedMeasurementHorizontal(child), topOffset + getDecoratedMeasurementVertical(child));
//儲存Rect供逆序layout用
Rect rect = new Rect(leftOffset, topOffset + mVerticalOffset, leftOffset + getDecoratedMeasurementHorizontal(child), topOffset + getDecoratedMeasurementVertical(child) + mVerticalOffset);
mItemRects.put(i, rect);
//改變 left lineHeight
leftOffset += getDecoratedMeasurementHorizontal(child);
lineMaxHeight = Math.max(lineMaxHeight, getDecoratedMeasurementVertical(child));
} else {//目前行排列不下
//改變top left lineHeight
leftOffset = getPaddingLeft();
topOffset += lineMaxHeight;
lineMaxHeight = ;
//新起一行的時候要判斷一下邊界
if (topOffset - dy > getHeight() - getPaddingBottom()) {
//越界了 就回收
removeAndRecycleView(child, recycler);
mLastVisiPos = i - ;
} else {
layoutDecoratedWithMargins(child, leftOffset, topOffset, leftOffset + getDecoratedMeasurementHorizontal(child), topOffset + getDecoratedMeasurementVertical(child));
//儲存Rect供逆序layout用
Rect rect = new Rect(leftOffset, topOffset + mVerticalOffset, leftOffset + getDecoratedMeasurementHorizontal(child), topOffset + getDecoratedMeasurementVertical(child) + mVerticalOffset);
mItemRects.put(i, rect);
//改變 left lineHeight
leftOffset += getDecoratedMeasurementHorizontal(child);
lineMaxHeight = Math.max(lineMaxHeight, getDecoratedMeasurementVertical(child));
}
}
}
//添加完後,判斷是否已經沒有更多的ItemView,并且此時螢幕仍有空白,則需要修正dy
View lastChild = getChildAt(getChildCount() - );
if (getPosition(lastChild) == getItemCount() - ) {
int gap = getHeight() - getPaddingBottom() - getDecoratedBottom(lastChild);
if (gap > ) {
dy -= gap;
}
}
} else {
/**
* ## 利用Rect儲存子View邊界
正序排列時,儲存每個子View的Rect,逆序時,直接拿出來layout。
*/
int maxPos = getItemCount() - ;
mFirstVisiPos = ;
if (getChildCount() > ) {
View firstView = getChildAt();
maxPos = getPosition(firstView) - ;
}
for (int i = maxPos; i >= mFirstVisiPos; i--) {
Rect rect = mItemRects.get(i);
if (rect.bottom - mVerticalOffset - dy < getPaddingTop()) {
mFirstVisiPos = i + ;
break;
} else {
View child = recycler.getViewForPosition(i);
addView(child, );//将View添加至RecyclerView中,childIndex為1,但是View的位置還是由layout的位置決定
measureChildWithMargins(child, , );
layoutDecoratedWithMargins(child, rect.left, rect.top - mVerticalOffset, rect.right, rect.bottom - mVerticalOffset);
}
}
}
Log.d("TAG", "count= [" + getChildCount() + "]" + ",[recycler.getScrapList().size():" + recycler.getScrapList().size() + ", dy:" + dy + ", mVerticalOffset" + mVerticalOffset+", ");
return dy;
}
思路已經在前面講解過,代碼裡也配上了注釋,計算坐标等都是數學問題,略饒人,需要用筆在紙上寫一寫,或者運作調試調試。沒啥好辦法。
值得一提的是,可以通過
getChildCount()
和
recycler.getScrapList().size()
檢視目前螢幕上的Item數量 和 scrapCache緩存區域的Item數量,合格的LayoutManager,childCount數量不應大于螢幕上顯示的Item數量,而scrapCache緩存區域的Item數量應該是0.
官方的LayoutManager都是達标的,本例也是達标的,網上大部分文章的Demo,都是不合格的。。
原因在系列開篇也提過,不再贅述。
至此我們的自定義LayoutManager已經可以用了,使用的效果就和文首的兩張圖一模一樣。
下面再提及一些其他注意點和适配事項:
五 适配notifyDataSetChanged()
此時會回調onLayoutChildren()函數。因為我們流式布局的特殊性,每個Item的寬度不一緻,是以化簡處理,每次這裡歸零。
//初始化區域
mVerticalOffset = 0;
mFirstVisiPos = 0;
mLastVisiPos = getItemCount();
如果每個Item的大小都一樣,逆序順序layoutChild都比較好處理,則應該在此判斷,getChildCount(),大于0說明是DatasetChanged()操作,(初始化的第二次也會childCount>0)。根據目前記錄的position和位移資訊去fill視圖即可。
六 适配 Adapter的替換。
我根據24.2.1源碼,發現網上的資料對這裡的處理其實是不必要的。
一 資料中的做法如下:
當對RecyclerView設定一個新的Adapter時,
onAdapterChanged()
方法會被回調,一般的做法是在這裡remove掉所有的View。此時
onLayoutChildren()
方法會被再次調用,一個新的輪回開始。
@Override
public void onAdapterChanged(final RecyclerView.Adapter oldAdapter, final RecyclerView.Adapter newAdapter) {
removeAllViews();
}
二 我的新觀點:
通過檢視源碼+打斷點跟蹤分析,調用RecyclerView.setAdapter後,調用順序依次為
1 Recycler.setAdapter():
public void setAdapter(Adapter adapter) {
// bail out if layout is frozen
setLayoutFrozen(false);
setAdapterInternal(adapter, false, true); //張旭童注:注意第三個參數是true
requestLayout();
}
那麼我們檢視
setAdapterInternal()
方法:
private void setAdapterInternal(Adapter adapter, boolean compatibleWithPrevious,
boolean removeAndRecycleViews) {
...
//張旭童注:removeAndRecycleViews 參數此時為ture
if (!compatibleWithPrevious || removeAndRecycleViews) {
...
if (mLayout != null) {
//張旭童注: 是以如果我們更換Adapter時,mLayout不為空,會先執行如下操作,
mLayout.removeAndRecycleAllViews(mRecycler);
mLayout.removeAndRecycleScrapInt(mRecycler);
}
// we should clear it here before adapters are swapped to ensure correct callbacks.
//張旭童注:而且還會清空Recycler的緩存
mRecycler.clear();
}
...
if (mLayout != null) {
//張旭童注:這裡才調用的LayoutManager的方法
mLayout.onAdapterChanged(oldAdapter, mAdapter);
}
//張旭童注:這裡調用Recycler的方法
mRecycler.onAdapterChanged(oldAdapter, mAdapter, compatibleWithPrevious);
...
}
也就是說 更換Adapter一開始,還沒有執行到
LayoutManager.onAdapterChanged()
,界面上的View都已經被remove掉了,我們的操作屬于多餘的。
2 LayoutManager.onAdapterChanged()
空實作:也沒必要實作了
public void onAdapterChanged(Adapter oldAdapter, Adapter newAdapter) {
}
3 Recycler.onAdapterChanged():
該方法先清空scapCache區域(貌似也是多餘,一開始被清空過了),然後調用
RecyclerViewPool.onAdapterChanged()
。
void onAdapterChanged(Adapter oldAdapter, Adapter newAdapter,
boolean compatibleWithPrevious) {
clear();
getRecycledViewPool().onAdapterChanged(oldAdapter, newAdapter, compatibleWithPrevious);
}
public void clear() {
mAttachedScrap.clear();
recycleAndClearCachedViews();
}
4 RecyclerViewPool.onAdapterChanged()
如果沒有别的Adapter在用這個RecyclerViewPool,會清空RecyclerViewPool的緩存。
void onAdapterChanged(Adapter oldAdapter, Adapter newAdapter,
boolean compatibleWithPrevious) {
if (oldAdapter != null) {
detach();
}
if (!compatibleWithPrevious && mAttachCount == ) {
clear();
}
if (newAdapter != null) {
attach(newAdapter);
}
}
5 LayoutManager.onLayoutChildren()
新的布局開始。
七 總結:
引用一段話
They are also extremely complex, and hard to get right. For every amount of effort RecyclerView requires of you, it is doing 10x more behind the scenes.
本文Demo仍有很大完善空間,有些需要完善的細節非常複雜,需要經過多次試驗才能得到正确的結果(這裡我更加敬佩Google提供的三個LM)。每一個我們想要實作的需求,可能要花費比我們想象的時間*10倍的時間。
上篇也提及到的,不要過度優化,達成需求就好。
可以通過
getChildCount()
和
recycler.getScrapList().size()
檢視目前螢幕上的Item數量 和 scrapCache緩存區域的Item數量,合格的LayoutManager,childCount數量不應大于螢幕上顯示的Item數量,而scrapCache緩存區域的Item數量應該是0.
官方的LayoutManager都是達标的,本例也是達标的,網上大部分文章的Demo,都是不合格的。。
感興趣的同學可以對網上的各個Demo列印他們onCreateViewHolder執行的次數,以及上述兩個參數的值,和官方的LayoutManager比較,這三個參數先達标,才算是及格的LayoutManager,但後續優化之路仍很長。
本系列文章相關代碼傳送門:
自定義LayoutManager實作的流式布局
歡迎star,pr,issue。