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_BANNERoutRect.setEmpty()간격 없이 전체 너비를 유지했습니다.
  • 마지막 아이템에는 bottomPadding을 추가해 리스트 하단 여유 공간을 확보했습니다.

 

 


이렇게 멀티 뷰 타입 어댑터, SpanSizeLookup, ItemDecoration을 조합하여, 한 줄에 2개의 상품 아이템과 전체 열을 차지하는 헤더를 한 화면에 자연스럽게 배치할 수 있었습니다.