📜

RecyclerView.LayoutManagerの実装方法

2023/08/15に公開

本稿は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は組み立てられますが、scrollTosmoothScrollTo、instance stateの保存など他にも実装すべきことは色々あります。
それはまたの機会に。

関連

参考

Discussion