728x90
라이브러리를 사용하지 않고 외부 코드를 인용해 StickyHeader를 사용하기
ItemDecoration
- RecyclerView의 커스텀 기능
ex) 아이템 간의 구분선을 만들기, 스크롤 이동 시 상단에 특정 뷰 고정 등
ItemDecoration 코드와 사용한 코드
HeaderItemDecoration.kt
더보기
package com.android.basic_recyclerview
import android.graphics.Canvas
import android.graphics.Rect
import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
class HeaderItemDecoration(
recyclerView: RecyclerView,
private val isHeader: (itemPosition: Int) -> Boolean,
) : RecyclerView.ItemDecoration() {
private var currentHeaderToShow: Pair<Int, RecyclerView.ViewHolder>? = null
init {
recyclerView.adapter?.registerAdapterDataObserver(object : RecyclerView.AdapterDataObserver() {
override fun onChanged() {
// clear saved header as it can be outdated now
currentHeaderToShow = null
}
})
recyclerView.addOnLayoutChangeListener { _, _, _, _, _, _, _, _, _ ->
// clear saved layout as it may need layout update
currentHeaderToShow = null
}
}
override fun onDrawOver(c: Canvas, recyclerView: RecyclerView, state: RecyclerView.State) {
super.onDrawOver(c, recyclerView, state)
val topChildView = getTopChildView(recyclerView) ?: return
val topChildViewPosition = recyclerView.getChildAdapterPosition(topChildView)
if (topChildViewPosition == RecyclerView.NO_POSITION) return
val headerView = getHeaderViewToShow(topChildViewPosition, recyclerView) ?: return
val contactedNewHeader = getContactedNewHeader(headerView, recyclerView)
if (contactedNewHeader != null) {
drawMovedHeader(c, headerView, contactedNewHeader, recyclerView.paddingTop)
} else {
drawHeader(c, headerView, recyclerView.paddingTop)
}
}
private fun getTopChildView(recyclerView: RecyclerView)
= recyclerView.findChildViewUnder(
recyclerView.paddingStart.toFloat(),
recyclerView.paddingTop.toFloat()
)
private fun getHeaderViewToShow(topChildItemPosition: Int, recyclerView: RecyclerView): View? {
recyclerView.adapter ?: return null
val headerPositionToShow = getHeaderPositionToShow(topChildItemPosition)
if (headerPositionToShow == RecyclerView.NO_POSITION) return null
return getHeaderView(headerPositionToShow, recyclerView)
}
private fun getHeaderView(headerPositionToShow: Int, recyclerView: RecyclerView): View? {
val headerHolderType = recyclerView.adapter!!.getItemViewType(headerPositionToShow)
if (currentHeaderToShow?.first == headerPositionToShow && currentHeaderToShow?.second?.itemViewType == headerHolderType) {
return currentHeaderToShow?.second?.itemView
}
val headerHolder = recyclerView.adapter!!.createViewHolder(recyclerView, headerHolderType)
recyclerView.adapter!!.onBindViewHolder(headerHolder, headerPositionToShow)
fixLayoutSize(recyclerView, headerHolder.itemView)
currentHeaderToShow = headerPositionToShow to headerHolder
return headerHolder.itemView
}
private fun getHeaderPositionToShow(topChildItemPosition: Int): Int {
var headerPosition = RecyclerView.NO_POSITION
var currentPosition = topChildItemPosition
do {
if (isHeader(currentPosition)) {
headerPosition = currentPosition
break
}
currentPosition -= 1
} while (currentPosition >= 0)
return headerPosition
}
private fun fixLayoutSize(parent: ViewGroup, view: View) {
// Specs for parent (RecyclerView)
val widthSpec = View.MeasureSpec.makeMeasureSpec(parent.width, View.MeasureSpec.EXACTLY)
val heightSpec = View.MeasureSpec.makeMeasureSpec(parent.height, View.MeasureSpec.UNSPECIFIED)
// Specs for children (headers)
val childWidthSpec = ViewGroup.getChildMeasureSpec(
widthSpec,
parent.paddingStart + parent.paddingEnd,
view.layoutParams.width
)
val childHeightSpec = ViewGroup.getChildMeasureSpec(
heightSpec,
parent.paddingTop + parent.paddingBottom,
view.layoutParams.height
)
view.measure(childWidthSpec, childHeightSpec)
view.layout(0, 0, view.measuredWidth, view.measuredHeight)
}
private fun drawHeader(c: Canvas, header: View, paddingTop: Int) {
c.save()
c.translate(0f, paddingTop.toFloat())
header.draw(c)
c.restore()
}
private fun getContactedNewHeader(headerView: View, recyclerView: RecyclerView): View? {
val contactPoint = headerView.bottom + recyclerView.paddingTop
val contactedChildView = getContactedChildView(recyclerView, contactPoint) ?: return null
val contactedChildViewPosition = recyclerView.getChildAdapterPosition(contactedChildView)
return if (isHeader(contactedChildViewPosition)) {
contactedChildView
} else {
null
}
}
private fun getContactedChildView(recyclerView: RecyclerView, contactPoint: Int): View? {
var childInContact: View? = null
for (i in 0 until recyclerView.childCount) {
val child = recyclerView.getChildAt(i)
val bounds = Rect()
recyclerView.getDecoratedBoundsWithMargins(child, bounds)
if (bounds.bottom > contactPoint) {
if (bounds.top <= contactPoint) {
childInContact = child
break
}
}
}
return childInContact
}
private fun drawMovedHeader(c: Canvas, contactedTopHeader: View, contactedBottomHeader: View, paddingTop: Int) {
c.save()
c.translate(0f, (contactedBottomHeader.top - contactedTopHeader.height).toFloat())
contactedTopHeader.draw(c)
c.restore()
}
private fun initHeaderClickListener(recyclerView: RecyclerView, onClickHeader: (Int) -> Unit) {
recyclerView.addOnItemTouchListener(object : RecyclerView.SimpleOnItemTouchListener() {
override fun onInterceptTouchEvent(
recyclerView: RecyclerView,
motionEvent: MotionEvent
): Boolean {
return if (motionEvent.action == MotionEvent.ACTION_DOWN) {
val hasClickedOnHeaderArea = motionEvent.y <= currentHeaderToShow?.second?.itemView?.bottom ?: 0
if (hasClickedOnHeaderArea) {
currentHeaderToShow?.first?.let { position ->
onClickHeader.invoke(position)
}
true
} else {
false
}
} else false
}
})
}
}
override fun onDrawOver(c: Canvas, recyclerView: RecyclerView, state: RecyclerView.State) {
super.onDrawOver(c, recyclerView, state)
// recyclerView에서 보이는 가장 최상단 아이템 포지션을 얻는다
val topChildView = getTopChildView(recyclerView) ?: return
// 최상단 아이템 index 이하의 가장 가까운 header 아이템의 뷰를 얻는다
val topChildViewPosition = recyclerView.getChildAdapterPosition(topChildView)
// 기존에 있는 header 아래로 새로운 header가 접촉이 있는지 확인한다
if (topChildViewPosition == RecyclerView.NO_POSITION) return
val headerView = getHeaderViewToShow(topChildViewPosition, recyclerView) ?: return
val contactedNewHeader = getContactedNewHeader(headerView, recyclerView)
// 새로운 header가 있으면 간섭한 만큼 위로 올리며 그린다
if (contactedNewHeader != null) {
drawMovedHeader(c, headerView, contactedNewHeader, recyclerView.paddingTop)
// 새로운 header가 없으면 그대로 그린다
} else {
drawHeader(c, headerView, recyclerView.paddingTop)
}
}
- ItemDecoration의 onDrawOver 메서드를 오버라이딩해서 구현하는데 이게 상당히 중요하다
- RecyclerView가 draw 할 때마다 콜백으로 호출되며 RecyclerView 위에 그려진다
MainActivity.kt 에서 추가한 코드
더보기
binding.recyclerView.addItemDecoration(HeaderItemDecoration(binding.recyclerView) {
itemPosition: Int -> dataList[itemPosition] is DogItems.MyTitle
})
binding.recyclerView.addItemDecoration(DividerItemDecoration(this,LinearLayout.VERTICAL))
- HeaderItemDecoration(recyclerView, Header 판단 함수)
이 코드를 찾기 전에 구글링 하다가 MyAdapter.kt 에서 StickyId 인터페이스 추가, inner class 들한테 bind() 메서드 추가해 주고 onBindViewHolder()에 bind() 하는 것들도 해보다가 도저히 안 됐었다..
그렇게 계속 구글링을 하다가 참고 사이트에서 본 코드가 있길래 써봤고, onClickHeader(헤더 클릭 시 이벤트)는 필요 없어서 삭제해 주고 그대로 써봤는데, {it%4 == 0}만 바꾸면 될 것 같은 느낌이 들었다.
그런데 이 과정에서도 몇 시간을 썼고, 나와 똑같은 코드로 문제를 해결하신 분께 여쭤봤다
해답은,
HeaderItemDecoration클래스에서 isHeader: (itemPosition: Int) → Boolean을 따라 써보는 것이었다..
- itemPosition: Int ->
- Int 타입의 아이템 포지션을 받아오는데 이게 Boolean 타입 이어야 한다 - dataList[position]
- dataList(본문에서는 헤더와 노멀 아이템이 있는 더미데이터)의 포지션값(Int) - is DogItems.MyTitle
- Boolean 타입이어야 하므로 is를 사용하여 자료형을 체크해 준다
- DogItems.MyTitle은 본문에서 더미데이터.헤더를 의미한다
[원본 코드]
[참고 사이트]
728x90
'코틀린(Kotlin) > TIL' 카테고리의 다른 글
[코틀린(Kotlin)] 사용자의 위치 얻기 (0) | 2024.01.25 |
---|---|
[코틀린(Kotlin)] 데이터 저장 - SharedPreference, Room (0) | 2024.01.24 |
[코틀린(Kotlin)] RecyclerView에 Divider 구분선 넣기, DividerItemDecoration (0) | 2024.01.18 |
[코틀린(Kotlin)] getParcelableExtra() 실선, 버전 별 다르게 적용 (0) | 2024.01.15 |
[코틀린(Kotlin)] Parcelize와 사용법 (0) | 2024.01.12 |