RecyclerView.LayoutManagerの実装方法
本稿は2018年に執筆したものをZennに移行したものです。
仕事でLinearLayoutManagerやGridLayoutManagerといったGoogle提供のLayoutManagerでは実現できないUIを要求されたので、今LayoutManagerをスクラッチで実装しています。
大先輩が作ったAbemaTVの番組表なんかもオリジナルのLayoutManagerで実装されています。
備忘録としてミニマムなLinearLayoutManagerの実装方法を例にまとめておきたいと思います。
最低限実装しなければならないもの
- generateDefaultLayoutParams
- onLayoutChildren
- canScrollVertically
- canScrollHorizontally
- scrollVerticallyBy
- scrollHorizontallyBy
generateDefaultLayoutParams
デフォルトのRecyclerView.LayoutParamを生成する関数です。
override fun generateDefaultLayoutParams(): RecyclerView.LayoutParams {
return RecyclerView.LayoutParams(
ViewGroup.LayoutParams.WRAP_CONTENT,
ViewGroup.LayoutParams.WRAP_CONTENT)
}
onLayoutChildren
Layoutの初期化を行います。
RecyclerViewのスペースを使い切るか表示すべきアイテムが無くなるまでViewの生成と配置を行います。
adapterがsetされたときに1度呼ばれるだけなので、多少重たい処理を行っても問題ありません。
override fun onLayoutChildren(recycler: Recycler, state: State) {
detachAndScrapAttachedViews(recycler)
firstVisiblePosition = 0
lastVisiblePosition = 0
var offset = paddingTop
for (position in (0 until itemCount)) {
offset += addRow(position, offset, recycler)
if (offset > height - paddingBottom) {
lastVisiblePosition = position
break
}
}
}
private fun addRow(position: Int, offsetY: Int, recycler: Recycler): Int {
val v = recycler.getViewForPosition(position)
val isAppend = position >= firstVisiblePosition
if (isAppend) addView(v) else addView(v, 0)
measureChild(v, 0, 0)
val height = getDecoratedMeasuredHeight(v)
val left = paddingLeft
val top = if (isAppend) offsetY else offsetY - height
val right = left + getDecoratedMeasuredWidth(v)
val bottom = top + height
layoutDecorated(v, left, top, right, bottom)
return height
}
detachAndScrapAttachedViews(recycler)
ですでに配置されているViewの破棄を行います。
次に順次itemを配置していきます。
recycler#getViewForPosition
でViewの生成/再利用を行ってくれます。
layoutDecorated(v, left, top, right, bottom)
でViewを配置します。
RecyclerViewのPaddingやItemDecorationによるDecorate後のitemサイズを考慮する必要があります。
canScrollVertically / canScrollHorizontally
縦方向/横方向にscroll可能かどうかを返却する関数です。
defaultでfalseが返却されるように実装されているので、対応したい方向のみoverrideすればいいでしょう。
override fun canScrollVertically() = true
scrollVerticallyBy / scrollHorizontallyBy
スクロール時に呼ばれる関数です。スクロール量dyが渡されるので次に表示する必要があるアイテムの配置と見えなくなったアイテムの削除とアイテムの表示位置の移動を行います。
まず、実際にスクロール可能な量を計算する関数を作ります。
ユーザーが大きくSwipeをしてもアイテム数が少なければ少ししかスクロールできない可能性があります。
private fun calculateScrollAmount(dy: Int, firstItemTop: Int, lastItemBottom: Int): Int {
return if (dy > 0) { // upper swipe
if (lastVisiblePosition == itemCount - 1)
min(dy, lastItemBottom - height + paddingBottom)
else
dy
} else {
if (firstVisiblePosition == 0)
max(dy, -(paddingTop - firstItemTop))
else
dy
}
}
スクロール後にできる空きスペースに新しいアイテムを配置する関数を作ります。
private fun fillAbove(scrollAmount: Int, lastItemBottom: Int, recycler: Recycler)
val space = height - paddingBottom - lastItemBottom - scrollAmount
var consumed = 0
for (i in lastVisiblePosition + 1 until itemCount) {
consumed += addRow(i, lastItemBottom + consumed, recycler)
lastVisiblePosition++
if (consumed > space) break
}
}
private fun fillBelow(scrollAmount: Int, firstItemBottom: Int, recycler: Recycler
val space = firstItemBottom + scrollAmount - paddingTop
var consumed = 0
for (i in firstVisiblePosition - 1 downTo 0) {
consumed += addRow(i, firstItemBottom - consumed, recycler)
firstVisiblePosition--
if (consumed > space) break
}
}
表示する必要がなくなったアイテムを消してリサイクルする関数を作ります。
private fun removeRow(position: Int, recycler: Recycler) {
when (position) {
firstVisiblePosition -> {
removeAndRecycleViewAt(0, recycler)
firstVisiblePosition++
}
lastVisiblePosition -> {
removeAndRecycleViewAt(childCount - 1, recycler)
lastVisiblePosition--
}
}
}
これらを組み合わせてscrollVerticallyByを実装したのが以下になります。
override fun scrollVerticallyBy(dy: Int, recycler: Recycler, state: State): Int {
if (dy == 0) return 0
val firstItem = getChildAt(0) ?: return 0
val lastItem = getChildAt(childCount - 1) ?: return 0
val firstTop = getDecoratedTop(firstItem)
val lastBottom = getDecoratedBottom(lastItem)
val scrollAmount = calculateScrollAmount(dy, firstTop, lastBottom)
if (dy > 0) { // upper swipe
val firstBottom = getDecoratedBottom(firstItem)
if (lastBottom - scrollAmount < height - paddingBottom)
fillAbove(scrollAmount, lastBottom, recycler)
if (firstBottom - scrollAmount < paddingTop)
removeRow(firstVisiblePosition, recycler)
} else {
val lastTop = getDecoratedTop(lastItem)
if (firstTop - scrollAmount >= paddingTop)
fillBelow(scrollAmount, firstTop, recycler)
if (lastTop - scrollAmount >= height - paddingBottom)
removeRow(lastVisiblePosition, recycler)
}
offsetChildrenVertical(-scrollAmount)
return scrollAmount
}
まずスクロール量を計算。
val scrollAmount = calculateScrollAmount(dy, firstTop, lastBottom)
スクロール量から空きスペースができるかどうかを計算して空きスペースへアイテムを配置。
必要なくなったアイテムを削除。
if (dy > 0) { // upper swipe
val firstBottom = getDecoratedBottom(firstItem)
if (lastBottom - scrollAmount < height - paddingBottom)
fillAbove(scrollAmount, lastBottom, recycler)
if (firstBottom - scrollAmount < paddingTop)
removeRow(firstVisiblePosition, recycler)
} else {
val lastTop = getDecoratedTop(lastItem)
if (firstTop - scrollAmount >= paddingTop)
fillBelow(scrollAmount, firstTop, recycler)
if (lastTop - scrollAmount >= height - paddingBottom)
removeRow(lastVisiblePosition, recycler)
}
offsetChildrenVertical
でアイテムを移動し実際にスクロールした量を返却。
offsetChildrenVertical(-scrollAmount)
return scrollAmount
他にも実装しなければならないことは色々ある。
最低限、上記を実装するだけでViewは組み立てられますが、scrollTo
やsmoothScrollTo
、instance stateの保存など他にも実装すべきことは色々あります。
それはまたの機会に。
Discussion