코틀린(Kotlin)/TIL

[코틀린(Kotlin)] RecyclerView - StickyHeader, 스크롤 시 상단 뷰 고정

초보왕보초 2024. 1. 19. 20:06
728x90

라이브러리를 사용하지 않고 외부 코드를 인용해 StickyHeader를 사용하기

 

 

 

ItemDecoration

 

 

 

[GitHub 레포지토리]

 

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