안드로이드 앱 개발에서 사용자에게 동적이고 시각적으로 매력적인 UI를 제공하는 것은 매우 중요합니다. RecyclerView
는 대규모 데이터 세트를 효율적으로 표시하기 위한 핵심 위젯으로, 유연성과 성능 면에서 ListView를 훨씬 뛰어넘는 강력함을 보여줍니다. RecyclerView
의 진정한 힘은 다양한 `LayoutManager`를 통해 발휘됩니다. `LinearLayoutManager`는 단순한 수직/수평 목록을, `GridLayoutManager`는 균일한 격자무늬 뷰를 구현하지만, 그 중에서도 StaggeredGridLayoutManager
는 각 아이템의 크기가 서로 다른, 마치 핀터레스트(Pinterest)와 같은 비대칭적인 그리드 레이아웃을 구현할 때 독보적인 가치를 지닙니다.
StaggeredGridLayoutManager
는 높이가 다른 뷰들을 교차 배치하여 화면 공간을 효율적으로 사용하고, 단조로움을 피하게 해주는 미학적 장점을 가집니다. 하지만 이러한 자유도 높은 레이아웃 구조는 개발자에게 한 가지 까다로운 문제를 제시합니다. 바로 리스트의 시작 부분(Header)이나 끝 부분(Footer)에 화면 전체 너비를 차지하는 뷰를 추가하고자 할 때입니다. 일반적인 아이템처럼 헤더나 푸터를 추가하면, 이들 역시 StaggeredGridLayoutManager
의 규칙에 따라 여러 열(column) 중 하나에 배정되어 버려, 전체 너비를 차지하지 못하고 레이아웃이 깨져 보이는 현상이 발생합니다. 이 글에서는 이러한 문제를 해결하고, 엇갈린 그리드 속에서도 헤더와 푸터를 온전히 전체 너비로 표시하는 세련되고 안정적인 방법들을 심도 있게 다룹니다.
문제 상황의 명확한 이해: 왜 헤더/푸터가 잘릴까?
이 문제를 해결하기 전에, 왜 이런 현상이 발생하는지 근본적인 원인을 파악하는 것이 중요합니다. StaggeredGridLayoutManager
는 지정된 `spanCount`(열의 개수)에 따라 화면을 분할하고, 각 아이템을 배치할 때 가장 공간이 많이 남아있는 열을 찾아 채워 넣는 방식으로 동작합니다. 예를 들어, `spanCount`를 2로 설정하면 화면은 두 개의 세로 열로 나뉩니다.
여기서 우리가 헤더나 푸터를 위한 뷰를 일반 아이템과 동일하게 어댑터에 추가하면, RecyclerView
와 StaggeredGridLayoutManager
는 이 뷰를 그저 또 하나의 '데이터 아이템'으로 인식합니다. 그 결과, 헤더나 푸터 뷰는 두 개의 열 중 하나의 너비에 맞춰 렌더링되고, 나머지 절반은 비어 있거나 다른 아이템이 채우게 됩니다. 우리가 원했던 '화면 전체를 가로지르는 구분선'이나 '더보기 버튼'의 역할은 완전히 실패하게 되는 것입니다. 이 문제를 해결하기 위해서는 해당 뷰가 특별한 아이템이며, 레이아웃 매니저의 기본 배치 규칙을 따르지 않고 전체 스팬(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단계: 라이브러리 의존성 추가
ConcatAdapter
는 recyclerview
라이브러리에 포함되어 있으므로, `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