Sticky Header란 사용자가 페이지를 아래로 스크롤 할 때 화면 상단에 고정 된 뷰를 말한다.
ItemDecoration
을 사용하여 상단의 Top Holder를 RecyclerView 위에 그려 고정 된 것처럼 보이게 하는 코드를 정리해보겠다.
// 애니메이션 효과를 주기 위해 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)
}
}
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
}
}
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)
}
}
}
}
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