들어가는 글
RecyclerView.ItemDecoration의 onDrawOver를 응용해 StickyHeader를 구현하는 방법은 헤더의 버튼들이 동작하지 않습니다. 요구사항을 맞추기 위해 실제 헤더 View를 추가하는 방식으로 StickyHeader를 구현해봤습니다.
아래 포스트를 참고하여 개발했습니다.
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
'Android' 카테고리의 다른 글
| [Android] Lifecycle-aware BroadcastReceiver 구현하기 (0) | 2025.05.19 |
|---|---|
| [Android] View에서 by viewModels() 사용하기 (0) | 2025.04.07 |
| [Android] FlexboxLayout 초과된 줄 숨김 처리 Extension (0) | 2025.04.04 |
| [Android] Jetpack Compose Radar Chart (1) | 2024.10.16 |
| [Android] Compose Scrollable Column에서 처음 보여지는 타이밍 캐치하기 (0) | 2024.07.05 |