編輯:關於Android編程
StickHeaderItemDecoration
是用於顯示固定頭部的item裝飾類,擴展來自系統的ItemDecoration
.本文參考了一部分sticky-headers-recyclerview
繪制頭部
固定頭部的ItemDecoration
本質是在RecycleView
上覆蓋一個界面.該界面沒有隨著滑動變動所以看起來就像一個固定的頭部.
繪制item間隔
ItemDecoration
也可以實現每個item之間的間隔的繪制(比如分隔線之類的),這種情況下就不是在RecycleView
上直接覆蓋一個界面了,而是通過ItemDecoration
的方法在每個item之間創建一個專門繪制item間隔元素的區域進行繪制.此時是先繪制的itemDecoration,再繪制的itemView,所以超出來繪制區域也不會有影響.
ItemDecoration
中有6個方法,3個是以前的方法被廢棄更新為新的三個方法,所以我們只看新的三個方法.
//將decoration繪制到canvas上,會優先於itemView進行繪制,所以超出繪制區域會被itemView覆蓋,不會有影響(可以理解為繪制背景)
public void onDraw(Canvas c, RecyclerView parent, State state);
//作用同onDraw,但是晚於itemView的繪制,所以會覆蓋在recycleView之上,是完整可見的(不超出 RecycleView 的情況下)
public void onDrawOver(Canvas c, RecyclerView parent, State state);
//將這個方法中獲取的outRect插到padding或者margin中,擴大了itemView之間的間距,用於onDraw中繪制decoration
public void getItemOffsets(Rect outRect, View view, RecyclerView parent, State state);
查看一下源碼可知,onDraw
與onDrawOver
都是在RecycleView
繪制時直接調用繪制的,但是getItemOffsets
是在measureChild的時候就已經計算好了添加到間隔中的.所以每一個item可以有不同的間距.
這裡需要注意的是getItemOffsets
中是使用Rect存儲計算後的間距,雖然這裡是一個矩形,但並不是將這個矩形放置到item的間距中,而是矩形的left/right/top/bottom
分別表示item四個方向上需要預留的間距大小(像一個盒子一樣加在item四周)
通過以上我們知道固定頭部實際上就是繪制一個頭部item在RecycleView
上顯示即可.所以我們需要關注的只有onDrawOver
這個方法,另外兩個我們都不需要用到.
繪制的流程大致如下:
主要流程就以上幾個,非常簡單和明確.重點是只在於流程中的某些細節,那就是一個又一個的坑啊…
IStickerHeaderDecoration
繪制固定頭部時,StickHeaderItemDecoration
完成固定頭部的測量繪制,對於固定頭部的獲取/數據綁定等並不能進行處理,實際上該部分操作也不能由其處理,因此定義了一個接口,用於處理相關的固定頭部的數據.
public interface IStickerHeaderDecoration {
//判斷當前位置的item是否為一個header
public boolean isHeaderPosition(int position);
//判斷當前位置的item是否需要一個stick header view
public boolean hasStickHeader(int position);
//獲取指定位置需要顯示的headerView的標志,該標志用於緩存唯一的一個header類型的view.
//不同的headerView應該使用不同的tag,否則會被替換
public int getHeaderViewTag(int position, RecyclerView parent);
//根據header標志或者position獲取需要的headerView
public View getHeaderView(int position, int headerViewTag, RecyclerView parent);
//設置headerView顯示的數據
public void setHeaderView(int position, int headerViewTag, RecyclerView parent, View headerView);
//判斷當前渲染的header是否與上一次渲染的header為同一分組,若是可以不再測量與綁定數據
//lastDecoratedPosition,上一次渲染stickHeader的位置
//nowDecoratingPosition,當前需要渲染stickHeader的位置
public boolean isBeenDecorated(int lastDecoratedPosition, int nowDecoratingPosition);
}
通過方法說明可以看出,主要是根據位置確定相關的頭部信息(包括是否顯示頭部,headerView的加載以及綁定數據),這些操作都由接口實現類去完成.這樣可以保證StickHeaderItemDecoration
所做的操作是獨立的,任何時候只需要更換一個IStickHeaderDecoration
都可以正常處理並繪制固定頭部.
對於以上接口方法,需要部分說明.
hasStickHeader(int)
該方法雖然是判斷當前item是否需要顯示header,但這個說法並不完全准確.
這裡的position其實並不是作為一個item項的位置作為判斷的參考,接口類需要處理時應該是考慮該position位置的item所在的分組是否需要顯示header.
通常來說,參數position是RecycleView
中顯示的第一個childView對應的位置,某些情況下會是第二個childView的位置(後面會解釋為什麼是第二個,這個很重要)
getHeaderViewTag(int,RecyclerView)
該方法為獲取對應位置的headerView的標志tag,這裡的tag主要是用於緩存headerView使用的.通過getHeaderView
方法獲取一個headerView之後,會使用其tag緩存起來,當緩存以後只要再得到的headerView的tag與之匹配就會復用此緩存的headerView.可以理解為headerView的類型
這裡是為了盡可能降低加載布局或者是其它獲取布局所占用的時間和資源(實際上如何得到布局由接口實現類決定的)
isBeenDecorated(int,int)
該方法是用於判斷上一次渲染繪制固定頭部的位置與當前位置是否在同一組(使用同一個固定頭部),若是則不會再去綁定任何數據及測量工作,直接復用上一次的headerView.
若不是則會調用setHeaderView
綁定數據並測量
setHeaderView(int,int,RecycleView,View)
根據以上的說明,此方法並不是一定會被調用的,當isBeenDecorated()
返回false時才會調用此方法(為了盡可能復用已存在的資源)
根據以上的流程說明,下面按流程一步步完成.一些細節或者非重要點會部分忽略.
由於RecycleView
有使用緩存,所以下面將用childView
表示緩存的子view;itemView
表示adapter中對應的位置的某個item顯示的view.
RecycleView
顯示的第一項itemView位置因為固定頭部是用於顯示當前RecycleView
裡顯示的分組的第一項頭部,所以確定繪制的頭部即確定當前RecycleView
顯示內容對應的頭部.
首先,獲取RecycleView
緩存的childView,得到其在adapter中對應的位置後,通過接口IStickHeaderDecoration
進行判斷當前位置的分組是否需要顯示header,繪制header等.
View itemView = null;
//獲取第一項View,注意此處的View不一定是第一項可見的View,可能是被緩存了的View(不可見)
itemView = parent.getChildAt(0);
//獲取對應View的位置
position = parent.getChildAdapterPosition(itemView);
是否需要繪制頭部並不由StickHeaderItemDecoration
決定,上述有提及IStickHeaderDecoration
接口,此接口就是為了提供由外部決定當前位置所在的分組是否需要顯示頭部,通過方法hasStickHeader(int position)
確定.
獲取頭部headerView也是通過接口IStickHeaderDecoration
完成的,通過方法即可獲取到對應的headerView.StickHeaderItemDecoration
對獲取的頭部做了緩存的處理,在不同分組但顯示的頭部headerView相同時,都會復用已有headerView,避免了反復的inflate加載view
這裡雖然獲取了headerView,但是並不能繪制到界面上.因為headerView並沒有經過任何測量的情況下,寬高都是0不可見的.
然後綁定數據並測量headerView後即可繪制出固定頭部了.
//獲取headerView的標志tag
int headerTag = mHeaderHandler.getHeaderViewTag(position, parent);
//獲取頭部tag對應的緩存headerView
View headerView = mViewCacheMap.get(headerTag);
//不存在緩存view時加載headerView
if (headerView == null) {
headerView = mHeaderHandler.getHeaderView(position, headerTag, parent);
//保存到緩存中
mViewCacheMap.put(headerTag, headerView);
}
所有工作中反而是headerView的繪制工作最簡單,通過view本身的方法View.draw(Canvas)
即可將view繪制到指定的canvas上,當然這裡會涉及到繪制的起點原點,這個地方會有其它考慮的事項.
但總的來說,繪制一個固定頭部就以上的操作即可.
//將View繪制到canvas上
headerView.draw(c);
從以上說明可知,繪制一個headerView的流程並不難,也不算復雜,但是以上僅僅只能繪制出固定headerView,需要其更完善地繪制還需要處理很多細節,比如:
正確獲取RecycleView
第一項item的位置 正確計算headerView的寬高大小 正確計算headerView繪制區域以確保不同headerView的替換交互
下面將主要說明以上幾個細節的處理方式.
RecycleView
第一項item的位置需要處理這個細節的原因是,在滑動過程中,被滑動出RecycleView
的item可能還會短暫地存放在緩存中,所以會造成一種現象是:
RecycleView
的第一個childView並不是可見的第一個childView,可見的第一個childView實際上是緩存的第二個childView.
設想一下,當第一個分組已經滑動出RecycleView
顯示的界面了,此時應該顯示的是第二個分組的header了,但實際上並不會,因為在判斷檢測時還是使用的第一個分組的最後一個itemView的位置,因此此時顯示的還是第一個分組的header,這種情況是我們不希望的.
解決方案是:
我們將遍歷所有RecycleView
當前緩存的childView,從中查找到第一個headerView,判斷第一個headerView是否顯示在第一項可見位置,若是則當前第一項的位置position將使用第一個headerView的位置而不是第一項childView的位置
對於一個childView,可以通過下面的方法判斷是否為第一個可見項.
只要其view.getTop()
小於RecycleView
的頂部坐標,同時view.getBottom()
大於RecycleView
的頂部坐標(比較都是頂部坐標),即說明該view在RecycleView
的頂部第一項顯示(即RecycleView
頂部邊界線夾在chidView上下邊界坐標之間).
//RecycleView頂部邊界線
int edgeOfCanSeen = parent.getPaddingTop();
//headerView頂部坐標
int edgeOfStart = firstHeaderView.getTop();
//headerView底部坐標
int edgeOfEnd =firstHeaderView.getBottom();
//recycleView邊界線介於headerView之間即可
return edgeOfStart <= edgeOfCanSeen && edgeOfEnd >edgeOfCanSeen;
注意這裡需要考慮RecycleView
自身的padding
值,實際的頂部邊界線應該是除去paddingTop
的值.
RecycleView
的第一項可見childView根據前面已經有如何判斷某個childView是否為RecycleView
的第一項可見view,所以可以直接遍歷所有的childView來查找(實際上第一個可見childView必定在前1或者2的位置,不會真的遍歷所有的childView)
//獲取緩存的childView總數
int childCount = parent.getChildCount();
//逐一遍歷
for (int i = 0; i < childCount; i++) {
View childView = parent.getChildAt(i);
if (isViewCanSeenAtFirstPosition(childView, parent, isHorizontal)) {
//查找到第一個可見childView時保存其當前的childView位置(後面需要查找此位置之後的headerView)
saveChildPositionToView(i);
return childView;
}
}
//若沒有返回null,除非RecycleView不存在childView,不然正常情況下不會返回null
return null;
獲取到了第一個可見的childView,然後就可以從這個view入手去判斷對應的headerView繪制了.
計算headerView寬高大小包括判斷是否需要進行測量及測量工作
從以上IStickHeaderDecoration
接口中已知,當確定當前渲染的固定頭部是一個新的頭部或者需要重新綁定數據時,才會回調綁定數據並測量.所以存在一個判斷當前位置的固定頭部是否需要綁定數據及測量的方法.
boolean isNeed = true;
//第一次渲染加載,必定進行測量
if (mIsFirstDecoration) {
mIsHorizontal = isHorizontal;
//取消第一次加載標識
mIsFirstDecoration = false;
} else if (mIsHorizontal != isHorizontal) {
//當前處理的布局方向與上一次處理的布局方向不同
//重新測量加載
mIsHorizontal = isHorizontal;
} else if (measureView.getWidth() <= 0 || measureView.getHeight() <= 0) {
//當前view未被測量過
} else {
//被加載過的情況下,不再需要進行綁定數據及測量
//否則返回true進行綁定數據及測量
isNeed = !mHeaderHandler.isBeenDecorated(mLastDecorationPosition, newPosition);
}
//不管如何處理,最終必定會將當前渲染的位置保存起來
mLastDecorationPosition = newPosition;
return isNeed;
需要測量的情況有以下幾種:
第一次加載 切換RecycleView
布局方向 當前headerView還未進行過任何測量工作 接口回調確定當前固定頭部必須重新綁定數據
綁定數據並測量headerView工作
if (isNeedToBindAndMeasureView(headerView, isHorizontal, position)) {
//設置headerView的數據顯示
mHeaderHandler.setHeaderView(position, headerTag, parent, headerView);
/**
* 測量工作必須在這裡處理,因為默認是布局處理的布局是wrap_content,需要設置數據之後再進行測量計算工作
* 否則如果布局中某些view是wrap_content,當不存在數據時該view大小將為0,即無法顯示
* **/
measureHeaderView(parent, headerView, isHorizontal);
}
上面我們已經得到了headerView,但有時會出現某些控件顯示不正常或者是headerView並沒有預想一樣顯示,問題就在沒有對headerView的大小進行計算.
headerView由於是通過接口回調加載出來的,並不能確定該view的大小是否已經計算過(如果是inflate出來的新view則並不會進行measure),因此我們需要對其大小進行計算.計算的方式可以參考所有自定義view的measure方式.
//不存在layoutparams的view添加默認布局參數
if (headerView.getLayoutParams() == null) {
headerView.setLayoutParams(new ViewGroup.LayoutParams(
ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT));
}
int widthSpec;
int heightSpec;
//測量處理
widthSpec = View.MeasureSpec.makeMeasureSpec(parent.getWidth(), View.MeasureSpec.EXACTLY);
heightSpec =View.MeasureSpec.makeMeasureSpec(parent.getHeight(),View.MeasureSpec.UNSPECIFIED);
//測量並計算headerView寬高
int childWidth = ViewGroup.getChildMeasureSpec(widthSpec,
parent.getPaddingLeft() + parent.getPaddingRight(), headerView.getLayoutParams().width);
int childHeight = ViewGroup.getChildMeasureSpec(heightSpec,
parent.getPaddingTop() + parent.getPaddingBottom(), headerView.getLayoutParams().height);
//測量並設置headerView寬高
//這裡是直接將計算得到的寬高提交給view使用
headerView.measure(childWidth, childHeight);
//刷新layout布局
headerView.layout(0, 0,headerView.getMeasuredWidth(),headerView.getMeasuredHeight());
前半部分是設置並計算headerView的寬高模式(match_parent/wrap_content
或者固定值等),後面通過View.measure(widthMeasureSpec,heightMeasureSpec)
通知view進行自身的測量(因為我們並不知道該headerView只是一個普通的view還是一個viewGroup),最後再通過view進行自身的布局layout(原因同上).
通過以上的方式就可以完成headerView的測量工作了.這種情況下就不會出現當headerView中有某些控件是wrap_content
時,沒有填充數據或內容時無法正常顯示該控件(如TextView)
我們知道如果headerView是通過inflate方式加載出來的,那麼默認情況下不具有寬高信息,我們也不可見.通過measureHeaderView
之後可以得到headerView的寬高信息,此時headerView就是可見的了.
但是這裡要注意measure的時機.前面我們提到綁定headerView的數據,所以對於某些控件可能存在wrap_content
的寬高設置,不存在數據時不管怎麼measure都是0,所以應該先綁定數據,再進行headerView的測量工作.
//設置headerView的數據顯示
mHeaderHandler.setHeaderView(position, headerTag, parent, headerView);
/**
* 測量工作必須在這裡處理,因為默認是布局處理的布局是wrap_content,需要設置數據之後再進行測量計算工作
* 否則如果布局中某些view是wrap_content,當不存在數據時該view大小將為0,即無法顯示
* **/
measureHeaderView(parent, headerView,isHorizontal);
並且這裡還需要注意的是,盡管同一個recycleView的數據中固定頭部的布局顯示很可能是一樣的,但是還是需要每一次繪制時進行一次measure,這是因為當切換數據時,永遠不知道下一次綁定的數據會不會影響到界面的顯示(如文字長度不同等)
通過以上的操作,固定頭部是可以正常進行繪制的.但是運行一下會發現,當下一個頭部需要替換上一個頭部的時候,就顯得不正常了.因為這時兩個頭部會疊加在一起,原因是本身RecycleView
正常顯示的頭部在底下,繪制的固定頭部一直保持在上面直接覆蓋上,所以就疊加在一起了.
解決這個問題可以使用網上常見的處理方式,讓下一個頭部將固定頭部給頂上去,最終替換整個固定頭部,使用這種方式會更加平滑和提升用戶體驗.
我們已經知道所謂的固定頭部其實也只是繪制出來的一個靜態界面,現在我們需要將固定頭部頂上去,實際上不可能是像RecycleView
一樣將item滑動上去,所以只能是通過計算繪制重新繪制固定頭部,並且只需要繪制一部分的頭部.
canvas的顯示范圍
首先,肯定是繪制到canvas上,這裡的canvas可以認為是RecycleView
背景,那麼能顯示的范圍最多也是RecycleView
的大小(理論上canvas可以無限大).
指定canvas的繪制區域
canvas是可以通過指定一個繪制區域,使得以該區域作為繪制的范圍,同時該區域的左上角為原點.超過繪制的范圍最終將不可見.
轉換canvas的區域
canvas還可以通過轉換調整繪制的起點位置,從而改變繪制的界面的顯示位置.
//canvas的部分方法
//設置canvas中的繪制區域
canvas.clipRect(Rect);
//調整X/Y軸的偏移量,相當於移動了整個坐標軸
canvas.translate(float x,float y);
根據以上canvas的使用,我們可以確定出如何完全繪制一部分的固定頭部流程了.
計算正常情況下繪制頭部的區域drawRect 計算下一個頭部已經滑動占據了固定頭部的區域位置 更新繪制頭部的區域(占據部分不再繪制) 繪制固定頭部頭部繪制區域是比較容易計算的,根據headerView的寬高大小及RecycleView
的相關坐標數據即可得到需要繪制的區域.
//獲取可開始繪制的位置
int drawLeft = parent.getPaddingLeft();
int drawTop = parent.getPaddingTop();
int drawRight = 0;
int drawBottom = 0;
//豎向布局
//寬填充整個parent
//高根據view處理
drawRight = drawLeft + (parent.getWidth() - parent.getPaddingLeft() - parent.getPaddingRight());
drawBottom = drawTop + headerView.getHeight();
//獲取headerView的layout參數
ViewGroup.LayoutParams params = headerView.getLayoutParams();
//判斷headerView是否存在margin
ViewGroup.MarginLayoutParams marginParams = null;
if (params instanceof ViewGroup.MarginLayoutParams) {
//存在margin時,繪制時的區域需要去除margin的部分
marginParams = (ViewGroup.MarginLayoutParams) params;
drawLeft += marginParams.leftMargin;
drawTop += marginParams.topMargin;
drawRight -= marginParams.rightMargin;
}
//設置繪制的區域
outRect.set(drawLeft, drawTop, drawRight,drawBottom);
以上需要注意要考慮RecycleView
的padding間距,除去padding間距後的才是正常顯示的范圍.
在滑動過程中,下一個頭部會占據一部分頂部固定頭部的位置,這時就需要計算占據的位置是多少,從而達到將固定頭部繪制區域減小的,這樣在滑動時就可以實現下一個頭部將固定頭部頂出界面的效果了.
代碼中用到了childPosition,這裡的childPosition就是在查找第一個可見childView中保存的saveChildPositionToView(int)
.
//childPosition是前面查找到的第一項可見childView,+1是從其後開始查找最近的一個headerView
//這是因為第一項View可能是一個headerView,也可能是某一組分組中的子項,若是上一個分組的子項,則此時需要顯示的還是該分組的headerView,否則需要顯示下一個分組的header
//從第二項開始查找是為了查找最近的一個headerView
//該headerView可能是當前正在替換舊headerView的頭部,也可能是遠未達到頂部的headerView
View headerView =
this.searchFirstHeaderView(childPosition+1, state.getItemCount(), parent);
int offsetX = 0;
int offsetY = 0;
if (headerView != null) {
//當前下一個headerView正處於固定頭部區域內,即此時會占據一部分繪制區域
if (headerView.getTop() < rect.bottom && headerView.getTop() > rect.top) {
//如果查找得到的headerView已經在替換當前的stick headerView
//計算出需要處理的偏移量,否則不處理(即不存在偏移量,返回0)
offsetY = rect.bottom -headerView.getTop();
}
}
//偏移量是負值,因為繪制區域將向上或者向左移動出界面
return new Point(offsetX * -1, offsetY * -1);
需要注意的是,計算後的值應該是負的,因為頂上去的過程中繪制區域是向上偏移,所以偏移量應該是負的.得到的Point
包括了X/Y的軸方向的偏移值.
計算最終繪制時的頭部區域
前面已經計算得到完全顯示的頭部的繪制區域大小,再計算得到偏移量,下面就是把兩個值給結合起來計算出最終繪制頭部時的區域大小了.
//獲取原始區域的寬高
int width = outRect.width();
int height = outRect.height();
//將寬高處理偏移量
width += offset.x;
height += offset.y;
//重新計算其繪制區域(一般為縮小了)
//此處是改變繪制區域的大小而不是調整繪制區域的位置
int newRight = outRect.left + width;
int newBottom = outRect.top + height;
outRect.set(outRect.left, outRect.top, newRight,newBottom);
這裡實際上就是把原本的繪制區域給變小了,變小的部分就是被下一個頭部占據的區域.然後就可以設置到canvas中確定僅在此區域內繪制有效.
//指定canvas的有效繪制區域
canvas.clipRect(outRect);
默認情況下canvas還是從(0,0)的坐標位置進行繪制,但是這裡我們需要注意,雖然固定頭部有一部分繪制區域被下一個頭部占用了,但是我們要的效果應該是下一個頭部把固定頭部頂出界面,所以剩下的固定頭部需要繪制的區域應該是繪制固定頭部的後半部分.
為了繪制後半部分在可見的繪制區域(前面已經設置了canvas的繪制區域),所以需要把canvas的繪制原點向上移動,而移動的距離也剛好是與下一個頭部占用的區域偏移量相同.
可以想像成整個固定頭部向上移動一部分區域繪制,同時只是顯示了後半部分
//計算正常情況下的繪制起點位置
int drawLeft = parent.getPaddingLeft();
int drawTop = parent.getPaddingTop();
outRect.set(drawLeft, drawTop, 0, 0);
//更新偏移量
outRect.offset(offset.x, offset.y);
偏移繪制的原點,將整個繪制原點向上移動,這樣繪制出來的就只會看到後半部分了.不偏移的情況下只能看到前半部分.這裡只是計算出繪制原點需要的偏移量,還需要設置到canvas中才有效.
最後再將固定頭部繪制出來即可.
//調整canvas的繪制起點
canvas.translate(outRect.left,outRect.top);
headerView.draw(canvas);
根據以上的流程和細節處理後,一個完整地固定頭部的繪制就可以完成了.但是實際上還有很多的工作可以做.
以上僅僅只是處理了RecycleView
的方向為vertical
的情況,還有horizontal
的情況,其實是跟豎直方向相似的,只是很多東西是從上下的檢測和計算替換成左右的檢測和計算.這裡不再說明舉例,代碼中已經實現了自動適配豎直與水平的方向兼容.
另外一個是,前面提到只需要是實現了IStickerHeaderDecoration
接口的都可以使用此固定頭部的處理類,同系列與RecycleView
相關的HeaderRecycleAdapter
也已經實現了此接口,所以可以直接使用此類進行固定頭部的裝飾.
建議使用HeaderRecycleAdapter
與此StickHeaderItemDecoration
配合實現固定頭部
事實說明,其實看起來可能有點復雜的功能實現起來並沒有想像中那麼難,但是當需要把他做得更加完美或者兼容大部分的情況甚至是做成一個引用庫時,需要處理的細節就很多了.
希望可以幫到有類似需求的人~~
普通情況
連續多個頭部的情況
https://github.com/CrazyTaro/RecycleViewAdatper
上一篇文章已經介紹了如何為RecyclerView添加FootView,在此基礎上,要添加分頁加載的功能其實已經很簡單了。 上一篇文章地址:為RecyclerView添加
android 迭代開發中陸續遇到各種問題,我們要善於總結,歸類。現在記錄一下這幾個月遇到的問題匯總。1、android fragment中onActivityResul
應用APP內存的使用,也是評價一個應用性能高低的一個重要的指標。所以不管什麼樣的應用,都應該把內存效率,用戶體驗放在首位。由於Android應用的沙箱機制(一種安全機制)
概論NDK全稱是Native Development Kit,NDK提供了一系列的工具,幫助開發者快速開發C(或C++)的動態庫,並能自動將so和java應用一起打包成a