RecyclerView.ItemDecoration을 이용한 StickyHeader 구현

외부 라이브러리없이 전체 코드로 구현한다.

 

ItemDecoration

RecyclerView를 더 커스텀하여 사용할 수 있게 해준다.

아이템 간의 구분선을 만들거나 여백을 조정할 수 있으며 해당 포스팅처럼 RecyclerView 자체에 그릴 수도 있다.

 

ItemDecoration의 onDrawOver 메소드를 오버라이딩하여 구현한다.

RecyclerView가 draw할 때마다 콜백으로 호출되며 RecyclerView 위에 그려지게 된다.

 

 

RecyclerView의 Header 아이템과 일반 아이템으로 구분이 되어야 한다.

ViewType은 따로 나누지 않아도 상관없으며 구분할 함수만 구현하면 된다.

 

 

 

 

 

 

 

ItemDecoration 코드와 사용 코드

 

 

HeaderItemDecoration.kt

 

더보기
class HeaderItemDecoration(
    recyclerView: RecyclerView,
    private val isHeader: (itemPosition: Int) -> Boolean,
    onClickHeader: ((itemPosition: Int) -> Unit)? = null
) : 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
        }
 
        // handle click on sticky header
        onClickHeader?.let {
            initHeaderClickListener(recyclerView, it)
        }
    }
 
    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(00, 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
            }
        })
    }
}
cs

 

 

사용 코드

 

        rv_main.addItemDecoration(
            HeaderItemDecoration(
                rv_main,
                { it%4 == 0 },
                { Toast.makeText(this, it.toString(), Toast.LENGTH_SHORT).show() }
            )
        )
cs

 

HeaderItemDecoration 클래스에

recyclerView, header 판단 함수, header 클릭시 동작 함수를 입력해 사용하면 된다.

 

 

 

 

onDrawOver

    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)
        }
    }
cs

 

onDrawOver 함수에 모든 핵심 내용이 다 있다.

 

1. recyclerView에서 보이는 가장 최상단 아이템 포지션을 얻는다.

2. 최상단 아이템 index 이하의 가장 가까운 header 아이템의 뷰를 얻는다.

3. (기존의 그리고 있는 header) 아래로 (접촉이 있는 새로운 header)가 있는지 확인한다.

 3-1. 있다면 새로운 header가 간섭한 만큼 위로 올려 그린다.

 3-2. 없다면 그대로 그린다.

 

만약 헤더끼리 접촉했을 때 하단 헤더가 상단 헤더를 밀도록 구현하는게 아닌 아래로 들어가듯이 구현하고 싶다면 contactedNewHeaer를 구할 필요없이 drawHeader만 사용하면 된다.

 

 

 

 

 

 

원본 코드 및 참고한 링크

 

원본코드

원본코드에서 필요하다고 생각하는 코드로만 다시 정리했다.

https://gist.github.com/filipkowicz/1a769001fae407b8813ab4387c42fcbd

 

Item Decorator for sticky headers in Kotlin

Item Decorator for sticky headers in Kotlin. GitHub Gist: instantly share code, notes, and snippets.

gist.github.com

 

참고한 링크

StickyHeader를 구현하는 여러 코드들이 있다.

https://www.it-swarm.dev/ko/android/recyclerview%EC%97%90%EC%84%9C-%EB%81%88%EC%A0%81%ED%95%9C-%ED%97%A4%EB%8D%94%EB%A5%BC-%EB%A7%8C%EB%93%A4%EB%A0%A4%EB%A9%B4-%EC%96%B4%EB%96%BB%EA%B2%8C%ED%95%B4%EC%95%BC%ED%95%A9%EB%8B%88%EA%B9%8C-%EC%99%B8%EB%B6%80-lib%EC%97%86%EC%9D%B4/1055032283/

 

android — RecyclerView에서 끈적한 헤더를 만들려면 어떻게해야합니까? (외부 lib없이)

에야디야, 이것은 스크린에서 벗어나기 시작할 때 한 종류의 홀더 스틱을 원한다면 (우리는 어떤 섹션도 신경 쓰지 않습니다) 당신이 그것을하는 방법입니다. 재활용 아이템의 내부 RecyclerView 로

www.it-swarm.dev

 

 

'Android > UI' 카테고리의 다른 글

BottomSheet  (0) 2020.08.10

+ Recent posts