https://blog.csdn.net/ccy0122/article/details/90515386
看到这篇文章指出的很多博文中自定义 LayoutManager 误区部分,一定是一篇好文。另外随着 RecyclerView 流行程度,自定义 LayoutManager 逐渐成为一项基本技能了,可以抽空设置到自己的 TODO LIST 中去。
效果以及使用
可自己监听滚动编写效果,如修改成仿MacOS文件浏览:
focusLayoutManager =
new
FocusLayoutManager.Builder
.layerPadding(dp2px(
this
,
14
))
.normalViewGap(dp2px(
this
,
14
))
.focusOrientation(FocusLayoutManager.FOCUS_LEFT)
.isAutoSelect(
true
)
.maxLayerCount(
3
)
.setOnFocusChangeListener(
new
FocusLayoutManager.OnFocusChangeListener {
@Override
public
void
onFocusChanged
(
int
focusdPosition,
int
lastFocusdPosition)
{
.build;
recyclerView.setLayoutManager(focusLayoutManager);
注意:因为item在不同区域随着滑动会有不同的缩放,所以实际layerPadding、normalViewGap会被缩放计算。
自定义LayoutManager基础知识
这个项目就我学习LayoutManager的实战项目。(断断续续学习过很多次,还是得实际编码才能掌握)
推荐几篇我觉得好的自定义LayoutManager文章:
1、 张旭童的掌握自定义LayoutManager(一) 系列开篇 常见误区、问题、注意事项,常用API
https://blog.csdn.net/zxt0601/article/details/52948009
2、张旭童的掌握自定义LayoutManager(二) 实现流式布局
https://blog.csdn.net/zxt0601/article/details/52956504
3、陈小缘的自定义LayoutManager第十一式之飞龙在天
https://blog.csdn.net/u011387817/article/details/81875021
自定义LayoutManager注意事项
上面张旭童的文章里有指出很多自定义LayoutManager的误区、注意事项,我补充几点:
1、不要遍历ItemCount
这个真的,是我认为最关键的一个注意事项。
getItemCount获取到的是什么?
是列表的总item数量,它可能有几千条几万条,甚至某些情况使用者会特意重写getItemCount将其返回为Integer.MAX_VALUE(比如为了实现无限循环轮播)。
你之所以自定义LayoutManager而不自定义ViewGroup,就是为了不管itemCount多少你都能hold住。所以你不应该在布局相关代码中遍历ItemCount!!
诚然,遍历它往往可以获取很多有用的数据,对后续的布局的计算、子View是否在屏幕内等判断非常有用,但请尽量不要遍历它(除非你的LM够特殊)。
张旭童说的没错,很多文章都存在误导,我还看到过有篇”喜欢“数很多的文章里有类似这么一段代码:
for
(
int
i =
0
; i < getItemCount; i++) {
View view = recycler.getViewForPosition(i);
addView(view);
......
对于初次布局,这不就是有多少item就onCreateViewHolder多少次了么。
缓存池总数 = item总数?之后的回收复用操作也没意义了。
2、注意调用getChildCount时机
在列表滚动时,一般都要判断子View是否还在屏幕内,若不在了则回收。那么获取子View的逻辑应该在detachAndScrapAttachedViews(or detachAndScrapView等)之前。
//分离全部的view,放入临时缓存
log
(
"before。child count = "
+ getChildCount +
";scrap count = "
+ recycler.getScrapList.size);
detachAndScrapAttachedViews(recycler);
log
(
"after。child count = "
+ getChildCount +
";scrap count = "
+ recycler.getScrapList.size);
//打印结果:
//before。child count = 5;scrap count = 0
//after。child count = 0;scrap count = 5
另外,不用多说,recycler.getViewForPosition应在detachAndScrapAttachedViews之后
3、回收子View小技巧
这是在陈小缘那篇文章里学到的:
可以直接把Recycler里面的mAttachedScrap全部放进mRecyclerPool中,因为我们在一开始就已经调用了detachAndScrapAttachedViews方法将当前屏幕中有效的ViewHolder全部放进了mAttachedScrap,而在重新布局的时候,有用的Holder已经被重用了,也就是拿出去了,这个mAttachedScrap中剩下的Holder,都是不需要layout的,所以可以把它们都回收进mRecyclerPool中。
(不知道对预布局是否有影响,但我代码中并没有判断过isPreLayout,也测试过notifyItemRemoved,动画正常)
首先无视掉view的缩放、透明度变化。那么布局其实就这样:
我们称一个view从”普通view“滚动到”焦点view“为一次完整的聚焦滑动所需要移动的距离,定义其为onceCompleteScrollLength。
在普通view移动了一个onceCompleteScrollLength,堆叠View只移动了一个layerPadding
。核心逻辑就这一句。
我们在scrollHorizontallyBy中记录偏移量dx,保存一个累计偏移量mHorizontalOffset,然后用该偏移量除以onceCompleteScrollLength,就知道当前已经滚动了多少个item了,换句话说就是屏幕内第一个可见view的position知道了。
同时能计算出一个onceCompleteScrollLength已经滚动了的百分比fraction,再用这个百分比换算出堆叠区域和普通区域布局起始位置的偏移量,然后可以开始布局了,对于堆叠区域的view,彼此之间距离一个layerPadding,对于普通区域view,彼此之间距离一个onceCompleteScrollLength。
@Override
public
int
scrollHorizontallyBy
(
int
dx, RecyclerView.Recycler recycler,
RecyclerView.State state){
//手指从右向左滑动,dx > 0; 手指从左向右滑动,dx < 0;
//位移0、没有子View 当然不移动
if
(dx ==
0
|| getChildCount ==
0
) {
return
0
;
mHorizontalOffset += dx;
//累加实际滑动距离
dx = fill(recycler, state, dx);
return
dx;
*
@param
recycler
*
@param
state
*
@param
delta
private
int
fill
(RecyclerView.Recycler recycler, RecyclerView.State state,
int
delta)
{
int
resultDelta = delta;
resultDelta = fillHorizontalLeft(recycler, state, delta);
return
resultDelta;
* 水平滚动、向左堆叠布局
*
@param
recycler
*
@param
state
*
@param
dx 偏移量。手指从右向左滑动,dx > 0; 手指从左向右滑动,dx < 0;
private
int
fillHorizontalLeft
(RecyclerView.Recycler recycler, RecyclerView.State state,
int
dx){
//----------------1、边界检测-----------------
if
(dx <
0
) {
//已达左边界
if
(mHorizontalOffset <
0
) {
mHorizontalOffset = dx =
0
;
if
(dx >
0
) {
//滑动到只剩堆叠view,没有普通view了,说明已经到达右边界了
if
(mLastVisiPos - mFirstVisiPos <= maxLayerCount -
1
) {
//因为scrollHorizontallyBy里加了一次dx,现在减回去
mHorizontalOffset -= dx;
dx =
0
;
//分离全部的view,放入临时缓存
detachAndScrapAttachedViews(recycler);
//----------------2、初始化布局数据-----------------
float
startX = getPaddingLeft - layerPadding;
View tempView =
null
;
int
tempPosition = -
1
;
if
(onceCompleteScrollLength == -
1
) {
//因为mFirstVisiPos在下面可能会被改变,所以用tempPosition暂存一下。
tempPosition = mFirstVisiPos;
tempView = recycler.getViewForPosition(tempPosition);
measureChildWithMargins(tempView,
0
,
0
);
onceCompleteScrollLength = getDecoratedMeasurementHorizontal(tempView) + normalViewGap;
//当前"一次完整的聚焦滑动"所在的进度百分比.百分比增加方向为向着堆叠移动的方向(即如果为FOCUS_LEFT,从右向左移动fraction将从0%到100%)
float
fraction =
(Math.abs(mHorizontalOffset) % onceCompleteScrollLength) / (onceCompleteScrollLength *
1.0f
);
//堆叠区域view偏移量。在一次完整的聚焦滑动期间,其总偏移量是一个layerPadding的距离
float
layerViewOffset = layerPadding * fraction;
//普通区域view偏移量。在一次完整的聚焦滑动期间,其总位移量是一个onceCompleteScrollLength
float
normalViewOffset = onceCompleteScrollLength * fraction;
boolean
isLayerViewOffsetSetted =
false
;
boolean
isNormalViewOffsetSetted =
false
;
//修正第一个可见的view:mFirstVisiPos。已经滑动了多少个完整的onceCompleteScrollLength就代表滑动了多少个item
mFirstVisiPos = (
int
) Math.floor(Math.abs(mHorizontalOffset) / onceCompleteScrollLength);
//向下取整
//临时将mLastVisiPos赋值为getItemCount - 1,放心,下面遍历时会判断view是否已溢出屏幕,并及时修正该值并结束布局
mLastVisiPos = getItemCount -
1
;
//...省略监听回调
//----------------3、开始布局-----------------
for
(
int
i = mFirstVisiPos; i <= mLastVisiPos; i++) {
//属于堆叠区域
if
(i - mFirstVisiPos < maxLayerCount) {
View item;
if
(i == tempPosition && tempView !=
null
) {
//如果初始化数据时已经取了一个临时view,可别浪费了!
item = tempView;
}
else
{
item = recycler.getViewForPosition(i);
addView(item);
measureChildWithMargins(item,
0
,
0
);
startX += layerPadding;
if
(!isLayerViewOffsetSetted) {
startX -= layerViewOffset;
isLayerViewOffsetSetted =
true
;
//...省略监听回调
int
l, t, r, b;
l = (
int
) startX;
t = getPaddingTop;
r = (
int
) (startX + getDecoratedMeasurementHorizontal(item));
b = getPaddingTop + getDecoratedMeasurementVertical(item);
layoutDecoratedWithMargins(item, l, t, r, b);
}
else
{
//属于普通区域
View item = recycler.getViewForPosition(i);
addView(item);
measureChildWithMargins(item,
0
,
0
);
startX += onceCompleteScrollLength;
if
(!isNormalViewOffsetSetted) {
startX += layerViewOffset;
startX -= normalViewOffset;
isNormalViewOffsetSetted =
true
;
//...省略监听回调
int
l, t, r, b;
l = (
int
) startX;
t = getPaddingTop;
r = (
int
) (startX + getDecoratedMeasurementHorizontal(item));
b = getPaddingTop + getDecoratedMeasurementVertical(item);
layoutDecoratedWithMargins(item, l, t, r, b);
//判断下一个view的布局位置是不是已经超出屏幕了,若超出,修正mLastVisiPos并跳出遍历
if
(startX + onceCompleteScrollLength > getWidth - getPaddingRight) {
mLastVisiPos = i;
break
;
return
dx;
因为measure、layout调用的都是考虑了margin的api,所以布局时也要考虑到margin:
* 获取某个childView在水平方向所占的空间,将margin考虑进去
* @param view
* @return
public
int
getDecoratedMeasurementHorizontal
(
View view
)
{
final RecyclerView.LayoutParams
params
= (RecyclerView.LayoutParams)
view.getLayoutParams;
return
getDecoratedMeasuredWidth(view) +
params
.leftMargin
+
params
.rightMargin;
* 获取某个childView在竖直方向所占的空间,将margin考虑进去
* @param view
* @return
public
int
getDecoratedMeasurementVertical
(
View view
)
{
final RecyclerView.LayoutParams
params
= (RecyclerView.LayoutParams)
view.getLayoutParams;
return
getDecoratedMeasuredHeight(view) +
params
.topMargin
+
params
.bottomMargin;
* @param recycler
* @param state
* @param delta
private
int
fill
(RecyclerView.Recycler recycler, RecyclerView.State state,
int
delta)
{
int
resultDelta = delta;
//。。。省略
recycleChildren(recycler);
log
(
"childCount= ["
+ getChildCount +
"]"
+
",[recycler.getScrapList.size:"
+ recycler.getScrapList.size);
return
resultDelta;
* 回收需回收的Item。
private
void
recycleChildren
(RecyclerView.Recycler recycler)
{
List<RecyclerView.ViewHolder> scrapList = recycler.getScrapList;
for
(
int
i =
0
; i < scrapList.size; i++) {
RecyclerView.ViewHolder holder = scrapList.get(i);
removeAndRecycleView(holder.itemView, recycler);
张旭童:
通过getChildCount和recycler.getScrapList.size 查看当前屏幕上的Item数量 和 scrapCache缓存区域的Item数量,合格的LayoutManager,childCount数量不应大于屏幕上显示的Item数量,而scrapCache缓存区域的Item数量应该是0.
编写log并打印:
childCount= [5],[recycler.getScrapList.size:0
childCount= [6],[recycler.getScrapList.size:0
childCount= [6],[recycler.getScrapList.size:0
childCount= [6],[recycler.getScrapList.size:0
childCount= [6],[recycler.getScrapList.size:0
childCount= [6],[recycler.getScrapList.size:0
childCount= [6],[recycler.getScrapList.size:0
childCount= [5],[recycler.getScrapList.size:0
childCount= [6],[recycler.getScrapList.size:0
childCount= [6],[recycler.getScrapList.size:0
childCount= [6],[recycler.getScrapList.size:0
childCount= [6],[recycler.getScrapList.size:0
childCount= [6],[recycler.getScrapList.size:0
用最直接的方法,打印onCreateViewHolder、onBindViewHolder看看到底复用了没:
@NonNull
@Override
public
ViewHolder
onCreateViewHolder
(@NonNull ViewGroup viewGroup,
int
viewType)
{
View view = LayoutInflater.from(viewGroup.getContext).inflate(R.layout.item_card,
viewGroup,
false
);
view.setTag(++index);
Log.d(
"ccy"
,
"onCreateViewHolder = "
+ index);
return
new
ViewHolder(view);
@Override
public
void
onBindViewHolder
(@NonNull ViewHolder viewHolder,
int
position)
{
Log.d(
"ccy"
,
"onBindViewHolder,index = "
+ (
int
) (viewHolder.itemView.getTag));
在onCreateViewHolder创建view时,给他一个tag,然后onBindViewHolder中打印这个tag,以此查看是不用复用了view。打印如下
onCreateViewHolder
=
1
on
BindViewHolder,index =
1
on
CreateViewHolder =
2
on
BindViewHolder,index =
2
on
CreateViewHolder =
3
on
BindViewHolder,index =
3
on
CreateViewHolder =
4
on
BindViewHolder,index =
4
on
CreateViewHolder =
5
on
BindViewHolder,index =
5
on
CreateViewHolder =
6
on
BindViewHolder,index =
6
on
CreateViewHolder =
7
on
BindViewHolder,index =
7
on
CreateViewHolder =
8
on
BindViewHolder,index =
8
on
BindViewHolder,index =
1
on
BindViewHolder,index =
2
on
BindViewHolder,index =
3
on
BindViewHolder,index =
4
on
BindViewHolder,index =
5
on
BindViewHolder,index =
6
on
BindViewHolder,index =
7
on
BindViewHolder,index =
8
on
CreateViewHolder =
9
on
BindViewHolder,index =
9
on
BindViewHolder,index =
2
on
BindViewHolder,index =
3
on
BindViewHolder,index =
1
on
BindViewHolder,index =
4
on
BindViewHolder,index =
5
on
BindViewHolder,index =
6
我测试时手机一屏内最多可见约6个,从打印中可见它最多调用了9次onCreateViewHolder,这个次数完全可以接受。并且onBindViewHolder也在复用view。完全ojbk没得问题
我做的动画,就是在滑动期间渐变view的缩放比例、透明度,使得view看上去像一层一层堆叠上去的样子。其实就是各种y = kx + b之类的计算,因为fill系列方法中已经计算出很多有用的数据了。
我的做法是,暴露出这么个接口:
* 滚动过程中view的变换监听接口。属于高级定制,暴露了很多关键布局数据。若定制要求不高,考虑使用{
@link
SimpleTrasitionListener}
public
interface
TrasitionListener
{
* 处理在堆叠里的view。
*
@param
focusLayoutManager
*
@param
view view对象。请仅在方法体范围内对view做操作,不要外部强引用它,view是要被回收复用的
*
@param
viewLayer 当前层级,0表示底层,maxLayerCount-1表示顶层
*
@param
maxLayerCount 最大层级
*
@param
position item所在的position
*
@param
fraction "一次完整的聚焦滑动"所在的进度百分比.百分比增加方向为向着堆叠移动的方向(即如果为FOCUS_LEFT
* ,从右向左移动fraction将从0%到100%)
*
@param
offset 当次滑动偏移量
void
handleLayerView
(FocusLayoutManager focusLayoutManager, View view,
int
viewLayer,
int
maxLayerCount,
int
position,
float
fraction,
float
offset);
* 处理正聚焦的那个View(即正处在从普通位置滚向聚焦位置时的那个view,即堆叠顶层view)
*
@param
focusLayoutManager
*
@param
view view对象。请仅在方法体范围内对view做操作,不要外部强引用它,view是要被回收复用的
*
@param
position item所在的position
*
@param
fraction "一次完整的聚焦滑动"所在的进度百分比.百分比增加方向为向着堆叠移动的方向(即如果为FOCUS_LEFT
* ,从右向左移动fraction将从0%到100%)
*
@param
offset 当次滑动偏移量
void
handleFocusingView
(FocusLayoutManager focusLayoutManager, View view,
int
position,
float
fraction,
float
offset);
* 处理不在堆叠里的普通view(正在聚焦的那个view除外)
*
@param
focusLayoutManager
*
@param
view view对象。请仅在方法体范围内对view做操作,不要外部强引用它,view是要被回收复用的
*
@param
position item所在的position
*
@param
fraction "一次完整的聚焦滑动"所在的进度百分比.百分比增加方向为向着堆叠移动的方向(即如果为FOCUS_LEFT
* ,从右向左移动fraction将从0%到100%)
*
@param
offset 当次滑动偏移量
void
handleNormalView
(FocusLayoutManager focusLayoutManager, View view,
int
position,
float
fraction,
float
offset);
然后在fill系列方法的对应位置回调该接口即可:
* 变换监听接口。
private
List
<TrasitionListener> trasitionListeners;
* 水平滚动、向左堆叠布局
*
@param
recycler
*
@param
state
*
@param
dx 偏移量。手指从右向左滑动,dx > 0; 手指从左向右滑动,dx < 0;
private
int
fillHorizontalLeft
(RecyclerView.Recycler recycler, RecyclerView.State state,
int
dx){
//省略。。。。。
//----------------3、开始布局-----------------
for
(
int
i = mFirstVisiPos; i <= mLastVisiPos; i++) {
//属于堆叠区域
if
(i - mFirstVisiPos < maxLayerCount) {
//省略。。。。。
if
(trasitionListeners !=
null
&& !trasitionListeners.isEmpty) {
for
(TrasitionListener trasitionListener : trasitionListeners) {
trasitionListener.handleLayerView(
this
, item, i - mFirstVisiPos,
maxLayerCount, i, fraction, dx);
}
else
{
//属于普通区域
//省略。。。。。
if
(trasitionListeners !=
null
&& !trasitionListeners.isEmpty) {
for
(TrasitionListener trasitionListener : trasitionListeners) {
if
(i - mFirstVisiPos == maxLayerCount) {
trasitionListener.handleFocusingView(
this
, item, i, fraction, dx);
}
else
{
trasitionListener.handleNormalView(
this
, item, i, fraction, dx);
return
dx;
然后使用者可以自己注册该接口,天马行空。
那么我这个项目默认的动画具体实现是怎么样的呢?
@Override
public
void
handleLayerView
(FocusLayoutManager focusLayoutManager, View view,
int
viewLayer,
int
maxLayerCount,
int
position,
float
fraction,
float
offset){
* 期望效果:从0%开始到{
@link
SimpleTrasitionListener#getLayerChangeRangePercent} 期间
* view均匀完成渐变,之后一直保持不变
//转换为真实的渐变变化百分比
float
realFraction;
if
(fraction <= stl.getLayerChangeRangePercent) {
realFraction = fraction / stl.getLayerChangeRangePercent;
}
else
{
realFraction =
1.0f
;
float
minScale = stl.getLayerViewMinScale(maxLayerCount);
float
maxScale = stl.getLayerViewMaxScale(maxLayerCount);
float
scaleDelta = maxScale - minScale;
//总缩放差
float
currentLayerMaxScale =
minScale + scaleDelta * (viewLayer +
1
) / (maxLayerCount *
1.0f
);
float
currentLayerMinScale = minScale + scaleDelta * viewLayer / (maxLayerCount *
1.0f
);
float
realScale =
currentLayerMaxScale - (currentLayerMaxScale - currentLayerMinScale) * realFraction;
float
minAlpha = stl.getLayerViewMinAlpha(maxLayerCount);
float
maxAlpha = stl.getLayerViewMaxAlpha(maxLayerCount);
float
alphaDelta = maxAlpha - minAlpha;
//总透明度差
float
currentLayerMaxAlpha =
minAlpha + alphaDelta * (viewLayer +
1
) / (maxLayerCount *
1.0f
);
float
currentLayerMinAlpha = minAlpha + alphaDelta * viewLayer / (maxLayerCount *
1.0f
);
float
realAlpha =
currentLayerMaxAlpha - (currentLayerMaxAlpha - currentLayerMinAlpha) * realFraction;
// log("layer =" + viewLayer + ";alpha = " + realAlpha + ";fraction = " + fraction);
view.setScaleX(realScale);
view.setScaleY(realScale);
view.setAlpha(realAlpha);
哈哈哈。代码中stl 存储着堆叠区域view、焦点view、普通view的最大和最小缩放比、透明度,然后利用fraction计算出当前位置真实的缩放比、透明度设置之。
上面只贴了堆叠区域view的实现,完整实现见源码中的TrasitionListenerConvert
1、滚动停止后自动选中
我的实现方式是这样的:监听onScrollStateChanged,在滚动停止时计算出应当停留的position,再计算出停留时的mHorizontalOffset值,播放属性动画将当前mHorizontalOffset不断更新至最终值即可。
具体代码参考源码中的onScrollStateChanged和smoothScrollToPosition。
(思考:能通过自定义SnapHelper实现么?)
2、点击非焦点view自动将其选中为焦点view
已经实现了setFocusdPosition方法。内部逻辑就是计算出实际position并调用smoothScrollToPosition或scrollToPosition 。
示例代码:
public
ViewHolder
(@NonNull
final
View itemView)
{
super
(itemView);
itemView.setOnClickListener(
new
View.OnClickListener {
@Override
public
void
onClick
(View v)
{
int
pos = getAdapterPosition;
if
(pos == focusLayoutManager.getFocusdPosition) {
//是焦点view
}
else
{
focusLayoutManager.setFocusdPosition(pos,
true
);
无限循环滚动
因为FocusLayoutManager内部没有遍历itemCount这种bad操作,你可以自己通过重写getItemCount返回Integer.MAX_VALUE实现伪无限循环。
示例代码:
public
void
initView
{
recyclerView.post(
new
Runnable {
@Override
public
void
run
{
focusLayoutManager.scrollToPosition(
1000
);
//差不多大行了,毕竟mHorizontalOffset是会一直累加的
public
class
Adapter
extends
RecyclerView
.
Adapter
<
Adapter
.
ViewHolder
>
{
@Override
public
void
onBindViewHolder
(@NonNull ViewHolder viewHolder,
int
position)
{
int
realPosition = position % datas.size;
Bean bean = datas.get(realPosition);
//...
@Override
public
int
getItemCount
{
return
Integer.MAX_VALUE;
让开头(堆叠数-1)个View可见
按目前布局逻辑,开头的position = 0 到position = maxLayerCount - 1个view永远只能在堆叠区域,没法拉出来到焦点view。解决方式也简单,给你的源数据开头插入maxLayerCount - 1个假数据,然后当adapter中识别到假数据时让其布局不可见即可
剩下的三个堆叠方向的实现就是加加减减的变化,不用贴出来了。
给个赞呗~
给个star呗~
https://github.com/CCY0122/FocusLayoutManager
最后推荐一下我做的网站,玩Android:
wanandroid.com
,包含详尽的知识体系、好用的工具,还有本公众号文章合集,欢迎体验和收藏!
返回搜狐,查看更多
责任编辑:
平台声明:该文观点仅代表作者本人,搜狐号系信息发布平台,搜狐仅提供信息存储空间服务。