본문 바로가기

ANDROID/UI - UX

안드로이드 RecyclerView 커스텀 LayoutManager 구성하기

현재 안드로이드에서는 RecyclerView와 사용 가능한 세 개의 LayoutManager를 제공하고 있다.(GridLayoutManager, LinearLayoutManager, StaggeredLayoutManager)

 

하지만 경우에 따라 여기중 어느 것으로도 원하는 아이템 구성을 RecyclerView에 적용하지 못할 수 있다.

 

그렇다면 이제.. 직접 커스텀 LayoutManager를 구성해야만 한다!

 

이번 작업을 위해 아래의 포스트를 참조했다.

 

 

A Custom LayoutManager Case: Bumble Beeline

A step by step guide to building a custom LayoutManager

medium.com


이제 직접 해야할 때

커스텀 LayoutManager를 구성하면서 필요한 개념에 대해서 정리해야 앞으로도 입맛에 맛게 어디를 손대야 하는지 알 수 있을 것이다.

 

이 포스트에서는 그 핵심 부분에 대해서 정리해두고자 한다.

 

필수 요소

여기서 여러 메소드 들에서 firstChild 혹은 lastChild를 getChildAt(0)과 getChildAt(childCount -1)로 각각 호출하는 부분이 있는데, 이는 현재 RecyclerView에 실제로 붙어 있는 item들의 맨 첫 아이템, 그리고 가장 마지막 아이템을 참고하는 것임을 주의하자.

 

1. generateDefaultLayoutParams 

RecyclerView의 각 아이템에 적용할 LayoutParams을 지정한다.

override fun generateDefaultLayoutParams(): RecyclerView.LayoutParams = 
   LayoutParams(
      RecyclerView.LayoutParams.MATCH_PARENT,
      RecyclerView.LayoutParams.WRAP_CONTENT
   )

 

2. onLayoutChildren

Recycler에 보여줄 아이템이 1개 이상인 경우 이를 그리도록 호출되는 메소드이다. 

override fun onLayoutChildren(recycler: RecyclerView.Recycler, state: RecyclerView.State) {
   detachAndScrapAttachedViews(recycler)
   if (state.itemCount <= 0) return
   fillBottom(recycler, state.itemCount)
}

 

3. fillBottom

최초 설정된 아이템을 그리거나, 아래로 스크롤하는 경우 아이템을 그리는 메소드.

여기서 childCount는 현재 RecyclerView에 실제로 attach된 아이템의 갯수로, RecyclerView Adapter에 설정한 아이템의 갯수와는 다르다.

private fun fillBottom(recycler: RecyclerView.Recycler, adapterItemCount: Int) {

   var hardTop: Int
   var softTop: Int

   var startPosition: Int

   if (childCount > 0) {
       val lastChild = getChildAt(childCount - 1) ?: return
       val lastChildPosition = getPosition(lastChild)
       startPosition = lastChildPosition + 1
       val lp = lastChild.layoutParams()
       
       // If the item is solid the hard line is moved down to bottom of this view minus allowed overlap
       // If not solid then the hard line is moved down to top of this item
       hardTop =
           if (lp.isSolid) {
               getDecoratedBottom(lastChild) + lp.bottomMargin - ((lastChild.measuredHeight + lp.verticalMargin) * lp.verticalOverlay).toInt()
           } else {
               getDecoratedTop(lastChild)
           }
       // Soft line is always moved to the bottom of given item
       softTop = getDecoratedBottom(lastChild) + lp.bottomMargin
   } else {
       // If there are no attached child views then we start from the top of parent view
       hardTop = parentTop + if (anchorPosition < adapterItemCount) anchorOffset else 0
       softTop = parentTop + if (anchorPosition < adapterItemCount) anchorOffset else 0

       startPosition = if (anchorPosition < adapterItemCount) anchorPosition else 0
   }

   val availableBottom = if (clipToPadding) parentBottom else height

   for (i in startPosition until adapterItemCount) {

       if (hardTop > availableBottom) break

       val view = recycler.getViewForPosition(i)
       addView(view)
       view.setBeelineLayoutParams(i)
       view.measure()
       val lp = view.layoutParams()
       if (lp.isSolid) {
           // For solid items, the top is determined as the lower one of previous item’s hard line
           // or previous item’s soft line minus this view’s vertical overlap allowance
           val top = max(
               hardTop,
               softTop - ((view.measuredHeight + lp.verticalMargin) * lp.verticalOverlay).toInt()
           )
           val bottom = top + view.measuredHeight + lp.verticalMargin
           layoutView(view, top, bottom)
           // The hard line is moved down to bottom of this view minus allowed overlap
           hardTop = bottom - ((view.measuredHeight + lp.verticalMargin) * lp.verticalOverlay).toInt()
           softTop = bottom
       } else {
           // If the item is not solid then we lay it out according to the current hard line
           val top = hardTop
           val bottom = top + view.measuredHeight + lp.verticalMargin
           layoutView(view, top, bottom)
           softTop = bottom
       }
   }
}

 

4. fillTop

위로 스크롤 하는 경우 아래에서 위로 아이템을 그려 나가는 메소드.

private fun fillTop(recycler: RecyclerView.Recycler) {
        if (childCount == 0) return

        val firstChild = getChildAt(0) ?: return
        val firstChildPosition = getPosition(firstChild)
        if (firstChildPosition == 0) return
        val lp = firstChild.layoutParams()
        var hardBottom =
            if (lp.isSolid) {
                getDecoratedTop(firstChild) - lp.topMargin + ((firstChild.measuredHeight + lp.verticalMargin) * lp.verticalOverlay).toInt()
            } else {
                getDecoratedBottom(firstChild)
            }
        var softBottom = getDecoratedTop(firstChild) - lp.topMargin
        val availableTop = if (clipToPadding) parentTop else 0

        for (i in firstChildPosition - 1 downTo 0) {

            if (hardBottom < availableTop) break

            val view = recycler.getViewForPosition(i)
            anchorPosition--
            addView(view, 0)
            view.setBeelineLayoutParams(i)
            view.measure()
            val lp = view.layoutParams()
            if (lp.isSolid) {
                val bottom = min(
                    hardBottom,
                    softBottom + ((view.measuredHeight + lp.verticalMargin) * lp.verticalOverlay).toInt()
                )
                val top = bottom - view.measuredHeight - lp.verticalMargin
                layoutView(view, top, bottom)
                hardBottom = top + ((view.measuredHeight + lp.verticalMargin) * lp.verticalOverlay).toInt()
                softBottom = top
            } else {
                val bottom = hardBottom
                val top = bottom - view.measuredHeight - lp.verticalMargin
                layoutView(view, top, bottom)
                softBottom = top
            }
        }
    }

 

5. View.measure()

아이템을 그리기 위해 measure를 하는 단계로, parent 영역에서 가로 혹은 세로 영역을 제외한 사이즈를 파라미터로 넘겨주어야 하는 것이 키 포인트(이것이 잘 지켜지지 않으면 아이템이 흐릿하게 노출될 수 있음)

private fun View.measure() {
   val widthUsed = if (layoutParams().spanSize == 1) columnWidth else 0
   measureChildWithMargins(this, widthUsed, 0)
}

6. scrollVerticallyBy

스크롤이 일어날 때 RecyclerView가 호출하는 메소드로, dy 값에 따라 fillTop 혹은 fillBottom을 수행하며 유효한 범위 외에 있는 뷰들을 Recycle한다(recycleViewOutOfBounds)

override fun scrollVerticallyBy(
    dy: Int,
    recycler: RecyclerView.Recycler,
    state: RecyclerView.State
): Int =
    when {
        childCount == 0 -> 0
        dy < 0 -> {
            val availableTop = parentTop
            var scrolled = 0
            while (scrolled > dy) {
                val firstChild = getChildAt(0) ?: break
                val firstChildTop =
                    getDecoratedTop(firstChild) - firstChild.layoutParams().topMargin
                val hangingTop = max(0, availableTop - firstChildTop)
                val scrollBy = min(hangingTop, scrolled - dy)
                offsetChildrenVerticallyBy(-scrollBy)
                scrolled -= scrollBy
                if (anchorPosition == 0) break
                fillTop(recycler)
            }
            scrolled
        }
        dy > 0 -> {
            val availableBottom = parentBottom
            var scrolled = 0
            while (scrolled < dy) {
                val lastChild = getChildAt(childCount - 1) ?: break
                val lastChildPosition = getPosition(lastChild)
                val layoutParams = lastChild.layoutParams()
                val lastChildBottom = getDecoratedBottom(lastChild) + layoutParams.bottomMargin
                val hangingBottom = max(0, lastChildBottom - availableBottom)
                val scrollBy = min(hangingBottom, dy - scrolled)
                offsetChildrenVerticallyBy(scrollBy)
                scrolled += scrollBy
                if (lastChildPosition == state.itemCount - 1) break
                fillBottom(recycler, state.itemCount)
            }
            scrolled
        }
        else -> 0
    }.also {
        recycleViewsOutOfBounds(recycler)
        updateAnchorOffset()
    }

7. recycleViewOutOfBounds

private fun recycleViewsOutOfBounds(recycler: RecyclerView.Recycler) {
    if (childCount == 0) return
    val childCount = childCount

    var firstVisibleChild = 0
    for (i in 0 until childCount) {
        val child = getChildAt(i) ?: break
        val layoutParams = child.layoutParams()
        val top = if (clipToPadding) parentTop else 0
        if (getDecoratedBottom(child) + layoutParams.bottomMargin < top) {
            firstVisibleChild++
        } else {
            break
        }
    }

    var lastVisibleChild = firstVisibleChild
    for (i in lastVisibleChild until childCount) {
        val child = getChildAt(i) ?: break
        val layoutParams = child.layoutParams()
        if (getDecoratedTop(child) - layoutParams.topMargin <= if (clipToPadding) parentBottom else height) {
            lastVisibleChild++
        } else {
            lastVisibleChild--
            break
        }
    }

    for (i in childCount - 1 downTo lastVisibleChild + 1) removeAndRecycleViewAt(i, recycler)
    for (i in firstVisibleChild - 1 downTo 0) removeAndRecycleViewAt(i, recycler)
    anchorPosition += firstVisibleChild
}

8. View.setItemLayoutParams

아이템 사이의 margin을 주고 싶다면? 포지션 별로 layoutParams를 두고 원하는 조건 하에 마진 값을 주도록 조정하자.

 

private fun View.setItemLayoutParams(position: Int) {
    val leftMargin = if ((position + 1) % 3 == 1) {
        0
    } else {
        1
    }

    val rightMargin = if ((position + 1) % 3 == 0) {
        0
    } else {
        1
    }

    layoutParams().apply {
        setMargins(leftMargin, 1, rightMargin, 1)
    }
}

9. layoutView

포지션 별로 그리고자 하는 아이템의 width, height을 결정하는 곳. Gravity는 반드시 사용할 필요 없으며, 여기도 역시 기호에 맞게 포지션 별 width, height을 조정하면 된다. 

private fun layoutView(view: View, top: Int, bottom: Int) {
   view.translationZ = view.layoutParams().zIndex
   val (left, right) = when (view.layoutParams().gravity) {
       Gravity.LEFT -> parentLeft to (if (view.layoutParams().spanSize == 1) parentMiddle else parentRight)
       Gravity.RIGHT -> (if (view.layoutParams().spanSize == 1) parentMiddle else parentLeft) to parentRight
   }
   layoutDecoratedWithMargins(view, left, top, right, bottom)
}

 

왠만하면 건드리지 말자

1. anchorPosition

anchorPosition은 상하단 스크롤 및 이에 따른 아이템 Drawing과 강하게 엮여있기 때문에, anchorPosition을 건드리면 item change에 따라 스크롤이 버벅인다거나, 스크롤중 잘못된 계산으로 인해 ANR에 빠지는 등 다양한 이슈가 발생할 수 있다.. 고로 건드리지 말 것.

 

2. hardBottom, softBottom, hardTop, softTop

아이템을 순차적으로 그릴때 마지막으로 그렸던 아이템의 bottom, top line을 참조하여 그리는 변수로써 hard와 soft를 레퍼런스처럼 함께 사용해야 올바르게 아이템을 그릴 수 있다.