[Android] Sticky Header RecyclerView 응용하기
Android

[Android] Sticky Header RecyclerView 응용하기

728x90

 

 

 이번 포스팅에는 위와 같은 애니메이션 효과를 구현할 것이다. 위의 구글 캘린더 앱을 보면 왼쪽의 날짜와 요일이 좌측 상단에 고정되어 표시되고 있다. 같은 요일의 일정들이 있으면 좌측의 날짜는 고정이 되고 다음 요일로 지나가게 되면 위로 밀려 올라가는 방식이다.

 

Sticky Header RecyclerView

 프로젝트에서 위와 같은 애니메이션을 적용해야 했을 때 Sticky Header RecyclerView가 떠올랐다. 보통의 Sticky Header RecyclerView는 위의 사진과 같이 RecyclerView에서 위의 헤더를 고정시킬 때 많이 사용한다. 위와 같은 방식을 조금 응용하면 쉽게 구현할 수 있을 듯 했다.

 Sticky Header RecyclerView는 RecyclerView에 ItemDecoration을 추가하는 방식으로 사용한다. RecyclerView의 어댑터와 뷰홀더를 통해 애니메이션 효과를 내는 것이 아니라 ItemDecoration의 onDrawOver() 함수로 RecyclerView의 위에 그리게 된다.

 

 

 

StickHeaderItemDecoration.kt

class StickyHeaderItemDecoration(private val sectionCallback: SectionCallback) : ItemDecoration() {

    override fun onDrawOver(c: Canvas, parent: RecyclerView, state: RecyclerView.State) {
        super.onDrawOver(c, parent, state)

        val topChild = parent.getChildAt(0) ?: return

        val topChildPosition = parent.getChildAdapterPosition(topChild)
        if (topChildPosition == RecyclerView.NO_POSITION) {
            return
        }

        /* 헤더 */
        val currentHeader: View = sectionCallback.getHeaderLayoutView(parent, topChildPosition) ?: return

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

        val contactPoint = currentHeader.bottom

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

        val childAdapterPosition = parent.getChildAdapterPosition(childInContact)
        if (childAdapterPosition == -1)
            return

        when {
            sectionCallback.isHeader(childAdapterPosition) ->
                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()
    }

    /**
     * Measures the header view to make sure its size is greater than 0 and will be drawn
     * https://yoda.entelect.co.za/view/9627/how-to-android-recyclerview-item-decorations
     */
    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)
    }

    interface SectionCallback {
        fun isHeader(position: Int): Boolean
        fun getHeaderLayoutView(list: RecyclerView, position: Int): View?
    }
}

 애니메이션 효과를 주기 위해 ItemDecoration을 상속 받아 구현해준다. 앞서 설명한대로 onDrawOver() 함수를 통해 RecyclerView 위에 새로운 뷰를 그려준다.

 뷰를 그려주기 위해선 Header가 고정되어 있는지, 움직여야 하는지를 판단할 함수가 필요하다. 또한 어떤 뷰를 그려줄지를 반환하는 함수도 필요하다. 이 두개의 함수는 SectionCallback 인터페이스를 통해 외부에서 주입 받도록 한다.

 

 

MainActivity.kt

class MainActivity : AppCompatActivity() {

    private lateinit var adapter: StickyHeaderAdapter

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        adapter = StickyHeaderAdapter(events)

        val recyclerView = findViewById<RecyclerView>(R.id.recyclerView)

        recyclerView.layoutManager = LinearLayoutManager(this)
        recyclerView.adapter = adapter

        recyclerView.addItemDecoration(StickyHeaderItemDecoration(getSectionCallback()))
    }

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

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

    /* sample */
    private val events: List<Event>
        get() {
            val result = mutableListOf<Event>()
            for (i in 0 until 100) {
                result.add(Event(i))
            }
            return result
        }
}

 

 

StickyHeaderAdapter.kt

class StickyHeaderAdapter(
    private val items: List<Event>
) : RecyclerView.Adapter<StickyHeaderAdapter.StickyHeaderViewHolder>() {

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = StickyHeaderViewHolder(
        Item1Binding.inflate(
            LayoutInflater.from(parent.context),
            parent,
            false
        )
    )

    override fun getItemCount() = items.size

    override fun onBindViewHolder(holder: StickyHeaderViewHolder, position: Int) {
        holder.bind(items[position])
    }

    inner class StickyHeaderViewHolder(
        private val binding: Item1Binding
    ) : RecyclerView.ViewHolder(binding.root) {

        fun bind(event: Event) {
            binding.title.text = event.value.toString()
            binding.date.text = "${event.date}월"

            binding.date.visibility = if (event.isHeader) View.VISIBLE else View.GONE
        }
    }

    fun isHeader(position: Int) = items[position].isHeader

    fun getHeaderView(list: RecyclerView, position: Int): View? {
        val item = items[position]

        val binding = Item2Binding.inflate(LayoutInflater.from(list.context), list, false)
        binding.date.text = "${item.date}월"
        return binding.root
    }
}

 getHeaderView() 함수를 통해 RecyclerView 위에 그려줄 뷰를 반환해준다. 

 

 

 

Event.kt

data class Event(val value: Int) {
    val date: Int
        get() = value / 10

    val isHeader: Boolean
        get() = value % 10 == 0
}

 

 

 위의 gif를 보면 이 방법을 좀 더 명확하게 파악할 수가 있다. '2월'이 Header에 붙고 좀 더 위로 올리면 '2월'이 분신처럼 쪼개져서 위로 더 올라가는 것을 확인할 수 있다. 이 같은 현상은 RecyclerView에 그려진 뷰와 그 위에 새로 그려진 뷰가 겹쳐지기 때문에 나온다.

 마지막으로 새로 그려진 뷰의 background 색을 흰색으로 적용하면 뒤에 있는 뷰를 가려주기 때문에 아래와 같은 결과를 얻을 수 있다.

 

 

최종 결과

 

 

예제 소스

https://github.com/tkdgusl94/blog-source/tree/master/StickyHeader

 

tkdgusl94/blog-source

https://leveloper.tistory.com/ 에서 제공하는 예제 source. Contribute to tkdgusl94/blog-source development by creating an account on GitHub.

github.com

 

참고

stackoverflow.com/questions/32949971/how-can-i-make-sticky-headers-in-recyclerview-without-external-lib

 

How can I make sticky headers in RecyclerView? (Without external lib)

I want to fix my header views in the top of the screen like in the image below and without using external libraries. In my case, I don't want to do it alphabetically. I have two different types of...

stackoverflow.com

 

728x90