注册

RecyclerView无限循环效果实现与解析

前言


前两天在逛博客的时候发现了这样一张直播间的截图,其中这个商品列表的切换和循环播放效果感觉挺好:


468148e1a65d64dbfd0bff4c248d8028.jpg

熟悉android的同学应该很快能想到这是recyclerView实现的线性列表,其主要有两个效果:


1.顶部item切换后样式放大+转场动画。

2.列表自动、无限循环播放。


第一个效果比较好实现,顶部item布局的变化可以通过对RecyclerView进行OnScroll监听,判断item位置,做scale缩放。或者在自定义layoutManager在做layoutChild相关操作时判断第一个可见的item并修改样式。


自动播放则可以通过使用手势判断+延时任务来做。


本文主要提供关于第二个无限循环播放效果的自定义LayoutManager的实现。


正文


有现成的轮子吗?


先看看有没有合适的轮子,“不要重复造轮子”,除非轮子不满足需求。


1、修改adpter和数据映射实现

google了一下,有关recyclerView无限循环的博客很多,内容基本一模一样。大部分的博客都提到/使用了一种修改adpter以及数据映射的方式,主要有以下几步:


1. 修改adapter的getItemCount()方法,让其返回Integer.MAX_VALUE


2. 在取item的数据时,使用索引为position % list.size


3. 初始化的时候,让recyclerView滑到近似Integer.MAX_VALUE/2的位置,避免用户滑到边界。


在逛stackOverFlow时找到了这种方案的出处:
java - How to cycle through items in Android RecyclerView? - Stack Overflow


这个方法是建立了一个数据和位置的映射关系,因为itemCount无限大,所以用户可以一直滑下去,又因对位置与数据的取余操作,就可以在每经历一个数据的循环后重新开始。看上去RecyclerView就是无限循环的。


很多博客会说这种方法并不好,例如对索引进行了计算/用户可能会滑到边界导致需要再次动态调整到中间之类的。然后自己写了一份自定义layoutManager后觉得用自定义layoutManager的方法更好。


a7cd5e44e29669ddeb0904fa3f39f016.jpg

其实我倒不这么觉得。


事实上,这种方法已经可以很好地满足大部分无限循环的场景,并且由于它依然沿用了LinearLayoutManager。就代表列表依旧可以使用LLM(LinearLayoutManager)封装好的布局和缓存机制。



  1. 首先索引计算这个谈不上是个问题。至于用户滑到边界的情况,也可以做特殊处理调整位置。(另外真的有人会滑约Integer.MAX_VALUE/2大约1073741823个position吗?
  2. 性能上也无需担心。从数字的直觉上,设置这么多item然后初始化scrollToPosition(Integer.MAX_VALUE/2)看上去好像很可怕,性能上可能有问题,会卡顿巴拉巴拉。

实际从初始化到scrollPosition到真正onlayoutChildren系列操作,主要经过了以下几步。


先上一张流程图:


image.png



  • 设置mPendingScrollPosition,确定要滑动的位置,然后requestLayout()请求布局;

/**
* <p>Scroll the RecyclerView to make the position visible.</p>
*
* <p>RecyclerView will scroll the minimum amount that is necessary to make the
* target position visible. If you are looking for a similar behavior to
* {@link android.widget.ListView#setSelection(int)} or
* {@link android.widget.ListView#setSelectionFromTop(int, int)}, use
* {@link #scrollToPositionWithOffset(int, int)}.</p>
*
* <p>Note that scroll position change will not be reflected until the next layout call.</p>
*
* @param position Scroll to this adapter position
* @see #scrollToPositionWithOffset(int, int)
*/



@Override
public void scrollToPosition(int position) {
mPendingScrollPosition = position;//更新position
mPendingScrollPositionOffset = INVALID_OFFSET;
if (mPendingSavedState != null) {
mPendingSavedState.invalidateAnchor();
}
requestLayout();
}


  • 请求布局后会触发recyclerView的dispatchLayout,最终会调用onLayoutChildren进行子View的layout,如官方注释里描述的那样,onLayoutChildren最主要的工作是:确定锚点、layoutState,调用fill填充布局。

onLayoutChildren部分源码:


@Override
public void onLayoutChildren(RecyclerView.Recycler recycler, RecyclerView.State state) {
// layout algorithm:
// 1) by checking children and other variables, find an anchor coordinate and an anchor
// item position.
// 2) fill towards start, stacking from bottom
// 3) fill towards end, stacking from top
// 4) scroll to fulfill requirements like stack from bottom.

//..............
// 省略,前面主要做了一些异常状态的检测、针对焦点的特殊处理、确定锚点对anchorInfo赋值、偏移量计算
int startOffset;
int endOffset;
final int firstLayoutDirection;
if (mAnchorInfo.mLayoutFromEnd) {
// fill towards start
updateLayoutStateToFillStart(mAnchorInfo); //根据mAnchorInfo更新layoutState
mLayoutState.mExtraFillSpace = extraForStart;
fill(recycler, mLayoutState, state, false);//填充
startOffset = mLayoutState.mOffset;
final int firstElement = mLayoutState.mCurrentPosition;
if (mLayoutState.mAvailable > 0) {
extraForEnd += mLayoutState.mAvailable;
}
// fill towards end
updateLayoutStateToFillEnd(mAnchorInfo);//更新layoutState为fill做准备
mLayoutState.mExtraFillSpace = extraForEnd;
mLayoutState.mCurrentPosition += mLayoutState.mItemDirection;
fill(recycler, mLayoutState, state, false);//填充
endOffset = mLayoutState.mOffset;
if (mLayoutState.mAvailable > 0) {
// end could not consume all. add more items towards start
extraForStart = mLayoutState.mAvailable;
updateLayoutStateToFillStart(firstElement, startOffset);//更新layoutState为fill做准备
mLayoutState.mExtraFillSpace = extraForStart;
fill(recycler, mLayoutState, state, false);
startOffset = mLayoutState.mOffset;
}
} else {
//layoutFromStart 同理,省略
}
//try to fix gap , 省略


  • onLayoutChildren中会调用updateAnchorInfoForLayout更新anchoInfo锚点信息,updateLayoutStateToFillStart/End再根据anchorInfo更新layoutState为fill填充做准备。


  • fill的源码:
    `

int fill(RecyclerView.Recycler recycler, LayoutState layoutState,
RecyclerView.State state, boolean stopOnFocusable)
{
// max offset we should set is mFastScroll + available
final int start = layoutState.mAvailable;
if (layoutState.mScrollingOffset != LayoutState.SCROLLING_OFFSET_NaN) {
// TODO ugly bug fix. should not happen
if (layoutState.mAvailable < 0) {
layoutState.mScrollingOffset += layoutState.mAvailable;
}
recycleByLayoutState(recycler, layoutState);
}
int remainingSpace = layoutState.mAvailable + layoutState.mExtraFillSpace;
LayoutChunkResult layoutChunkResult = mLayoutChunkResult;
// (不限制layout个数/还有剩余空间) 并且 有剩余数据
while ((layoutState.mInfinite || remainingSpace > 0) && layoutState.hasMore(state)) {
layoutChunkResult.resetInternal();
if (RecyclerView.VERBOSE_TRACING) {
TraceCompat.beginSection("LLM LayoutChunk");
}
layoutChunk(recycler, state, layoutState, layoutChunkResult);
if (RecyclerView.VERBOSE_TRACING) {
TraceCompat.endSection();
}
if (layoutChunkResult.mFinished) {
break;
}
layoutState.mOffset += layoutChunkResult.mConsumed * layoutState.mLayoutDirection;
/**
* Consume the available space if:
* * layoutChunk did not request to be ignored
* * OR we are laying out scrap children
* * OR we are not doing pre-layout
*/

if (!layoutChunkResult.mIgnoreConsumed || layoutState.mScrapList != null
|| !state.isPreLayout()) {
layoutState.mAvailable -= layoutChunkResult.mConsumed;
// we keep a separate remaining space because mAvailable is important for recycling
remainingSpace -= layoutChunkResult.mConsumed;
}

if (layoutState.mScrollingOffset != LayoutState.SCROLLING_OFFSET_NaN) {
layoutState.mScrollingOffset += layoutChunkResult.mConsumed;
if (layoutState.mAvailable < 0) {
layoutState.mScrollingOffset += layoutState.mAvailable;
}
recycleByLayoutState(recycler, layoutState);//回收子view
}
if (stopOnFocusable && layoutChunkResult.mFocusable) {
break;
}
}
if (DEBUG) {
validateChildOrder();
}
return start - layoutState.mAvailable;

fill主要干了两件事:



  • 循环调用layoutChunk布局子view并计算可用空间
  • 回收那些不在屏幕上的view

所以可以清晰地看到LLM是按需layout、回收子view。


就算创建一个无限大的数据集,再进行滑动,它也是如此。可以写一个修改adapter和数据映射来实现无限循环的例子,验证一下我们的猜测:


//adapter关键代码
@NonNull
@Override
public DemoViewHolder onCreateViewHolder(@NonNull ViewGr0up parent, int viewType) {
LayoutInflater inflater = LayoutInflater.from(parent.getContext());
Log.d("DemoAdapter","onCreateViewHolder");
return new DemoViewHolder(inflater.inflate(R.layout.item_demo, parent, false));
}

@Override
public void onBindViewHolder(@NonNull DemoViewHolder holder, int position) {
Log.d("DemoAdapter","onBindViewHolder: position"+position);
String text = mData.get(position % mData.size());
holder.bind(text);
}

@Override
public int getItemCount() {
return Integer.MAX_VALUE;
}

在代码我们里打印了onCreateViewHolder、onBindViewHolder的情况。我们只要观察这viewHolder的情况,就知道进入界面再滑到Integer.MAX_VALUE/2时会初始化多少item。
`


RecyclerView recyclerView = findViewById(R.id.rv);
recyclerView.setAdapter(new DemoAdapter());
LinearLayoutManager layoutManager = new LinearLayoutManager(this);
layoutManager.setOrientation(RecyclerView.VERTICAL);
recyclerView.setLayoutManager(layoutManager);
recyclerView.scrollToPosition(Integer.MAX_VALUE/2);

初始化后ui效果:


d6b0a1a00f00e0a61451bd151b697c3f.jpg

日志打印:
image.png


可以看到,页面上共有5个item可见,LLM也按需创建、layout了5个item。


2、自定义layoutManager

找了找网上自定义layoutManager去实现列表循环的博客和代码,拷贝和复制的很多,找不到源头是哪一篇,这里就不贴链接了。大家都是先说第一种修改adapter的方式不好,然后甩了一份自定义layoutManager的代码。


然而自定义layoutManager难点和坑都很多,很容易不小心就踩到,一些博客的代码也有类似问题。
基本的一些坑点在张旭童大佬的博客中有提及,
【Android】掌握自定义LayoutManager(一) 系列开篇 常见误区、问题、注意事项,常用API。


比较常见的问题是:



  1. 不计算可用空间和子view消费的空间,layout出所有的子view。相当于抛弃了子view的复用机制
  2. 没有合理利用recyclerView的回收机制
  3. 没有支持一些常用但比较重要的api的实现,如前面提到的scrollToPosition。

其实最理想的办法是继承LinearLayoutManager然后修改,但由于LinearLayoutManager内部封装的原因,不方便像GridLayoutManager那样去继承LinearLayoutManager然后进行扩展(主要是包外的子类会拿不到layoutState等)。


要实现一个线性布局的layoutManager,最重要的就是实现一个类似LLM的fill(前面有提到过源码,可以翻回去看看)和layoutChunk方法。


(当然,可以照着LLM写一个丐版,本文就是这么做的。)


fill方法很重要,就如同官方注释里所说的,它是一个magic func。


从OnLayoutChildren到触发scroll滑动,都是调用fill来实现布局。


/**
* The magic functions :). Fills the given layout, defined by the layoutState. This is fairly
* independent from the rest of the {@link LinearLayoutManager}
* and with little change, can be made publicly available as a helper class.
*/

int fill(RecyclerView.Recycler recycler, LayoutState layoutState,
RecyclerView.State state, boolean stopOnFocusable)
{

前面提到过fill主要干了两件事:



  • 循环调用layoutChunk布局子view并计算可用空间
  • 回收那些不在屏幕上的view

而负责子view布局的layoutChunk则和把一个大象放进冰箱一样,主要分三步走:



  1. add子view
  2. measure
  3. layout 并计算消费了多少空间

60da6c7fb7cc8b70c9c947a73d410884.jpg

就像下面这样:


/**
* layout具体子view
*/

private void layoutChunk(RecyclerView.Recycler recycler, RecyclerView.State state,
LayoutState layoutState, LayoutChunkResult result)
{
View view = layoutState.next(recycler, state);
if (view == null) {
result.mFinished = true;
return;
}
RecyclerView.LayoutParams params = (RecyclerView.LayoutParams) view.getLayoutParams();

// add
if (layoutState.mLayoutDirection != LayoutState.LAYOUT_START) {
addView(view);
} else {
addView(view, 0);
}

Rect insets = new Rect();
calculateItemDecorationsForChild(view, insets);

// 测量
measureChildWithMargins(view, 0, 0);

//布局
layoutChild(view, result, params, layoutState, state);

// Consume the available space if the view is not removed OR changed
if (params.isItemRemoved() || params.isItemChanged()) {
result.mIgnoreConsumed = true;
}
result.mFocusable = view.hasFocusable();
}

那最关键的如何实现循环呢??


其实和修改adapter的实现方法有异曲同工之妙,本质都是修改位置与数据的映射关系。


修改layoutStae的方法:


    boolean hasMore(RecyclerView.State state) {
return Math.abs(mCurrentPosition) <= state.getItemCount();
}


View next(RecyclerView.Recycler recycler, RecyclerView.State state) {
int itemCount = state.getItemCount();
mCurrentPosition = mCurrentPosition % itemCount;
if (mCurrentPosition < 0) {
mCurrentPosition += itemCount;
}
final View view = recycler.getViewForPosition(mCurrentPosition);
mCurrentPosition += mItemDirection;
return view;
}

}

最终效果:


8a39f62e6eebdefc8e260af63a2c51b4.jpg

源码地址:aFlyFatPig/cycleLayoutManager (github.com)


注:也可以直接引用依赖使用,详见readme.md。


后记


本文介绍了recyclerview无限循环效果的两种不同实现方法与解析。


虽然自定义layoutManager坑点很多并且很少用的到,但了解下也会对recyclerView有更深的理解。


作者:紫槐
来源:juejin.cn/post/7215200495983214629

0 个评论

要回复文章请先登录注册