현재 안드로이드에서는 RecyclerView와 사용 가능한 세 개의 LayoutManager를 제공하고 있다.(GridLayoutManager, LinearLayoutManager, StaggeredLayoutManager)
하지만 경우에 따라 여기중 어느 것으로도 원하는 아이템 구성을 RecyclerView에 적용하지 못할 수 있다.
그렇다면 이제.. 직접 커스텀 LayoutManager를 구성해야만 한다!
이번 작업을 위해 아래의 포스트를 참조했다.
이제 직접 해야할 때
커스텀 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를 레퍼런스처럼 함께 사용해야 올바르게 아이템을 그릴 수 있다.
'ANDROID > UI - UX' 카테고리의 다른 글
[Android/UI-UX] 텍스트의 일부에 스타일 및 터치 이벤트 설정하기 (0) | 2022.02.24 |
---|---|
[Android/UI-UX] 유동적인 TextView의 사이즈를 제한하고, 정렬까지(with ConstraintLayout) (0) | 2021.03.09 |
[안드로이드/UI-UX] Material BadgeDrawable 로 Badge를 원하는 뷰에 적용하기 (0) | 2020.10.14 |
[안드로이드] Material Design의 Chips를 사용해보자 (0) | 2020.05.16 |
[안드로이드 | 코틀린 ] 뷰가 겹치는 상황에서 터치 우선순위 관리하기 (0) | 2020.04.14 |