Thursday, August 23, 2018

안드로이드 StaggeredGridLayoutManager, 헤더와 푸터를 위한 고급 레이아웃 전략

안드로이드 앱 개발에서 사용자에게 동적이고 시각적으로 매력적인 UI를 제공하는 것은 매우 중요합니다. RecyclerView는 대규모 데이터 세트를 효율적으로 표시하기 위한 핵심 위젯으로, 유연성과 성능 면에서 ListView를 훨씬 뛰어넘는 강력함을 보여줍니다. RecyclerView의 진정한 힘은 다양한 `LayoutManager`를 통해 발휘됩니다. `LinearLayoutManager`는 단순한 수직/수평 목록을, `GridLayoutManager`는 균일한 격자무늬 뷰를 구현하지만, 그 중에서도 StaggeredGridLayoutManager는 각 아이템의 크기가 서로 다른, 마치 핀터레스트(Pinterest)와 같은 비대칭적인 그리드 레이아웃을 구현할 때 독보적인 가치를 지닙니다.

StaggeredGridLayoutManager는 높이가 다른 뷰들을 교차 배치하여 화면 공간을 효율적으로 사용하고, 단조로움을 피하게 해주는 미학적 장점을 가집니다. 하지만 이러한 자유도 높은 레이아웃 구조는 개발자에게 한 가지 까다로운 문제를 제시합니다. 바로 리스트의 시작 부분(Header)이나 끝 부분(Footer)에 화면 전체 너비를 차지하는 뷰를 추가하고자 할 때입니다. 일반적인 아이템처럼 헤더나 푸터를 추가하면, 이들 역시 StaggeredGridLayoutManager의 규칙에 따라 여러 열(column) 중 하나에 배정되어 버려, 전체 너비를 차지하지 못하고 레이아웃이 깨져 보이는 현상이 발생합니다. 이 글에서는 이러한 문제를 해결하고, 엇갈린 그리드 속에서도 헤더와 푸터를 온전히 전체 너비로 표시하는 세련되고 안정적인 방법들을 심도 있게 다룹니다.

문제 상황의 명확한 이해: 왜 헤더/푸터가 잘릴까?

이 문제를 해결하기 전에, 왜 이런 현상이 발생하는지 근본적인 원인을 파악하는 것이 중요합니다. StaggeredGridLayoutManager는 지정된 `spanCount`(열의 개수)에 따라 화면을 분할하고, 각 아이템을 배치할 때 가장 공간이 많이 남아있는 열을 찾아 채워 넣는 방식으로 동작합니다. 예를 들어, `spanCount`를 2로 설정하면 화면은 두 개의 세로 열로 나뉩니다.

여기서 우리가 헤더나 푸터를 위한 뷰를 일반 아이템과 동일하게 어댑터에 추가하면, RecyclerViewStaggeredGridLayoutManager는 이 뷰를 그저 또 하나의 '데이터 아이템'으로 인식합니다. 그 결과, 헤더나 푸터 뷰는 두 개의 열 중 하나의 너비에 맞춰 렌더링되고, 나머지 절반은 비어 있거나 다른 아이템이 채우게 됩니다. 우리가 원했던 '화면 전체를 가로지르는 구분선'이나 '더보기 버튼'의 역할은 완전히 실패하게 되는 것입니다. 이 문제를 해결하기 위해서는 해당 뷰가 특별한 아이템이며, 레이아웃 매니저의 기본 배치 규칙을 따르지 않고 전체 스팬(span), 즉 모든 열을 통합한 너비를 차지해야 한다는 사실을 명시적으로 알려주어야 합니다.

해결책 1: `LayoutParams.setFullSpan(true)` - 전통적이고 확실한 접근

가장 고전적이면서도 직관적인 해결책은 RecyclerView.Adapter 내에서 특정 뷰 홀더(ViewHolder)의 레이아웃 파라미터를 직접 조작하는 것입니다. StaggeredGridLayoutManager는 이 목적을 위해 특별한 속성인 setFullSpan(boolean)을 `LayoutParams` 클래스에 제공합니다. 이 메서드를 `true`로 호출하면, 해당 아이템은 다른 아이템과 관계없이 현재 레이아웃의 모든 스팬을 차지하게 됩니다.

구현 단계는 다음과 같습니다.

1단계: 뷰 타입(View Type) 정의

어댑터가 일반 아이템과 푸터(또는 헤더)를 구분할 수 있도록 뷰 타입을 정의해야 합니다. 일반적으로 정수 상수를 사용합니다.


class MyStaggeredAdapter(private val items: List) : RecyclerView.Adapter() {

    companion object {
        private const val VIEW_TYPE_ITEM = 0
        private const val VIEW_TYPE_FOOTER = 1
    }

    // ... 어댑터의 나머지 구현
}

2단계: `getItemViewType()` 오버라이드

아이템의 위치(position)에 따라 어떤 뷰 타입을 반환할지 결정합니다. 리스트의 가장 마지막 위치를 푸터로 간주하는 로직을 추가합니다.


    override fun getItemViewType(position: Int): Int {
        return if (position == items.size) {
            VIEW_TYPE_FOOTER
        } else {
            VIEW_TYPE_ITEM
        }
    }

중요: 푸터를 추가했으므로, 전체 아이템 개수는 원본 데이터 크기보다 1이 커져야 합니다.


    override fun getItemCount(): Int {
        // 데이터 아이템 개수 + 푸터 1개
        return items.size + 1
    }

3단계: `onCreateViewHolder()`에서 뷰 홀더 분기 처리

뷰 타입에 따라 각각 다른 레이아웃 파일을 인플레이트하고, 그에 맞는 뷰 홀더를 생성하여 반환합니다.


    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
        return when (viewType) {
            VIEW_TYPE_ITEM -> {
                val binding = ItemStaggeredBinding.inflate(LayoutInflater.from(parent.context), parent, false)
                ItemViewHolder(binding)
            }
            VIEW_TYPE_FOOTER -> {
                val binding = FooterLoadingBinding.inflate(LayoutInflater.from(parent.context), parent, false)
                FooterViewHolder(binding)
            }
            else -> throw IllegalArgumentException("Invalid view type")
        }
    }

    // 각 뷰 홀더 클래스 정의
    class ItemViewHolder(private val binding: ItemStaggeredBinding) : RecyclerView.ViewHolder(binding.root) {
        fun bind(item: MyItem) {
            // 아이템 데이터 바인딩
        }
    }

    class FooterViewHolder(private val binding: FooterLoadingBinding) : RecyclerView.ViewHolder(binding.root) {
        fun bind(isLoading: Boolean) {
            // 로딩 상태에 따라 프로그레스바 표시/숨김 처리
            binding.progressBar.visibility = if (isLoading) View.VISIBLE else View.GONE
        }
    }

4단계: `onBindViewHolder()`에서 `setFullSpan` 적용 (핵심)

뷰 홀더가 데이터와 바인딩될 때, 해당 뷰 홀더가 푸터 타입인지 확인하고, 그렇다면 `setFullSpan(true)`를 설정합니다. 이 코드는 바인딩 로직의 일부로 수행될 수 있으며, 뷰 홀더의 `itemView`에 직접 접근하여 `layoutParams`를 설정합니다.


    override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
        if (holder is ItemViewHolder) {
            holder.bind(items[position])
        } else if (holder is FooterViewHolder) {
            // 여기에 핵심 로직이 들어갑니다.
            val layoutParams = holder.itemView.layoutParams as? StaggeredGridLayoutManager.LayoutParams
            layoutParams?.isFullSpan = true // Kotlin에서는 isFullSpan 프로퍼티를 직접 사용
            
            // 예시: 로딩 상태를 바인딩
            holder.bind(isLoading = true) 
        }
    }

이 코드가 실행되면, `FooterViewHolder`에 해당하는 아이템은 StaggeredGridLayoutManager에 의해 전체 너비를 차지하도록 강제됩니다. `setFullSpan`을 설정하는 시점은 `onCreateViewHolder` 직후 또는 `onViewAttachedToWindow`에서도 가능하지만, `onBindViewHolder`에서 처리하는 것이 가장 일반적인 패턴 중 하나입니다. 이 방법을 사용하면 복잡한 라이브러리 추가 없이 RecyclerView의 기본 기능만으로 원하는 레이아웃을 완성할 수 있습니다.

해결책 2: `ConcatAdapter` - 모던하고 유연한 대안

여러 뷰 타입을 하나의 어댑터에서 관리하는 것은 리스트가 단순할 때는 문제가 없지만, 헤더, 푸터, 여러 종류의 아이템, 광고 등 다양한 뷰가 섞이기 시작하면 어댑터 클래스가 비대해지고 관리하기 어려워지는 단점이 있습니다. `getItemViewType`, `onCreateViewHolder`, `onBindViewHolder` 내부의 `if/else` 또는 `when` 분기문이 점점 복잡해지기 때문입니다.

이러한 문제를 해결하기 위해 Android Jetpack 팀은 ConcatAdapter를 도입했습니다. ConcatAdapter는 여러 개의 `RecyclerView.Adapter`를 하나의 어댑터처럼 순차적으로 연결해주는 컨테이너 어댑터입니다. 각각의 어댑터는 자신만의 로직과 뷰 홀더만 책임지면 되므로, 코드의 모듈성이 극대화되고 재사용성이 높아집니다.

ConcatAdapter를 사용하여 푸터 문제를 해결하는 방법은 다음과 같이 훨씬 깔끔합니다.

1단계: 라이브러리 의존성 추가

ConcatAdapterrecyclerview 라이브러리에 포함되어 있으므로, `build.gradle` 파일에 최신 버전이 포함되어 있는지 확인합니다.


dependencies {
    implementation 'androidx.recyclerview:recyclerview:1.2.1' // 또는 그 이상 버전
}

2단계: 각 부분에 대한 독립적인 어댑터 생성

메인 콘텐츠를 표시할 어댑터와 푸터를 표시할 어댑터를 별개의 클래스로 분리하여 만듭니다.

콘텐츠 어댑터 (ContentAdapter)

이 어댑터는 오직 그리드 아이템을 표시하는 책임만 집니다. 푸터에 대한 어떠한 로직도 포함하지 않습니다.


class ContentAdapter(private val items: List<MyItem>) : RecyclerView.Adapter<ContentAdapter.ItemViewHolder>() {
    
    // 이 어댑터는 오직 한 종류의 뷰만 신경쓰면 됩니다.
    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ItemViewHolder {
        val binding = ItemStaggeredBinding.inflate(LayoutInflater.from(parent.context), parent, false)
        return ItemViewHolder(binding)
    }

    override fun onBindViewHolder(holder: ItemViewHolder, position: Int) {
        holder.bind(items[position])
    }

    override fun getItemCount(): Int = items.size

    class ItemViewHolder(private val binding: ItemStaggeredBinding) : RecyclerView.ViewHolder(binding.root) {
        fun bind(item: MyItem) { /* ... */ }
    }
}

푸터 어댑터 (FooterAdapter)

이 어댑터는 오직 푸터 뷰 하나만을 표시하는 책임을 집니다. 일반적으로 아이템 개수는 1입니다.


class FooterAdapter(private val onRetry: () -> Unit) : RecyclerView.Adapter<FooterAdapter.FooterViewHolder>() {

    private var state: FooterState = FooterState.Loading

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): FooterViewHolder {
        val binding = FooterComplexBinding.inflate(LayoutInflater.from(parent.context), parent, false)
        return FooterViewHolder(binding, onRetry)
    }

    override fun onBindViewHolder(holder: FooterViewHolder, position: Int) {
        holder.bind(state)
    }

    // 푸터는 항상 1개입니다.
    override fun getItemCount(): Int = 1
    
    fun setState(newState: FooterState) {
        state = newState
        notifyItemChanged(0)
    }

    class FooterViewHolder(private val binding: FooterComplexBinding, private val onRetry: () -> Unit) : RecyclerView.ViewHolder(binding.root) {
        init {
            binding.retryButton.setOnClickListener { onRetry() }
        }
        fun bind(state: FooterState) {
            binding.progressBar.isVisible = state is FooterState.Loading
            binding.errorText.isVisible = state is FooterState.Error
            binding.retryButton.isVisible = state is FooterState.Error
        }
    }
}

// 푸터 상태를 관리하기 위한 Sealed Class
sealed class FooterState {
    object Loading : FooterState()
    object Error : FooterState()
    object Hidden : FooterState() // 푸터를 숨기고 싶을 때
}

위 예시처럼 푸터 어댑터 내에서 로딩, 에러, 재시도 등의 상태를 관리하면 더욱 구조화된 코드를 작성할 수 있습니다.

3단계: `ConcatAdapter`로 어댑터 결합

Activity나 Fragment에서 두 어댑터의 인스턴스를 생성하고 `ConcatAdapter`로 합칩니다. 이 `ConcatAdapter` 인스턴스를 `RecyclerView`에 설정하면 됩니다.


// In your Activity or Fragment
val contentAdapter = ContentAdapter(myInitialData)
val footerAdapter = FooterAdapter { loadMoreData() }

// 두 어댑터를 순서대로 결합합니다.
val concatAdapter = ConcatAdapter(contentAdapter, footerAdapter)

val layoutManager = StaggeredGridLayoutManager(2, StaggeredGridLayoutManager.VERTICAL)
recyclerView.layoutManager = layoutManager
recyclerView.adapter = concatAdapter

놀랍게도, 이게 전부입니다! `ConcatAdapter`를 `StaggeredGridLayoutManager`와 함께 사용하면, ConcatAdapter는 각 하위 어댑터가 하나의 아이템만 반환하는 경우(`getItemCount() == 1`) 해당 어댑터의 뷰가 전체 스팬을 차지해야 한다고 자동으로 인식하고 처리해줍니다. 즉, setFullSpan(true)를 직접 호출할 필요가 없습니다. 이 방식은 코드를 논리적 단위로 분리하여 유지보수를 용이하게 만들고, 특히 여러 종류의 헤더나 푸터, 중간 광고 등을 동적으로 추가하거나 제거해야 할 때 강력한 유연성을 제공합니다.

심화 학습: `ItemDecoration`과의 아름다운 조화

StaggeredGridLayoutManager를 사용할 때 아이템 간의 간격을 주기 위해 `ItemDecoration`을 사용하는 것은 흔한 일입니다. 하지만 무심코 `ItemDecoration`을 적용하면, 우리가 전체 너비로 설정한 푸터에도 불필요한 `outRect`(여백)이 적용되어 디자인이 미세하게 틀어지는 문제가 발생할 수 있습니다.

이 문제를 해결하려면 `getItemOffsets` 메서드 내부에서 현재 아이템이 전체 스팬을 사용하는지 여부를 확인하고, 그에 따라 여백 적용을 건너뛰는 로직을 추가해야 합니다.


class GridSpacingItemDecoration(private val spacing: Int) : RecyclerView.ItemDecoration() {

    override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State) {
        super.getItemOffsets(outRect, view, parent, state)
        
        val params = view.layoutParams as? StaggeredGridLayoutManager.LayoutParams
        
        // params가 null이 아니고, isFullSpan이 true라면 여백을 적용하지 않고 리턴
        if (params?.isFullSpan == true) {
            outRect.left = 0
            outRect.right = 0
            outRect.top = 0
            outRect.bottom = 0
            return
        }

        // 일반 아이템에 대한 여백 설정 로직
        outRect.left = spacing
        outRect.right = spacing
        outRect.top = spacing
        outRect.bottom = spacing
    }
}

// 사용법
val spacing = resources.getDimensionPixelSize(R.dimen.grid_spacing)
recyclerView.addItemDecoration(GridSpacingItemDecoration(spacing))

이렇게 `isFullSpan` 속성을 확인하는 간단한 조건문 하나만으로 `ItemDecoration`이 우리의 헤더/푸터 레이아웃을 방해하지 않도록 만들 수 있습니다. `ConcatAdapter`를 사용하는 경우에도 이 방법은 동일하게 유효합니다. 왜냐하면 `ConcatAdapter`가 내부적으로 `isFullSpan`을 설정해주기 때문입니다.

결론: 어떤 방법을 선택해야 하는가?

StaggeredGridLayoutManager에서 헤더나 푸터를 전체 너비로 만드는 두 가지 주요 방법을 살펴보았습니다. 각각의 장단점이 명확하므로 상황에 맞는 최적의 선택을 하는 것이 중요합니다.

  • `LayoutParams.setFullSpan(true)` 방식:
    • 장점: 추가 라이브러리 없이 `RecyclerView`의 기본 API만으로 구현 가능합니다. 매우 간단한 헤더/푸터 하나만 필요한 경우 가장 빠르고 직접적인 해결책입니다.
    • 단점: 뷰 타입이 많아질수록 어댑터 클래스가 복잡해지고 `when` 분기문이 길어져 가독성과 유지보수성이 저하됩니다.
    • 추천 대상: 기존에 이미 단일 어댑터로 구현된 프로젝트에 간단한 푸터 기능만 추가하거나, 프로젝트의 복잡도가 매우 낮은 경우.
  • `ConcatAdapter` 방식:
    • 장점: 각 UI 구성요소(콘텐츠, 헤더, 푸터 등)를 독립적인 어댑터로 분리하여 코드의 모듈성과 재사용성을 극대화합니다. `setFullSpan`과 같은 세부적인 처리를 자동으로 해주므로 개발자는 각 부분의 로직에만 집중할 수 있습니다. 동적으로 섹션을 추가/제거하기 매우 용이합니다.
    • 단점: 매우 단순한 리스트에는 과하게 느껴질 수 있습니다(Overkill). 여러 어댑터 클래스를 만들어야 하므로 초기 설정 코드가 조금 더 늘어납니다.
    • 추천 대상: 새로 시작하는 프로젝트, 여러 종류의 뷰 타입이 혼합된 복잡한 화면, 헤더/푸터의 상태 변화(로딩, 에러)가 필요한 경우, 또는 코드의 장기적인 확장성과 유지보수성을 중요하게 생각하는 모든 경우.

결론적으로, 단기적인 편리함보다는 장기적인 구조적 안정성과 확장성을 고려한다면 `ConcatAdapter`가 대부분의 현대적인 안드로이드 앱 개발에서 더 우월한 선택지라고 할 수 있습니다. RecyclerView의 유연함은 이러한 고급 패턴들을 통해 비로소 그 진가를 발휘하며, 개발자는 어떤 복잡한 UI 요구사항에도 자신감 있게 대응할 수 있게 될 것입니다.


0 개의 댓글:

Post a Comment