添加链接
link管理
链接快照平台
  • 输入网页链接,自动生成快照
  • 标签化管理网页链接

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 ,包含详尽的知识体系、好用的工具,还有本公众号文章合集,欢迎体验和收藏! 返回搜狐,查看更多

责任编辑:

平台声明:该文观点仅代表作者本人,搜狐号系信息发布平台,搜狐仅提供信息存储空间服务。