中山專業(yè)制作網(wǎng)站武漢網(wǎng)絡(luò)推廣自然排名
此前大部分涉及到 RecyclerView 頁面的 LayoutManager基本上用系統(tǒng)提供的 LinearLayoutManager 、GridLayoutManager 就能解決,但在一些特殊場景上還是需要我們自定義 LayoutManager。之前基本上沒有自己寫過,在網(wǎng)上看各種源碼各種文章,剛開始花了好多時(shí)間去理解整體流程,因?yàn)樗鼈兌冀o我一種非常非常復(fù)雜的感覺,包括相關(guān)的博客文章也是。經(jīng)過一段時(shí)間摸索,也慢慢能理解為什么要那么復(fù)雜了,這的確不是特別容易入門。所以對整體的流程進(jìn)行了一個(gè)拆解,盡量原子化一點(diǎn),對自己學(xué)習(xí)的一個(gè)總結(jié),也希望能幫助到一部分人能對 LayoutManager 入門。
本文最終實(shí)現(xiàn)一個(gè)簡單的 LinearLayoutManager(只支持 VERTICAL)方向,適合對 LayoutManager 整體流程的學(xué)習(xí)與理解,整體代碼分為多個(gè)文件,每個(gè)文件都是對前一段代碼的補(bǔ)充,方便理解,整體項(xiàng)目源碼已提交 Github: LayoutManagerGradually,代碼里面寫了很多很多注釋,如果不想浪費(fèi)時(shí)間,可以直接看代碼運(yùn)行,跳過這篇文章,把每一個(gè) LayoutManager 都跑一下體驗(yàn)結(jié)合代碼看看。
自定義 LayoutManager 的必要元素
-
繼承
RecyclerView.LayoutManager
并實(shí)現(xiàn)generateDefaultLayoutParams()
方法 -
重寫
onLayoutChildren
第一次數(shù)據(jù)填充的時(shí)候數(shù)據(jù)添加 -
重寫
canScrollHorizontally()
和canScrollVertically()
方法設(shè)定支持滑動(dòng)的方向 -
重寫
scrollHorizontallyBy()
和scrollVerticallyBy()
方法,在滑動(dòng)的時(shí)候?qū)ζ聊灰酝獾?View 進(jìn)行回收,以及填充即將滑動(dòng)進(jìn)入屏幕范圍內(nèi)的 View 進(jìn)行填充 -
重寫
scrollToPosition()
和smoothScrollToPosition()
方法支持
其中onLayoutChildren
和 scrollHorizontallyBy/scrollVerticallyBy
是最核心且最復(fù)雜的方法,這里稍微拎出來講一下
onLayoutChildren
這個(gè)方法類似于自定義 ViewGroup 的 onLayout() 方法,RecyclerView 的 LayoutManager.onLayoutChildren 在以下幾個(gè)時(shí)機(jī)會(huì)被觸發(fā):
- 當(dāng)
RecyclerView
首次附加到窗口時(shí) - 當(dāng)
Adapter
的數(shù)據(jù)集發(fā)生變化 - 當(dāng)
RecyclerView
被 執(zhí)行RequetLayout
的時(shí)候 - 當(dāng)
LayoutManager
發(fā)生變化時(shí)
scrollHorizontallyBy/scrollVerticallyBy
方法的主要作用包括:
-
更新 ItemView 的位置:根據(jù)傳入的垂直滾動(dòng)距離(dy 參數(shù)),更新子視圖在屏幕上的位置。通常調(diào)用
offsetChildrenVertical
方法。 -
回收不可見的 ItemView:在滾動(dòng)過程中,一些 ItemView 可能會(huì)離開屏幕,變得不可見。
scrollVerticallyBy
方法需要負(fù)責(zé)回收這些子視圖并將它們放入回收池,以便稍后復(fù)用。 -
添加新的 ItemView:在滾動(dòng)過程中,新的 ItemView 可能需要顯示在屏幕上。
scrollVerticallyBy
方法需要從回收池中獲取可復(fù)用的視圖并將它們添加到屏幕上。這通常涉及到調(diào)用RecyclerView.Recycler
的getViewForPosition
方法。 -
返回實(shí)際滾動(dòng)距離:由于 ItemView 的數(shù)量有限,滾動(dòng)可能會(huì)受到限制。例如,當(dāng)滾動(dòng)到列表頂部或底部時(shí),滾動(dòng)可能會(huì)停止。在這種情況下,實(shí)際滾動(dòng)的距離可能會(huì)小于傳入的
dy
參數(shù)。scrollVerticallyBy
方法需要返回實(shí)際滾動(dòng)的距離,以便RecyclerView
可以正確地更新滾動(dòng)條和觸發(fā)滾動(dòng)事件。
概念就簡單講這么多, talk is cheap show me the code,直接看代碼理解會(huì)比較深刻
逐步實(shí)現(xiàn)
要實(shí)現(xiàn)一個(gè)可用的 LayoutManger 通常我們需要實(shí)現(xiàn)以下流程
- 數(shù)據(jù)填充并且只需要填充屏幕范圍內(nèi)的 ItemView
- 回收掉屏幕以外的 ItemView
- 屏幕外 ItemView 再回到屏幕后,需要重新填充
- 對滑動(dòng)邊界邊界進(jìn)行處理
- 對 scrollToPosition 和 smoothScrollToPosition進(jìn)行支持
我們不用一上來就實(shí)現(xiàn)最終的效果,而是一步一步來,看看 LayoutManger 是怎么漸漸地變化,最終能跑起來的。
0 最簡單的 LayoutManager
代碼查看:MostSimpleLayoutManager,我們關(guān)注 onLayoutChildren
方法:
override fun onLayoutChildren(recycler: RecyclerView.Recycler, state: RecyclerView.State?) {// 垂直方向的偏移量var offsetTop = 0// 實(shí)際業(yè)務(wù)中最好不要這樣一次性加載所有的數(shù)據(jù),這里只是最簡單地演示一下整體是如何工作的for (itemIndex in 0 until itemCount) {// 從適配器獲取與給定位置關(guān)聯(lián)的視圖val itemView = recycler.getViewForPosition(itemIndex)// 將視圖添加到 RecyclerView 中addView(itemView)// 測量并布局視圖measureChildWithMargins(itemView, 0, 0)// 拿到寬高(包括ItemDecoration)val width = getDecoratedMeasuredWidth(itemView)val height = getDecoratedMeasuredHeight(itemView)// 對要添加的子 View 進(jìn)行布局layoutDecorated(itemView, 0, offsetTop, width, offsetTop + height)offsetTop += height}
}
上面的代碼主要演示了,如何利用addView
layoutDecorated
等方法,將 ItemView 添加到 RecyclerView 上。代碼可見是 將所有的 ItemView(即使它在屏幕上不可見)一次性全部加載到了 RecyclerView上, 這里一般不這么做,只是這里這里只是最簡單地演示一下整體是如何工作的。
運(yùn)行在手機(jī)上能看到這樣的效果:Item數(shù)據(jù)已經(jīng)被全部添加到界面上了,并且各個(gè)方向的滑動(dòng)都支持。

1 更合理的數(shù)據(jù)添加方式
代碼查看:LinearLayoutManager1.kt
對最開始的代碼進(jìn)行優(yōu)化,只在屏幕范圍內(nèi)的區(qū)域進(jìn)行數(shù)據(jù)的添加,這樣就不需要一次性將所有數(shù)據(jù)就添加上去,如果 Adapter 的 ItemCount 足夠巨大,for all addView 的話,很容易就 OOM。
override fun onLayoutChildren(recycler: RecyclerView.Recycler, state: RecyclerView.State) {// 垂直方向上的的空間大小var remainSpace = height - paddingTop//垂直方向的偏移量var offsetTop = 0var currentPosition = 0while (remainSpace > 0 && currentPosition < state.itemCount) {// 從適配器獲取與給定位置關(guān)聯(lián)的視圖val itemView = recycler.getViewForPosition(currentPosition)// 將視圖添加到 RecyclerView 中addView(itemView)// 測量并布局視圖measureChildWithMargins(itemView, 0, 0)// 拿到寬高(包括ItemDecoration)val itemWidth = getDecoratedMeasuredWidth(itemView)val itemHeight = getDecoratedMeasuredHeight(itemView)// 對要添加的子 View 進(jìn)行布局layoutDecorated(itemView, 0, offsetTop, itemWidth, offsetTop + itemHeight)offsetTop += itemHeightcurrentPosition++// 可用空間減少remainSpace -= itemHeight}
}
2 對屏幕外的View回收
代碼查看:LinearLayoutManager2
RecylerView 沒有 recycler 怎么行呢?當(dāng) RecylerView 的 ItemView 滑出屏幕后我們需要對齊進(jìn)行回收,實(shí)現(xiàn)的話需要在 scrollVerticallyBy
中,比較復(fù)雜的邏輯就是怎么去判斷:ItemView 在屏幕以外,最后利用:removeAndRecycleView
方法進(jìn)行回收
override fun scrollVerticallyBy(dy: Int, recycler: RecyclerView.Recycler, state: RecyclerView.State?): Int {// 在這里處理上下的滾動(dòng)邏輯,dy 表示滾動(dòng)的距離// 平移所有子視圖offsetChildrenVertical(-dy)// 如果實(shí)際滾動(dòng)距離與 dy 相同,返回 dy;如果未滾動(dòng),返回 0recycleInvisibleView(dy, recycler)return dy
}/*** 回收掉在界面上看不到的 ItemView** @param dy* @param recycler*/
private fun recycleInvisibleView(dy: Int, recycler: RecyclerView.Recycler) {val totalSpace = orientationHelper.totalSpace// 將要回收View的集合val recycleViews = hashSetOf<View>()// 從下往上滑if (dy > 0) {for (i in 0 until childCount) {val child = getChildAt(i)!!// 從下往上滑從最上面的 item 開始計(jì)算val top = getDecoratedTop(child)// 判斷最頂部的 item 是否已經(jīng)完全不可見,如何可見,那說明底下的 item 也是可見val height = top - getDecoratedBottom(child)if (height - top < 0) {break}recycleViews.add(child)}} else if (dy < 0) { // 從上往下滑for (i in childCount - 1 downTo 0) {val child = getChildAt(i)!!// 從上往下滑從最底部的 item 開始計(jì)算val bottom = getDecoratedBottom(child)// 判斷最底部的 item 是否已經(jīng)完全不可見,如何可見,那說明上面的 item 也是可見val height = bottom - getDecoratedTop(child)if (bottom - totalSpace < height) {break}recycleViews.add(child)}}// 真正把 View 移除掉的邏輯for (view in recycleViews) {// [removeAndRecycleView]// 用于從視圖層次結(jié)構(gòu)中刪除某個(gè)視圖,并將其資源回收,以便在需要時(shí)重新利用removeAndRecycleView(view, recycler)}recycleViews.clear()
}
運(yùn)行在手機(jī)上能看到這樣的效果:滑出屏幕外的ItemView 被回收掉了

3 向上滑動(dòng)的時(shí)View的填充
代碼查看:LinearLayoutManager3
override fun scrollVerticallyBy(dy: Int, recycler: RecyclerView.Recycler, state: RecyclerView.State?): Int {// 填充 viewfillView(dy, recycler)// 移動(dòng) viewoffsetChildrenVertical(-dy)// 回收 ViewrecycleInvisibleView(dy, recycler)return dy
}/*** 填充重新進(jìn)入屏幕內(nèi)的 ItemView* getChildCount():childCount-> 當(dāng)前屏幕內(nèi)RecyclerView展示的 ItemView 數(shù)量* getItemCount():itemCount-> 最大的 ItemView 數(shù)量,也就是 Adapter 傳遞的數(shù)據(jù)的數(shù)量*/
private fun fillView(dy: Int, recycler: RecyclerView.Recycler) {val verticalSpace = orientationVerticalHelper.totalSpacevar remainSpace = 0var nextFillPosition = 0//垂直方向的偏移量var offsetTop = 0var offsetLeft = 0// 從下往上滑,那么需要向底部添加數(shù)據(jù)if (dy > 0) {val anchorView = getChildAt(childCount - 1) ?: returnval anchorPosition = getPosition(anchorView)val anchorBottom = getDecoratedBottom(anchorView)val anchorLeft = getDecoratedLeft(anchorView)remainSpace = verticalSpace - anchorBottom// 垂直可用的數(shù)據(jù)為<0,意外著這時(shí)候屏幕底部的位置剛好在最底部的 ItemView 上,還需要向上滑動(dòng)一點(diǎn)點(diǎn)...我們才能添加 Viewif (remainSpace < 0) {return}nextFillPosition = anchorPosition + 1offsetTop = anchorBottomoffsetLeft = anchorLeftif (nextFillPosition >= itemCount) {return}} else if (dy < 0) { // 從上往下滑,那么需要向頂部添加數(shù)據(jù)//no-op 暫時(shí)不實(shí)現(xiàn)從上往下滑的底部數(shù)據(jù)填充}while (remainSpace > 0 && nextFillPosition < itemCount) {// 從適配器獲取與給定位置關(guān)聯(lián)的視圖val itemView = recycler.getViewForPosition(nextFillPosition)// 將視圖添加到 RecyclerView 中addView(itemView)// 測量并布局視圖measureChildWithMargins(itemView, 0, 0)// 拿到寬高(包括ItemDecoration)val itemWidth = getDecoratedMeasuredWidth(itemView)val itemHeight = getDecoratedMeasuredHeight(itemView)// 對要添加的子 View 進(jìn)行布局,相比onLayoutChildren 里面的實(shí)現(xiàn)添加了:offsetLeft(因?yàn)槲覀儧]有禁止掉 左右的滑動(dòng))// 試著把 offsetLeft 改成0,也就是最原始的樣子,然后左右上下滑滑,你會(huì)有意外收獲layoutDecorated(itemView, offsetLeft, offsetTop, itemWidth + offsetLeft, offsetTop + itemHeight)offsetTop += itemHeightnextFillPosition++// 可用空間減少remainSpace -= itemHeight}
}
運(yùn)行在手機(jī)上能看到這樣的效果:向上滑動(dòng)的時(shí)候,底部陸續(xù)有元素填充,但向下滑動(dòng)的時(shí)候沒有填充數(shù)據(jù)

4 兩個(gè)方向的View填充
代碼查看:LinearLayoutManager4
補(bǔ)齊從上往下滑之后添加的邏輯
private fun fillView(dy: Int, recycler: RecyclerView.Recycler) {val verticalSpace = orientationVerticalHelper.totalSpacevar remainSpace = 0var nextFillPosition = 0//垂直方向的偏移量var offsetTop = 0var offsetLeft = 0// 從下往上滑,那么需要向底部添加數(shù)據(jù)if (dy > 0) {……} else if (dy < 0) { // 從上往下滑,那么需要向頂部添加數(shù)據(jù)val anchorView = getChildAt(0) ?: returnval anchorPosition = getPosition(anchorView)val anchorTop = getDecoratedTop(anchorView)offsetLeft = getDecoratedLeft(anchorView)remainSpace = anchorTop// 垂直可用的數(shù)據(jù)為<0,意外著這時(shí)候屏幕頂部的位置剛好在最底部的 ItemView 上,還需要向下滑動(dòng)一點(diǎn)點(diǎn)...我們才能添加 Viewif (anchorTop < 0) {return}nextFillPosition = anchorPosition - 1if (nextFillPosition < 0) {return}val itemHeight = getDecoratedMeasuredHeight(anchorView)// 新的布局的itemView 的頂部位置應(yīng)該以 anchorTop - itemHeight 開始offsetTop = anchorTop - itemHeight}while (remainSpace > 0 &&((nextFillPosition < itemCount) && (nextFillPosition >= 0))) {// 從適配器獲取與給定位置關(guān)聯(lián)的視圖val itemView = recycler.getViewForPosition(nextFillPosition)// 將視圖添加到 RecyclerView 中k,從頂部添加的話,需要加到最前的位置if (dy > 0) {addView(itemView)} else {addView(itemView, 0)}……if (dy > 0) {offsetTop += itemHeightnextFillPosition++} else {offsetTop -= itemHeightnextFillPosition--}// 可用空間減少remainSpace -= itemHeight}
運(yùn)行在手機(jī)上能看到這樣的效果:向上或者滑動(dòng)的時(shí)候,底部陸續(xù)都有元素填充

5 對頂部和底部滑動(dòng)邊界處理
代碼查看:LinearLayoutManager5
對于前面的實(shí)現(xiàn)會(huì)發(fā)現(xiàn)會(huì):不停地下滑或者上滑會(huì)留出來巨大的空白。這里對填充 View 的邏輯進(jìn)行改造,需要進(jìn)行邊界檢測。
override fun scrollVerticallyBy(dy: Int, recycler: RecyclerView.Recycler, state: RecyclerView.State?): Int {// 填充 viewval adjustedDy = fillView(dy, recycler)// 移動(dòng) viewoffsetChildrenVertical(-adjustedDy)// 回收 ViewrecycleInvisibleView(adjustedDy, recycler)// 由于需要對邊界進(jìn)行限制,所以需要對原始的 dy 進(jìn)行修正,這里不再直接返回 dyreturn adjustedDy
}
這里的整體注釋我寫在了代碼里面,可以看圖稍微理解一下,以向上滑動(dòng)為例:假設(shè)這一次滑動(dòng)的距離非常非常大(想象成10000像素),如果直接滑動(dòng)的話,我們有50個(gè)元素,每個(gè)元素高度100像素,最大高度也只有50x100=5000,那么滑動(dòng)后一定會(huì)留下大量空區(qū)域。需要對當(dāng)前傳入的這 10000 像素做調(diào)整:只給到可滑動(dòng)的最大距離,如果不能滑動(dòng)了就返回0。

運(yùn)行在手機(jī)上能看到這樣的效果:向上或者滑動(dòng)的時(shí)候,達(dá)到最大的位置時(shí)候是不能再滑動(dòng)的。

6 實(shí)現(xiàn) scrollToPosition
代碼查看:LinearLayoutManager6
到這里這個(gè) LinearLayoutManager 看著已經(jīng)能正常運(yùn)行了,但一般還需要支持scrollToPosition
和 smoothScrollToPositio
private var mPendingScrollPosition = RecyclerView.NO_POSITIONoverride fun scrollToPosition(position: Int) {super.scrollToPosition(position)if (position < 0 || position >= itemCount) {return}mPendingScrollPosition = positionrequestLayout()
}override fun onLayoutChildren(recycler: RecyclerView.Recycler, state: RecyclerView.State) {……var currentPosition = 0if (mPendingScrollPosition != RecyclerView.NO_POSITION) {currentPosition = mPendingScrollPosition}while (remainSpace > 0 && currentPosition < state.itemCount) {…… // 填充View 的邏輯}
}
scrollToPosition
的實(shí)現(xiàn)比較簡單,如上代碼所示:在 scrollToPosition
的時(shí)候記錄一次目標(biāo)position,再 requestLayout 一波,還記得之前有提到過:onLayoutChildren
會(huì)在 requestLayout
的時(shí)候調(diào)用一次,于是再將onLayoutChildren
邏輯改寫,不再從第0個(gè)元素開始,而是從目標(biāo)位置進(jìn)行布局。
運(yùn)行在手機(jī)上能看到這樣的效果:點(diǎn)擊 scrollTo30 將會(huì)滑動(dòng)到 第30個(gè)位置。

7 實(shí)現(xiàn) smoothScrollToPosition
代碼查看:LinearLayoutManager7
要實(shí)現(xiàn)自定義的 smoothScrollToPosition 動(dòng)畫效果,這一塊如果要完全自己實(shí)現(xiàn)的話比較復(fù)雜,可以直接使用系統(tǒng)提供的 LinearSmoothScroller改造,也可以繼承 RecyclerView.SmoothScroller 自定義,也可以完全不使用 SmoothScroller, 照著 SmoothScroller 的實(shí)現(xiàn)使用類似 ValueAnimator 自定義動(dòng)畫,添加動(dòng)畫 UpdateListener,在 onAnimationUpdate 的時(shí)候動(dòng)態(tài)計(jì)算布局從而實(shí)現(xiàn)滑動(dòng)動(dòng)畫,這里拿 LinearSmoothScroller 舉例:
override fun smoothScrollToPosition(recyclerView: RecyclerView,state: RecyclerView.State,position: Int
) {if (position >= itemCount || position < 0) {return}val scroller: LinearSmoothScroller = object : LinearSmoothScroller(recyclerView.context) {/*** 這個(gè)方法用于計(jì)算滾動(dòng)到目標(biāo)位置所需的滾動(dòng)向量。滾動(dòng)向量是一個(gè)二維向量,包含水平和垂直方向上的滾動(dòng)距離** @param targetPosition 滑動(dòng)的目標(biāo)位置* @return 返回一個(gè) PointF 對象,表示滾動(dòng)向量。* PointF.x 表示水平方向上的滾動(dòng)距離,* PointF.y 表示垂直方向上的滾動(dòng)距離*/override fun computeScrollVectorForPosition(targetPosition: Int): PointF {// 查找到屏幕里顯示的第 1 個(gè)元素與val firstChildPos = getPosition(getChildAt(0)!!)val direction = if (targetPosition < firstChildPos) -1 else 1// x 左右滑動(dòng),由于我們只實(shí)現(xiàn)了垂直的滑動(dòng),所以 x方向?yàn)?即可// 整數(shù)代表正向移動(dòng),負(fù)數(shù)代表反向移動(dòng),這里的數(shù)值大小不重要,源碼里面最終都會(huì) normalize 歸一化處理return PointF(0f, direction.toFloat())}/*** 計(jì)算每像素速度** @param displayMetrics* @return 返回每一像素的耗時(shí),單位ms,假設(shè)返回值是1.0 代表著:1ms 內(nèi)會(huì)滑動(dòng) 1像素,1s會(huì)滑動(dòng)1000像素*/override fun calculateSpeedPerPixel(displayMetrics: DisplayMetrics?): Float {return super.calculateSpeedPerPixel(displayMetrics)}/*** 滑動(dòng)速度的插值(實(shí)現(xiàn)滑動(dòng)速度隨著滑動(dòng)時(shí)間的變化)** @param dx* @return*/override fun calculateTimeForDeceleration(dx: Int): Int {return super.calculateTimeForDeceleration(dx)}// 很多方法可以使用,不再一一列舉// ...}scroller.targetPosition = position// 執(zhí)行默認(rèn)動(dòng)畫的邏輯startSmoothScroll(scroller)
}
運(yùn)行在手機(jī)上能看到這樣的效果:點(diǎn)擊 smoothScrollTo30 將會(huì)有個(gè)動(dòng)畫效果滑動(dòng)到第30個(gè)位置。

以上基本上一個(gè)自定義 LayoutManager 的雛形就已經(jīng)完成了,雖然只實(shí)現(xiàn)了一個(gè)方向的滑動(dòng),但是其原理都是一樣的,剩下的就是各種細(xì)節(jié)的打磨了,可以加各種自己想要的效果,比如:指定位置 放大一定的系數(shù),或者更炫酷的滑動(dòng)動(dòng)畫…
總結(jié)
本文主要整理了自定義 LayoutManager 的必要元素,以及其核心方法 scrollHorizontallyBy/scrollVerticallyBy、onLayoutChildren 的作用與調(diào)用時(shí)機(jī),接下對實(shí)現(xiàn)一個(gè)簡單的 LinearLayoutManger 進(jìn)行邏輯拆解,從最簡單的不滑動(dòng)回收和填充以及不含滑動(dòng)邊界檢測,到最終一個(gè)具備基本功能的 LinearLayoutManger
源碼:https://github.com/VomPom/LayoutManagerGradually
參考:
《看完這篇文章你還不會(huì)自定義LayoutManager,我吃X!》
《/LayoutManager分析與實(shí)踐》
Building a RecyclerView LayoutManager – Part 1