RecyclerView無限循環(huán)效果實(shí)現(xiàn)及示例解析
前言
前兩天在逛博客的時(shí)候發(fā)現(xiàn)了這樣一張直播間的截圖,其中這個(gè)商品列表的切換和循環(huán)播放效果感覺挺好:

熟悉android的同學(xué)應(yīng)該很快能想到這是recyclerView實(shí)現(xiàn)的線性列表,其主要有兩個(gè)效果:
1.頂部item切換后樣式放大+轉(zhuǎn)場動(dòng)畫。2.列表自動(dòng)、無限循環(huán)播放。
第一個(gè)效果比較好實(shí)現(xiàn),頂部item布局的變化可以通過對RecyclerView進(jìn)行OnScroll監(jiān)聽,判斷item位置,做scale縮放。或者在自定義layoutManager在做layoutChild相關(guān)操作時(shí)判斷第一個(gè)可見的item并修改樣式。
自動(dòng)播放則可以通過使用手勢判斷+延時(shí)任務(wù)來做。
本文主要提供關(guān)于第二個(gè)無限循環(huán)播放效果的自定義LayoutManager的實(shí)現(xiàn)。
有現(xiàn)成的輪子嗎?
先看看有沒有合適的輪子,“不要重復(fù)造輪子”,除非輪子不滿足需求。
1、修改adpter和數(shù)據(jù)映射實(shí)現(xiàn)
google了一下,有關(guān)recyclerView無限循環(huán)的博客很多,內(nèi)容基本一模一樣。大部分的博客都提到/使用了一種修改adpter以及數(shù)據(jù)映射的方式,主要有以下幾步:
1. 修改adapter的getItemCount()方法,讓其返回Integer.MAX_VALUE
2. 在取item的數(shù)據(jù)時(shí),使用索引為position % list.size
3. 初始化的時(shí)候,讓recyclerView滑到近似Integer.MAX_VALUE/2的位置,避免用戶滑到邊界。
在逛stackOverFlow時(shí)找到了這種方案的出處: java - How to cycle through items in Android RecyclerView? - Stack Overflow
這個(gè)方法是建立了一個(gè)數(shù)據(jù)和位置的映射關(guān)系,因?yàn)閕temCount無限大,所以用戶可以一直滑下去,又因?qū)ξ恢门c數(shù)據(jù)的取余操作,就可以在每經(jīng)歷一個(gè)數(shù)據(jù)的循環(huán)后重新開始??瓷先ecyclerView就是無限循環(huán)的。
很多博客會(huì)說這種方法并不好,例如對索引進(jìn)行了計(jì)算/用戶可能會(huì)滑到邊界導(dǎo)致需要再次動(dòng)態(tài)調(diào)整到中間之類的。然后自己寫了一份自定義layoutManager后覺得用自定義layoutManager的方法更好。
其實(shí)我倒不這么覺得。
事實(shí)上,這種方法已經(jīng)可以很好地滿足大部分無限循環(huán)的場景,并且由于它依然沿用了LinearLayoutManager。就代表列表依舊可以使用LLM(LinearLayoutManager)封裝好的布局和緩存機(jī)制。
- 首先索引計(jì)算這個(gè)談不上是個(gè)問題。至于用戶滑到邊界的情況,也可以做特殊處理調(diào)整位置。(另外真的有人會(huì)滑約Integer.MAX_VALUE/2大約1073741823個(gè)position嗎?
- 性能上也無需擔(dān)心。從數(shù)字的直覺上,設(shè)置這么多item然后初始化scrollToPosition(Integer.MAX_VALUE/2)看上去好像很可怕,性能上可能有問題,會(huì)卡頓巴拉巴拉。
實(shí)際從初始化到scrollPosition到真正onlayoutChildren系列操作,主要經(jīng)過了以下幾步。
先上一張流程圖:

- 設(shè)置mPendingScrollPosition,確定要滑動(dòng)的位置,然后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();
}
- 請求布局后會(huì)觸發(fā)recyclerView的dispatchLayout,最終會(huì)調(diào)用onLayoutChildren進(jìn)行子View的layout,如官方注釋里描述的那樣,onLayoutChildren最主要的工作是:確定錨點(diǎn)、layoutState,調(diào)用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.
//..............
// 省略,前面主要做了一些異常狀態(tài)的檢測、針對焦點(diǎn)的特殊處理、確定錨點(diǎn)對anchorInfo賦值、偏移量計(jì)算
int startOffset;
int endOffset;
final int firstLayoutDirection;
if (mAnchorInfo.mLayoutFromEnd) {
// fill towards start
updateLayoutStateToFillStart(mAnchorInfo); //根據(jù)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做準(zhǔn)備
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做準(zhǔn)備
mLayoutState.mExtraFillSpace = extraForStart;
fill(recycler, mLayoutState, state, false);
startOffset = mLayoutState.mOffset;
}
} else {
//layoutFromStart 同理,省略
}
//try to fix gap , 省略
- onLayoutChildren中會(huì)調(diào)用updateAnchorInfoForLayout更新anchoInfo錨點(diǎn)信息,updateLayoutStateToFillStart/End再根據(jù)anchorInfo更新layoutState為fill填充做準(zhǔn)備。
- 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個(gè)數(shù)/還有剩余空間) 并且 有剩余數(shù)據(jù)
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主要干了兩件事:
- 循環(huán)調(diào)用layoutChunk布局子view并計(jì)算可用空間
- 回收那些不在屏幕上的view
所以可以清晰地看到LLM是按需layout、回收子view。
就算創(chuàng)建一個(gè)無限大的數(shù)據(jù)集,再進(jìn)行滑動(dòng),它也是如此??梢詫懸粋€(gè)修改adapter和數(shù)據(jù)映射來實(shí)現(xiàn)無限循環(huán)的例子,驗(yàn)證一下我們的猜測:
//adapter關(guān)鍵代碼
@NonNull
@Override
public DemoViewHolder onCreateViewHolder(@NonNull ViewGroup 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的情況,就知道進(jìn)入界面再滑到Integer.MAX_VALUE/2時(shí)會(huì)初始化多少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效果:

日志打?。?/p>

可以看到,頁面上共有5個(gè)item可見,LLM也按需創(chuàng)建、layout了5個(gè)item。
2、自定義layoutManager
找了找網(wǎng)上自定義layoutManager去實(shí)現(xiàn)列表循環(huán)的博客和代碼,拷貝和復(fù)制的很多,找不到源頭是哪一篇,這里就不貼鏈接了。大家都是先說第一種修改adapter的方式不好,然后甩了一份自定義layoutManager的代碼。
然而自定義layoutManager難點(diǎn)和坑都很多,很容易不小心就踩到,一些博客的代碼也有類似問題。 基本的一些坑點(diǎn)在張旭童大佬的博客中有提及, 【Android】掌握自定義LayoutManager
比較常見的問題是:
- 不計(jì)算可用空間和子view消費(fèi)的空間,layout出所有的子view。相當(dāng)于拋棄了子view的復(fù)用機(jī)制
- 沒有合理利用recyclerView的回收機(jī)制
- 沒有支持一些常用但比較重要的api的實(shí)現(xiàn),如前面提到的scrollToPosition。
其實(shí)最理想的辦法是繼承LinearLayoutManager然后修改,但由于LinearLayoutManager內(nèi)部封裝的原因,不方便像GridLayoutManager那樣去繼承LinearLayoutManager然后進(jìn)行擴(kuò)展(主要是包外的子類會(huì)拿不到layoutState等)。
要實(shí)現(xiàn)一個(gè)線性布局的layoutManager,最重要的就是實(shí)現(xiàn)一個(gè)類似LLM的fill(前面有提到過源碼,可以翻回去看看)和layoutChunk方法。
(當(dāng)然,可以照著LLM寫一個(gè)丐版,本文就是這么做的。)
fill方法很重要,就如同官方注釋里所說的,它是一個(gè)magic func。
從OnLayoutChildren到觸發(fā)scroll滑動(dòng),都是調(diào)用fill來實(shí)現(xiàn)布局。
/**
* 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主要干了兩件事:
- 循環(huán)調(diào)用layoutChunk布局子view并計(jì)算可用空間
- 回收那些不在屏幕上的view
而負(fù)責(zé)子view布局的layoutChunk則和把一個(gè)大象放進(jìn)冰箱一樣,主要分三步走:
- add子view
- measure
- layout 并計(jì)算消費(fèi)了多少空間

就像下面這樣:
/**
* 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();
}
那最關(guān)鍵的如何實(shí)現(xiàn)循環(huán)呢??
其實(shí)和修改adapter的實(shí)現(xiàn)方法有異曲同工之妙,本質(zhì)都是修改位置與數(shù)據(jù)的映射關(guān)系。
修改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;
}
}
最終效果:

源碼地址:aFlyFatPig/cycleLayoutManager (github.com)
注:也可以直接引用依賴使用,詳見readme.md。
后記
本文介紹了recyclerview無限循環(huán)效果的兩種不同實(shí)現(xiàn)方法與解析。
雖然自定義layoutManager坑點(diǎn)很多并且很少用的到,但了解下也會(huì)對recyclerView有更深的理解。
以上就是RecyclerView無限循環(huán)效果實(shí)現(xiàn)及示例解析的詳細(xì)內(nèi)容,更多關(guān)于RecyclerView無限循環(huán)的資料請關(guān)注腳本之家其它相關(guān)文章!
相關(guān)文章
解決Android加殼過程中mprotect調(diào)用失敗的原因分析
本文探討的主要內(nèi)容是mprotect調(diào)用失敗的根本原因,以及在加殼實(shí)現(xiàn)中的解決方案,通過本文的闡述,一方面能夠幫助遇到同類問題的小伙伴解決心中的疑惑,另一方面能夠給大家提供可落地的實(shí)現(xiàn)方案,需要的朋友可以參考下2022-01-01
Android實(shí)現(xiàn)在屏幕上移動(dòng)圖片的方法
這篇文章主要介紹了Android實(shí)現(xiàn)在屏幕上移動(dòng)圖片的方法,實(shí)例分析了Android操作圖片的相關(guān)技巧,需要的朋友可以參考下2015-06-06
Android Flutter實(shí)現(xiàn)上拉加載組件的示例代碼
既然列表有下拉刷新外當(dāng)然還有上拉加載更多操作了,本次就為大家詳細(xì)介紹如何利用Flutter實(shí)現(xiàn)為列表增加上拉加載更多的交互,感興趣的可以了解一下2022-08-08
Android系統(tǒng)檢測程序內(nèi)存占用各種方法
這篇文章主要介紹了Android系統(tǒng)檢測程序內(nèi)存占用各種方法,本文講解了檢查系統(tǒng)總內(nèi)存、檢查某個(gè)程序的各類型內(nèi)存占用、檢查程序狀態(tài)、檢查程序各部分的內(nèi)存占用等內(nèi)容,需要的朋友可以參考下2015-03-03
android自動(dòng)工具類TextUtils使用詳解
這篇文章主要介紹了android自動(dòng)工具類TextUtils的使用方法,具有一定的參考價(jià)值,感興趣的小伙伴們可以參考一下2017-10-10

