XML
[Android] MultiViewType으로 Grid RecyclerView에 Header 추가하기
Marchbreeze
2025. 5. 16. 00:44
2024년 6월 12일에 작성되었던 글입니다.
구현 영상
다음 영상과 같이, 한 줄에 2개의 상품아이템을 표시하는 리스트의 상단에 전체 열을 차지하는 헤더를 배치했습니다.
1. MultiViewType 형태의 Adapter 구성
MultiViewType
RecyclerView에서 서로 다른 레이아웃(헤더, 상품 아이템 등)을 한 어댑터로 처리할 때 사용합니다.
다음과 같이 Adapter의 getItemViewType()와 onCreateViewHolder()에서 뷰 타입별로 분기합니다.
class HomeAdapter(
private val bannerClick: (Unit) -> Unit,
private val productClick: (Unit) -> Unit,
private val likeClick: (Unit) -> Unit,
) : ListAdapter<ProductModel, RecyclerView.ViewHolder>(diffUtil) {
private var itemList = mutableListOf<ProductModel>()
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
val inflater by lazy { LayoutInflater.from(parent.context) }
return when (viewType) {
VIEW_TYPE_BANNER ->
HomeBannerViewHolder(
ItemHomeBannerBinding.inflate(inflater, parent, false),
bannerClick
)
VIEW_TYPE_PRODUCT ->
HomeProductViewHolder(
ItemHomeProductBinding.inflate(inflater, parent, false),
productClick,
likeClick,
)
else -> throw ClassCastException("Unknown view type: $viewType")
}
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
when (holder) {
is HomeBannerViewHolder -> holder.onBind()
is HomeProductViewHolder -> {
val itemPosition = position - HEADER_COUNT
holder.onBind(itemList[itemPosition])
}
}
}
override fun getItemCount() = itemList.size + HEADER_COUNT
override fun getItemViewType(position: Int) =
if (position == 0) VIEW_TYPE_BANNER else VIEW_TYPE_PRODUCT
fun setItemList(itemList: List<ProductModel>) {
this.itemList = itemList.toMutableList()
notifyDataSetChanged()
}
companion object {
private val diffUtil = ItemDiffCallback<ProductModel>(
onItemsTheSame = { old, new -> old.productId == new.productId },
onContentsTheSame = { old, new -> old == new },
)
const val HEADER_COUNT = 1
const val VIEW_TYPE_BANNER = 0
const val VIEW_TYPE_PRODUCT = 1
}
}
class ItemDiffCallback<T : Any>(
val onItemsTheSame: (T, T) -> Boolean,
val onContentsTheSame: (T, T) -> Boolean,
) : DiffUtil.ItemCallback<T>() {
override fun areItemsTheSame(
oldItem: T,
newItem: T,
): Boolean = onItemsTheSame(oldItem, newItem)
override fun areContentsTheSame(
oldItem: T,
newItem: T,
): Boolean = onContentsTheSame(oldItem, newItem)
}
- ListAdapter를 상속해 DiffUtil 기반으로 변경 사항만 갱신하도록 최적화했습니다.
- getItemViewType()에서 헤더는 첫 번째 위치에만, 나머지는 상품 뷰 타입으로 지정했습니다.
- getItemCount()에 HEADER_COUNT(1)을 더해, 아이템 리스트 크기보다 한 칸 더 커지도록 했습니다.
- onBindViewHolder()에서 position - HEADER_COUNT로 실제 상품 리스트의 인덱스를 보정했습니다.
- 각 뷰홀더는 onBind() 메서드로 데이터를 바인딩했습니다.
2. GridLayoutManager에 Header Span 지정
private fun setGridRecyclerView() {
binding.rvHome.layoutManager = GridLayoutManager(context, 2).apply {
spanSizeLookup = object : GridLayoutManager.SpanSizeLookup() {
override fun getSpanSize(position: Int): Int {
// 헤더는 두 칸 전체를, 나머지 상품은 한 칸씩 차지
return when (adapter.getItemViewType(position)) {
VIEW_TYPE_BANNER -> 2
else -> 1
}
}
}
}
}
- GridLayoutManager(context, 2)로 2열 그리드를 설정했습니다.
- spanSizeLookup을 오버라이드해 VIEW_TYPE_BANNER일 때 spanSize = 2로 지정해 전체 열을 차지하도록 했습니다.
- 일반 상품 항목은 spanSize = 1로 한 칸씩 배치되었습니다.
3. 그리드 아이템 간격 분기처리
class GridItemDecoration(
private val spanCount: Int,
private val spacing: Int,
private val bottomPadding: Int
) : RecyclerView.ItemDecoration() {
override fun getItemOffsets(
outRect: Rect, view: View,
parent: RecyclerView, state: RecyclerView.State
) {
val position = parent.getChildAdapterPosition(view)
val viewType = parent.adapter?.getItemViewType(position)
// 상품 아이템일 때만 좌우 여백 계산
if (viewType == VIEW_TYPE_PRODUCT) {
val column = (position - HEADER_COUNT) % spanCount
// 왼쪽과 오른쪽 간격을 칼럼 위치에 맞춰 계산
outRect.left = spacing - column * spacing / spanCount
outRect.right = (column + 1) * spacing / spanCount
} else {
outRect.setEmpty() // 헤더는 간격 없음
}
// 마지막 아이템이면 하단 패딩 적용
if (position == parent.adapter?.itemCount?.minus(1)) {
outRect.bottom = bottomPadding
}
}
}
private fun setRecyclerViewDeco() {
binding.rvHome.addItemDecoration(
GridItemDecoration(
spanCount = 2,
spacing = 30.dpToPx(requireContext()),
bottomPadding = 50.dpToPx(requireContext())
)
)
}
- ItemDecoration을 사용해 그리드 각 셀의 좌우 마진을 동적으로 계산했습니다.
- 각 아이템이 속한 column 인덱스를 얻은 후 spacing을 열 번호에 맞춰 나누어, 겹치는 영역 없이 균등한 간격을 적용했습니다.
- VIEW_TYPE_BANNER는 outRect.setEmpty()로 간격 없이 전체 너비를 유지했습니다.
- 마지막 아이템에는 bottomPadding을 추가해 리스트 하단 여유 공간을 확보했습니다.
이렇게 멀티 뷰 타입 어댑터, SpanSizeLookup, ItemDecoration을 조합하여, 한 줄에 2개의 상품 아이템과 전체 열을 차지하는 헤더를 한 화면에 자연스럽게 배치할 수 있었습니다.