編輯:關於Android編程
在開始之前,我想說,如果需求是每個Item寬高一樣,實現起來復雜度比每個Item寬高不一樣的,要小10+倍。然而我們今天要實現的流式布局,恰巧就是至少每個Item的寬度不一樣,所以在計算坐標的時候算的我死去活來。先看一下效果圖:
艾瑪,換成妹子圖後貌似好看了許多,我都不認識它了,好吧,項目裡它一般長下面這樣:
往常這種效果,我們一般使用自定義ViewGroup實現,我以前也寫了一個。
這不最近再研究自定義LayoutManager麼,想來想去也沒有好的創意,就先拿它開第一刀吧。
(後話:流式布局Item寬度不一,不知不覺給自己挖了個大坑,造成拓展一些功能難度倍增,觀之網上的DEMO,99%Item的大小都是一樣的,so,這個系列的下一篇我計劃 實現一個Item大小一樣 的酷炫LayoutManager。但是最終做成啥樣的效果還沒想好,有朋友看到酷炫的效果可以告訴我,我去高仿一個。)
以本文的流式布局為例,需求是一個垂直滾動的布局,子View以流式排列。先總結一下步驟:
一 實現 generateDefaultLayoutParams()
二 實現 onLayoutChildren()
三 豎直滾動需要 重寫canScrollVertically()和scrollVerticallyBy()
下面我們就一步一步來吧。
如果沒有特殊需求,大部分情況下,我們只需要如下重寫該方法即可。
@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() == 0) {//沒有Item,界面空著吧
detachAndScrapAttachedViews(recycler);
return;
}
if (getChildCount() == 0 && state.isPreLayout()) {//state.isPreLayout()是支持動畫的
return;
}
//onLayoutChildren方法在RecyclerView 初始化時 會執行兩遍
detachAndScrapAttachedViews(recycler);
//初始化
mVerticalOffset = 0;
mFirstVisiPos = 0;
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 = 0;//每一行最大的高度
int minPos = mFirstVisiPos;//初始化時,我們不清楚究竟要layout多少個子View,所以就假設從0~itemcount-1
mLastVisiPos = getItemCount() - 1;
//順序addChildView
for (int i = minPos; i <= mLastVisiPos; i++) {
//找recycler要一個childItemView,我們不管它是從scrap裡取,還是從RecyclerViewPool裡取,亦或是onCreateViewHolder裡拿。
View child = recycler.getViewForPosition(i);
addView(child);
measureChildWithMargins(child, 0, 0);
//計算寬度 包括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 = 0;
//新起一行的時候要判斷一下邊界
if (topOffset - dy > getHeight() - getPaddingBottom()) {
//越界了 就回收
removeAndRecycleView(child, recycler);
mLastVisiPos = i - 1;
} 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 == 0 || getChildCount() == 0) {
return 0;
}
int realOffset = dy;//實際滑動的距離, 可能會在邊界處被修復
//邊界修復代碼
if (mVerticalOffset + realOffset < 0) {//上邊界
realOffset = -mVerticalOffset;
} else if (realOffset > 0) {//下邊界
//利用最後一個子View比較修正
View lastChild = getChildAt(getChildCount() - 1);
if (getPosition(lastChild) == getItemCount() - 1) {
int gap = getHeight() - getPaddingBottom() - getDecoratedBottom(lastChild);
if (gap > 0) {
realOffset = -gap;
} else if (gap == 0) {
realOffset = 0;
} 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 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() > 0) {//滑動時進來的
for (int i = getChildCount() - 1; i >= 0; i--) {
View child = getChildAt(i);
if (dy > 0) {//需要回收當前屏幕,上越界的View
if (getDecoratedBottom(child) - dy < topOffset) {
removeAndRecycleView(child, recycler);
mFirstVisiPos++;
continue;
}
} else if (dy < 0) {//回收當前屏幕,下越界的View
if (getDecoratedTop(child) - dy > getHeight() - getPaddingBottom()) {
removeAndRecycleView(child, recycler);
mLastVisiPos--;
continue;
}
}
}
//detachAndScrapAttachedViews(recycler);
}
int leftOffset = getPaddingLeft();
int lineMaxHeight = 0;
//布局子View階段
if (dy >= 0) {
int minPos = mFirstVisiPos;
mLastVisiPos = getItemCount() - 1;
if (getChildCount() > 0) {
View lastView = getChildAt(getChildCount() - 1);
minPos = getPosition(lastView) + 1;//從最後一個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, 0, 0);
//計算寬度 包括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 = 0;
//新起一行的時候要判斷一下邊界
if (topOffset - dy > getHeight() - getPaddingBottom()) {
//越界了 就回收
removeAndRecycleView(child, recycler);
mLastVisiPos = i - 1;
} 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() - 1);
if (getPosition(lastChild) == getItemCount() - 1) {
int gap = getHeight() - getPaddingBottom() - getDecoratedBottom(lastChild);
if (gap > 0) {
dy -= gap;
}
}
} else {
/**
* ## 利用Rect保存子View邊界
正序排列時,保存每個子View的Rect,逆序時,直接拿出來layout。
*/
int maxPos = getItemCount() - 1;
mFirstVisiPos = 0;
if (getChildCount() > 0) {
View firstView = getChildAt(0);
maxPos = getPosition(firstView) - 1;
}
for (int i = maxPos; i >= mFirstVisiPos; i--) {
Rect rect = mItemRects.get(i);
if (rect.bottom - mVerticalOffset - dy < getPaddingTop()) {
mFirstVisiPos = i + 1;
break;
} else {
View child = recycler.getViewForPosition(i);
addView(child, 0);//將View添加至RecyclerView中,childIndex為1,但是View的位置還是由layout的位置決定
measureChildWithMargins(child, 0, 0);
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已經可以用了,使用的效果就和文首的兩張圖一模一樣。
下面再提及一些其他注意點和適配事項:
五 適配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 == 0) {
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實現的流式布局
前言開發做得久了,總免不了會遇到各種坑。而在Android開發的路上,『軟鍵盤擋住了輸入框』這個坑,可謂是一個曠日持久的巨坑——來來來,我們慢慢看
IT行業是一個踩在巨人肩膀上前進的行業,否則做的事情不一定有意義,所以我也是基於havlenapetr移植的ffmpeg基礎上做了些改進,他做的主要貢獻有:1. 移植了f
1、Netfilter與iptables關系Netfilter: http://www.netfilter.org/: Netfilter is a fram
MediaPlayer通過如下兩個靜態方法來加載指定的音頻: 1、static MediaPlayer create(Context context,Uri uri