[Android / Kotlin] RecyclerView와 Sticky Header

Subeen·2024년 1월 14일
0

Android

목록 보기
41/73

Sticky Header 란?

Sticky Header란 사용자가 페이지를 아래로 스크롤 할 때 화면 상단에 고정 된 뷰를 말한다.
ItemDecoration을 사용하여 상단의 Top Holder를 RecyclerView 위에 그려 고정 된 것처럼 보이게 하는 코드를 정리해보겠다.

결과 화면

ItemDecoration


// 애니메이션 효과를 주기 위해 ItemDecoration을 상속 받아 구현한다.
class StickHeaderItemDecoration(private val sectionCallback: SectionCallback) : ItemDecoration() {

    /*
     * ItemDecoration에서는 adapter에 직접 접근하지 않아야 한다.
     * Interface를 생성하여 adapter에 필요한 정보를 가져온다.
     */
    interface SectionCallback {
        fun isHeader(position: Int): Boolean // 해당 position이 Header이고 고정 될 View인지 판단한다.
        fun getHeadLayoutView(list: RecyclerView, position: Int): View? // 해당 position에 해당 하는 뷰를 가져온다.
    }

    /*
     * RecyclerView 위에 새로운 뷰를 그린다.
     * onDrawOver 함수는 RecyclerView가 그려진 뒤에 호출 된다.
     */
    override fun onDrawOver(c: Canvas, parent: RecyclerView, state: RecyclerView.State) {
        super.onDrawOver(c, parent, state)

        val topChild = parent.getChildAt(0) ?: return // RecyclerView에 보이는 View의 0번째를 가져온다.
        val topChildPosition = parent.getChildAdapterPosition(topChild) // topChild에 해당하는 position을 가져온다.
        if (topChildPosition == RecyclerView.NO_POSITION) {
            return
        }

        /*
         * Header
         * getHeaderLayoutView를 사용해 topChildPosition에 해당하는 뷰를 찾는다.
         */
        val currentHeader: View =
            sectionCallback.getHeadLayoutView(parent, topChildPosition) ?: return

        // View의 레이아웃 설정
        fixLayoutSize(parent, currentHeader, topChild.measuredHeight)

        val contactPoint = currentHeader.bottom // 현재 topChildPosition에 해당하는 뷰의 bottom을 구한다.


        val childInContact: View = getChildInContact(parent, contactPoint) ?: return

        // 인접한 뷰를 구하고 childInContact에 해당하는 position을 가져온다.
        val childAdapterPoint = parent.getChildAdapterPosition(childInContact)
        if (childAdapterPoint == -1) {
            return
        }

        /*
         * childAdapterPosition은 리스트뷰의 최 상단에 있을 때 moveHeader로 밀려나는 것 처럼 그리고,
         * 그 외에는 상단에 고정되어 있는 것처럼 보이도록 drawHeader로 그린다.
         */
        when {
            sectionCallback.isHeader(childAdapterPoint) -> moveHeader(
                c,
                currentHeader,
                childInContact
            )

            else -> drawHeader(c, currentHeader)
        }
    }

    private fun getChildInContact(parent: RecyclerView, contactPoint: Int): View? {
        var childInContact: View? = null
        for (i in 0 until parent.childCount) {
            val child = parent.getChildAt(i)
            if (child.bottom > contactPoint) {
                if (child.top <= contactPoint) {
                    childInContact = child
                    break
                }
            }
        }
        return childInContact
    }


    private fun moveHeader(c: Canvas, currentHeader: View, nextHeader: View) {
        c.save()
        c.translate(0f, nextHeader.top - currentHeader.height.toFloat())
        currentHeader.draw(c)
        c.restore()
    }

    private fun drawHeader(c: Canvas, header: View) {
        c.save()
        c.translate(0f, 0f)
        header.draw(c)
        c.restore()
    }

    private fun fixLayoutSize(parent: ViewGroup, view: View, height: Int) {
        val widthSpec = View.MeasureSpec.makeMeasureSpec(
            parent.width,
            View.MeasureSpec.EXACTLY
        )
        val heightSpec = View.MeasureSpec.makeMeasureSpec(
            parent.height,
            View.MeasureSpec.EXACTLY
        )
        val childWidth: Int = ViewGroup.getChildMeasureSpec(
            widthSpec,
            parent.paddingLeft + parent.paddingRight,
            view.layoutParams.width
        )
        val childHeight: Int = ViewGroup.getChildMeasureSpec(
            heightSpec,
            parent.paddingTop + parent.paddingBottom,
            height
        )
        view.measure(childWidth, childHeight)
        view.layout(0, 0, view.measuredWidth, view.measuredHeight)
    }
}

Adapter

class StickyHeaderAdapter(private val items: MutableList<Items>) :
    RecyclerView.Adapter<RecyclerView.ViewHolder>() {
    companion object {
        private const val VIEW_TYPE_HEADER = 0
        private const val VIEW_TYPE_ITEM = 1
    }

	/**
    getItemViewType의 리턴값 Int가 viewType으로 넘어온다.
    viewType으로 넘어오는 값에 따라 viewHolder를 알맞게 처리해주면 된다.
     */
    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
        val inflater = LayoutInflater.from(parent.context)
        return when (viewType) {
            VIEW_TYPE_HEADER -> {
                val binding = ItemHeaderBinding.inflate(inflater, parent, false)
                HeaderViewHolder(binding)
            }

            else -> {
                val binding = ItemItemBinding.inflate(inflater, parent, false)
                ItemViewHolder(binding)
            }
        }
    }

	// 데이터의 index
    override fun getItemId(position: Int): Long {
        return position.toLong()
    }

    override fun getItemCount(): Int {
        return items.size
    }

    override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
        when (val item = items[position]) {
            is Items.TitleItem -> {
                (holder as StickyHeaderAdapter.HeaderViewHolder).header.text = item.title
            }

            is Items.ContentItem -> {
                (holder as StickyHeaderAdapter.ItemViewHolder).item.text = item.content
            }
        }
    }

    override fun getItemViewType(position: Int): Int {
        return when (items[position]) {
            is Items.TitleItem -> VIEW_TYPE_HEADER
            is Items.ContentItem -> VIEW_TYPE_ITEM
        }
    }

    inner class HeaderViewHolder(binding: ItemHeaderBinding) :
        RecyclerView.ViewHolder(binding.root) {
        val header = binding.tvHeader
    }

    inner class ItemViewHolder(binding: ItemItemBinding) :
        RecyclerView.ViewHolder(binding.root) {
        val item = binding.tvItem
    }

    fun isHeader(position: Int) = getItemViewType(position) == VIEW_TYPE_HEADER

    // RecyclerView 위에 그려줄 View를 반환한다.
    fun getHeaderView(list: RecyclerView, position: Int): View? {
        val lastIndex = if (position < items.size) position else items.size - 1

        for (index in lastIndex downTo 0) {
            if (getItemViewType(index) == VIEW_TYPE_HEADER) {
                val titleItem = items[index] as? Items.TitleItem
                if (titleItem != null) {
                    val binding = ItemHeaderBinding.inflate(LayoutInflater.from(list.context), list, false)
                    binding.tvHeader.text = titleItem.title
                    return binding.root
                }
            }
        }

        return null
    }
}

Activity

class StickyHeaderActivity : AppCompatActivity() {
    private val binding: ActivityStickyHeaderBinding by lazy {
        ActivityStickyHeaderBinding.inflate(layoutInflater)
    }

    private lateinit var adapter: StickyHeaderAdapter

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(binding.root)

        initView()
    }

    private fun initView() {
    	// 샘플 데이터 리스트
        val dataList = mutableListOf( 
            Items.TitleItem("Header 1"),
            Items.ContentItem("Item 1-1"),
            Items.ContentItem("Item 1-2"),
            Items.ContentItem("Item 1-3"),
            Items.ContentItem("Item 1-4"),
            Items.ContentItem("Item 1-5"),
            Items.TitleItem("Header 2"),
			...
        )

        adapter = StickyHeaderAdapter(dataList) // adapter 생성
        binding.recyclerView.adapter = adapter	// adapter 연결
        binding.recyclerView.layoutManager = LinearLayoutManager(this)
        binding.recyclerView.addItemDecoration(StickHeaderItemDecoration(getSectionCallback()))
    }

    private fun getSectionCallback(): StickHeaderItemDecoration.SectionCallback {
        return object : StickHeaderItemDecoration.SectionCallback {
            override fun isHeader(position: Int): Boolean {
                return adapter.isHeader(position)
            }

            override fun getHeadLayoutView(list: RecyclerView, position: Int): View? {
                return adapter.getHeaderView(list, position)
            }
        }
    }

}

data class

sealed class Items {
    data class TitleItem(val title: String) : Items() // header
    data class ContentItem(val content: String) : Items() // item 
}

참조
[Android] Sticky Header RecyclerView 응용하기
Sticky Header Recyclerview using ItemDecoration without library

profile
개발 공부 기록 🌱

1개의 댓글