본문 바로가기

Android

[Android] Clickable RecyclerView StickyHeader

들어가는 글

 RecyclerView.ItemDecoration의 onDrawOver를 응용해 StickyHeader를 구현하는 방법은 헤더의 버튼들이 동작하지 않습니다. 요구사항을 맞추기 위해 실제 헤더 View를 추가하는 방식으로 StickyHeader를 구현해봤습니다.

 아래 포스트를 참고하여 개발했습니다.

https://medium.com/@preethamivan473/sticky-headers-with-click-interactions-using-android-recycler-view-40e583a31605

 

Sticky Headers with click interactions using Android Recycler View

Several times, we need to implement a sticky header for some list of data displayed in RecyclerView. Android doesn't have a native UI…

medium.com

소스코드

resources/ids.xml

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <item name="tag_viewholder" type="id" />
    <item name="tag_position" type="id" />
    <item name="tag_viewtype" type="id" />
</resources>
import android.view.View
import android.view.View.MeasureSpec
import android.view.ViewGroup
import androidx.core.view.children
import androidx.core.view.isNotEmpty
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView

class StickyHeaderItemDecorator<VH : RecyclerView.ViewHolder> {

    private lateinit var listener: StickyHeaderInterface
    private lateinit var recyclerView: RecyclerView
    private lateinit var adapter: RecyclerView.Adapter<VH>
    private lateinit var stickyHeaderContainer: ViewGroup

    fun attachRecyclerView(
        stickyHeaderContainer: ViewGroup,
        recyclerView: RecyclerView,
        listener: StickyHeaderInterface,
        adapter: RecyclerView.Adapter<VH>
    ) {
        this.stickyHeaderContainer = stickyHeaderContainer
        this.listener = listener
        this.recyclerView = recyclerView
        this.adapter = adapter
        initContainer()
        clearHeaderViews()
        redrawHeader()
    }

    private fun initContainer() {
        recyclerView.addOnScrollListener(onScrollChangeListener)
    }

    private fun clearHeaderViews() {
        stickyHeaderContainer.removeAllViews()
    }

    private fun addHeaderViewFromPosition(position: Int): View {
        stickyHeaderContainer.let {
            val viewType = adapter.getItemViewType(position)
            val vh = adapter.createViewHolder(it, viewType)
            adapter.bindViewHolder(vh, position)
            val view = vh.itemView
            view.setTag(R.id.tag_position, position)
            view.setTag(R.id.tag_viewholder, vh)
            view.setTag(R.id.tag_viewtype, viewType)
            it.addView(
                view,
                ViewGroup.LayoutParams.MATCH_PARENT,
                ViewGroup.LayoutParams.WRAP_CONTENT
            )
            recyclerView.post { it.requestLayout() }
            return view
        }
    }

    private fun drawHeaders(refresh: Boolean) {
        val stickyHeaderContainer = stickyHeaderContainer
        val headerPosition = findHeaderPosition() ?: run {
            stickyHeaderContainer.let {
                if (it.isNotEmpty()) it.removeAllViews()
            }
            return
        }
        val headerViewType = adapter.getItemViewType(headerPosition)
        var exist = false
        var headerView: View? = null
        for (i in 0 until stickyHeaderContainer.childCount) {
            val child = stickyHeaderContainer.getChildAt(i) ?: continue
            if (child.getTag(R.id.tag_position) == headerPosition && child.getTag(R.id.tag_viewtype) == headerViewType) {
                headerView = child
                if (refresh) {
                    adapter.onBindViewHolder(
                        child.getTag(R.id.tag_viewholder) as VH,
                        headerPosition
                    )
                }
                exist = true
            } else {
                stickyHeaderContainer.removeView(child)
            }
        }
        if (exist.not()) {
            headerView = addHeaderViewFromPosition(headerPosition)
        }
        if (headerView == null) return
        val nextHeaderPosition = findNextHeaderPosition() ?: run {
            headerView.translationY = 0f
            return
        }
        recyclerView.children.forEach { child ->
            val viewHolder = recyclerView.getChildViewHolder(child)
            if (viewHolder.bindingAdapterPosition == nextHeaderPosition) {
                headerView.measure(
                    MeasureSpec.makeMeasureSpec(
                        stickyHeaderContainer.width,
                        MeasureSpec.EXACTLY
                    ),
                    MeasureSpec.makeMeasureSpec(stickyHeaderContainer.height, MeasureSpec.AT_MOST)
                )
                val top = child.top
                headerView.translationY =
                    (top - headerView.measuredHeight).coerceAtMost(0).toFloat()
            }
        }
    }

    private val onScrollChangeListener: RecyclerView.OnScrollListener =
        object : RecyclerView.OnScrollListener() {
            override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
                super.onScrolled(recyclerView, dx, dy)
                redrawHeader()
            }
        }

    fun redrawHeader(refresh: Boolean = false) {
        recyclerView.post {
            drawHeaders(refresh)
        }
    }

    fun clearReferences() {
        recyclerView.removeOnScrollListener(onScrollChangeListener)
    }

    private fun findHeaderPosition(): Int? {
        val firstVisibleIndex =
            (recyclerView.layoutManager as LinearLayoutManager).findFirstVisibleItemPosition()
        val firstCompletelyVisibleIndex =
            (recyclerView.layoutManager as LinearLayoutManager).findFirstCompletelyVisibleItemPosition()
        if (firstVisibleIndex == RecyclerView.NO_POSITION) return null
        for (i in firstVisibleIndex downTo 0) {
            if (listener.isHeader(i)) {
                if (i == firstCompletelyVisibleIndex) return null
                return i
            }
        }
        return null
    }

    private fun findNextHeaderPosition(): Int? {
        val layoutManager = (recyclerView.layoutManager as LinearLayoutManager)
        val firstVisibleIndex = layoutManager.findFirstVisibleItemPosition()
        val lastVisibleIndex = layoutManager.findLastVisibleItemPosition()
        if (firstVisibleIndex == RecyclerView.NO_POSITION || lastVisibleIndex == RecyclerView.NO_POSITION) return null
        for (i in (firstVisibleIndex + 1)..lastVisibleIndex) {
            if (listener.isHeader(i)) return i
        }
        return null
    }

    interface StickyHeaderInterface {
        fun isHeader(itemPosition: Int): Boolean
    }
}
import android.view.LayoutInflater
import android.view.ViewGroup
import android.widget.TextView
import android.widget.Toast
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import com.bjj.clickablestickyheader.databinding.ItemContentBinding
import com.bjj.clickablestickyheader.databinding.ItemHeaderBinding

private val diffCallback = object : DiffUtil.ItemCallback<Item>() {
    override fun areItemsTheSame(oldItem: Item, newItem: Item): Boolean = oldItem == newItem

    override fun areContentsTheSame(oldItem: Item, newItem: Item): Boolean = oldItem == newItem
}

class SampleAdapter : ListAdapter<Item, RecyclerView.ViewHolder>(diffCallback),
    StickyHeaderItemDecorator.StickyHeaderInterface {
    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
        val layoutInflater = LayoutInflater.from(parent.context)
        return when (viewType) {
            0 -> HeaderViewHolder(ItemHeaderBinding.inflate(layoutInflater, parent, false).root)
            1 -> ContentViewHolder(ItemContentBinding.inflate(layoutInflater, parent, false).root)
            else -> throw IllegalArgumentException("Invalid view type")
        }
    }

    override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
        val text = getItem(position).text
        when (holder) {
            is HeaderViewHolder -> {
                holder.bind(text)
                holder.itemView.setOnClickListener {
                    Toast.makeText(holder.itemView.context, "$text clicked", Toast.LENGTH_SHORT)
                        .show()
                }
            }

            is ContentViewHolder -> holder.bind(text)
        }
    }

    override fun getItemViewType(position: Int): Int {
        return when (getItem(position)) {
            is Item.Header -> 0
            is Item.Content -> 1
        }
    }

    override fun isHeader(itemPosition: Int): Boolean {
        if (itemPosition < 0 || itemPosition >= itemCount) return false
        return getItem(itemPosition) is Item.Header
    }
}

class HeaderViewHolder(private val view: TextView) : RecyclerView.ViewHolder(view) {
    fun bind(text: String) {
        view.text = text
    }
}

class ContentViewHolder(private val view: TextView) : RecyclerView.ViewHolder(view) {
    fun bind(text: String) {
        view.text = text
    }
}
import android.os.Bundle
import androidx.activity.enableEdgeToEdge
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.bjj.clickablestickyheader.databinding.ActivityMainBinding

class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        val binding = ActivityMainBinding.inflate(layoutInflater)
        setContentView(binding.root)
        ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.main)) { v, insets ->
            val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
            v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom)
            insets
        }
        val adapter = SampleAdapter()
        binding.recyclerview.adapter = adapter
        binding.recyclerview.layoutManager = LinearLayoutManager(this)
        val stickyHeaderItemDecorator = StickyHeaderItemDecorator<RecyclerView.ViewHolder>()
        stickyHeaderItemDecorator.attachRecyclerView(
            binding.headerContainer,
            binding.recyclerview,
            adapter,
            adapter
        )
        // Diff 처리 완료 후 ItemDecorator rebind 처리
        adapter.submitList(getTestItems()) {
            stickyHeaderItemDecorator.redrawHeader(refresh = true)
        }
    }
}

private fun getTestItems(): List<Item> {
    return buildList {
        repeat(100) {
            if (Math.random() > 0.1) {
                add(Item.Content("Content: $it"))
            } else {
                add(Item.Header("Header: $it"))
            }
        }
    }
}

주의!

StickHeaderItemDecorator에서 생성한 ViewHolder의 경우 bindingAdapterPosition 등 position 접근이 안되기 때문에

onCreateViewHolder 때가 아닌 onBindViewHolder에서 Click Listener가 등록되어야 합니다

https://github.com/bjj3036/ClickableStickyHeader

 

GitHub - bjj3036/ClickableStickyHeader

Contribute to bjj3036/ClickableStickyHeader development by creating an account on GitHub.

github.com