💡여러 종류의 목록 뷰를 하나의 스크롤 영역에 구현하기 위해 학습한 내용을 기록하였습니다.
개요
- 인스타그램을 포함한 여러 앱에서 여러 종류의 목록 뷰를 하나의 스크롤 영역에 표시합니다.
- 리싸이클러뷰로 이를 구현하려고 할 때 여러가지 방식의 차이를 비교하려 합니다.
NestedScrollView
- NestedScrollView는 ScrollView와 동일하게 동작하지만 중첩된 스크롤을 지원합니다.
NestedScrollView | Android Developers
- NestedScrollView는 View의 스크롤 X or Y 위치가 변경될 때 호출되는 콜백에 대한 인터페이스인 OnScrollChangeListener를 기본적으로 지원합니다.
- 이로 인해서 스크롤 이벤트를 감지하고 중첩 스크롤에 대한 이슈를 해결할 수 있습니다.
OnScrollChangeListener
public interface [NestedScrollView.OnScrollChangeListener](<https://developer.android.com/reference/androidx/core/widget/NestedScrollView.OnScrollChangeListener>)
Interface definition for a callback to be invoked when the scroll X or Y positions of a view change.
setOnScrollChangeListener(
@Nullable NestedScrollView.OnScrollChangeListener l
)
- 해당 인터페이스는 뷰의 스크롤 위치가 변경되면 호출되며, 스크롤 포지션에 대한 콜백을 받아서 이에 맞는 이벤트를 작성할 수 있습니다.
- ScrollView에는 OnScrollChangeListener 인터페이스가 구현되어 있지 않습니다.
- 이는 중첩 스크롤 시 에러가 발생하며, ScrollView의 해결책으로 NestedScrollView를 활용할 수 있습니다.
RecyclerView의 ViewHolder의 재활용 이슈
- recyclerView는 ViewHolder를 재활용하면서 효율적으로 목록을 나타낼 수 있습니다.
- 하지만 NestedScrollView로 구현하는 경우 이를 재활용하지 못하는 문제가 발생합니다.
- RecyclerView의 layout height가 그려지는 과정에서 height가 고정이 아니라면 item을 한번에 그리는 이슈가 있습니다.
View Type
- NestedScrollView의 문제를 해결하기 위한 대안으로 ViewType을 활용할 수 있습니다.
- 이는 RecyclerView의 하나의 ViewType을 RecyclerView의 가로 영역 (혹은 세로 영역)으로 두고 ViewHolder를 분리하여 구성하는 방법입니다.
- viewType이 VIEW_TYPE_HEADER인 경우 상위 리사이클러뷰로 가정하고, 첫번째 아이템을 가로 리사이클러뷰로 구현하는 방식입니다.
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
// 상위 RecyclerView의 어댑터
class ParentAdapter(private val items: List<ParentItem>) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
companion object {
private const val VIEW_TYPE_HEADER = 0
private const val VIEW_TYPE_CHILD_RECYCLER = 1
}
override fun getItemViewType(position: Int): Int {
return when (items[position].viewType) {
ParentItem.ViewType.HEADER -> VIEW_TYPE_HEADER
ParentItem.ViewType.CHILD_RECYCLER -> VIEW_TYPE_CHILD_RECYCLER
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
return when (viewType) {
VIEW_TYPE_HEADER -> HeaderViewHolder(TextView(parent.context)) // 간단한 TextView를 사용
VIEW_TYPE_CHILD_RECYCLER -> ChildRecyclerViewHolder(RecyclerView(parent.context)) // RecyclerView
else -> throw IllegalArgumentException("Invalid view type")
}
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
when (holder) {
is HeaderViewHolder -> holder.bind(items[position].headerText)
is ChildRecyclerViewHolder -> holder.bind(items[position].childItems)
}
}
override fun getItemCount(): Int = items.size
// Header를 위한 ViewHolder
class HeaderViewHolder(private val textView: TextView) : RecyclerView.ViewHolder(textView) {
fun bind(text: String) {
textView.text = text
}
}
// RecyclerView를 포함하는 ViewHolder
class ChildRecyclerViewHolder(private val recyclerView: RecyclerView) : RecyclerView.ViewHolder(recyclerView) {
fun bind(childItems: List<String>) {
recyclerView.layoutManager = LinearLayoutManager(recyclerView.context, LinearLayoutManager.HORIZONTAL, false)
recyclerView.adapter = ChildAdapter(childItems)
}
}
}
// 하위 RecyclerView의 어댑터
class ChildAdapter(private val items: List<String>) : RecyclerView.Adapter<ChildAdapter.ChildViewHolder>() {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ChildViewHolder {
return ChildViewHolder(TextView(parent.context)) // 간단한 TextView 사용
}
override fun onBindViewHolder(holder: ChildViewHolder, position: Int) {
holder.bind(items[position])
}
override fun getItemCount(): Int = items.size
class ChildViewHolder(private val textView: TextView) : RecyclerView.ViewHolder(textView) {
fun bind(text: String) {
textView.text = text
}
}
}
복잡한 구현
- NestedScrollView가 가지는 이슈를 해결하였지만, 구현이 복잡하다는 단점이 발생하였습니다.
- 또한 ViewType마다 다른 작업을 처리해주고 싶거나 ViewHolder마다 bind해주고 싶은 data 타입이 다른 상황 등 ViewHolder에 따라 처리해야 하는 작업이 복잡한 경우가 발생하였습니다.
ConcatAdapter
- 또 다른 대안으로는 ConcatAdapter를 활용하는 방법이 있습니다.
ConcatAdapter | Android Developers
- RecyclerView 1.2.0 버전부터 추가된 컴포넌트로 여러 Adapter를 하나의 Adapter로 묶어 사용할 수 있습니다.
- 각각의 어댑터가 다른 데이터 세트를 처리할 수 있으며, 하나의 RecyclerView가 여러 어댑터의 데이터를 연속적으로 표현할 수 있습니다.
import android.view.LayoutInflater
import android.view.ViewGroup
import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import androidx.recyclerview.widget.ConcatAdapter
import androidx.recyclerview.widget.LinearLayoutManager
class SimpleAdapter(private val items: List<String>) : RecyclerView.Adapter<SimpleAdapter.SimpleViewHolder>() {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SimpleViewHolder {
val textView = TextView(parent.context)
return SimpleViewHolder(textView)
}
override fun onBindViewHolder(holder: SimpleViewHolder, position: Int) {
holder.bind(items[position])
}
override fun getItemCount(): Int = items.size
class SimpleViewHolder(private val textView: TextView) : RecyclerView.ViewHolder(textView) {
fun bind(text: String) {
textView.text = text
}
}
}
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// RecyclerView 생성 및 레이아웃 매니저 설정
val recyclerView = RecyclerView(this).apply {
layoutManager = LinearLayoutManager(this@MainActivity)
}
// 어댑터 1 - 숫자 목록
val adapter1 = SimpleAdapter(listOf("1", "2", "3", "4", "5"))
// 어댑터 2 - 문자 목록
val adapter2 = SimpleAdapter(listOf("A", "B", "C", "D", "E"))
// ConcatAdapter를 사용하여 두 어댑터를 결합
val concatAdapter = ConcatAdapter(adapter1, adapter2)
// RecyclerView에 ConcatAdapter 설정
recyclerView.adapter = concatAdapter
// RecyclerView를 화면에 표시
setContentView(recyclerView)
}
}
- 일반적인 RecyclerView와 동일하게 구현하며, Adapter를 순차적으로 결합하여 구현할 수 있습니다.
- 여러 어댑터의 데이터를 하나의 RecyclerView에서 관리할 수 있으며, ViewType을 활용한 패턴과 비슷한 구조를 가지고 있습니다.
- 이를 활용하면 각기 다른 레이아웃과 데이터를 처리하는 어댑터들을 쉽게 결합할 수 있습니다.
참고
https://velog.io/@dabin/안드로이드-공식문서-파헤치기-ScrollView-NestedScrollView의-모든-것