
모바일, 웹, 데스크탑을 가리지 않고, 거의 모든 GUI 애플리케이션에서 동일한 형태를 가진 많은 데이터가 나열되어 있는 요소(이하
데이터 세트)를 볼 수 있습니다. 즉 클라이언트 개발자 입장에서, 데이터 세트를 그려야 하는 상황은 필연적으로 생기게 됩니다. 본 게시글에서는 안드로이드 프레임워크, 그 중 View System을 활용하여 어떻게 데이터 세트를 보여줄 수 있는지에 관하여 다룹니다.이 글은 ListView, RecyclerView를 접해보지 않은 사람들이 그 원리, 사용이 필요한 상황, 필요성 등을 이해할 수 있도록 하는 것을 목표로 하고 있습니다. 총 세 편에 걸쳐서 발행될 예정이고, 특정 방식을 사용했을 때 발생하는 문제와 그것을 다른 방식으로 해결하는 모습을 step by step 방식으로 설명할 예정입니다.
대상 독자는 처음 안드로이드 개발을 공부하는 사람이지만, 원활한 이해를 위해 Kotlin 문법과 안드로이드 View, ViewGroup 기초 지식 학습이 선행되어야 합니다.

안드로이드 프레임워크에서 View System을 사용하여 UI를 그릴 때, 다양한 방법으로 화면에 데이터 세트를 그릴 수 있습니다.
(본 글에서 Compose를 활용한 방법은 다루지 않습니다.)
가장 단순하면서 원초적인 방법은, 직접 모든 데이터를 XML 코드로 작성하는 것입니다. [그림 1]에서 세 번째 화면인 토스의 혜택 목록 5개를 간략하게 그려보겠습니다.
activity_benefits.xml
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/ll_benefits"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
tools:context=".BenefitsActivity">
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/cl_benefit_button"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingHorizontal="16dp"
android:paddingVertical="8dp">
<ImageView
android:id="@+id/iv_benefit_button"
android:layout_width="0dp"
android:layout_height="64dp"
android:background="#FFAAAAAA"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintDimensionRatio="H,1:1"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/tv_benefit_button_title"
android:layout_width="0dp"
android:layout_height="wrap_content"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/iv_benefit_button"
app:layout_constraintTop_toTopOf="@id/iv_benefit_button"
android:textStyle="bold"
android:textSize="16sp"
android:text="버튼 누르기"
android:layout_marginStart="12dp"
app:layout_constraintBottom_toTopOf="@id/tv_benefit_button_description"/>
<TextView
android:id="@+id/tv_benefit_button_description"
android:layout_width="0dp"
android:layout_height="wrap_content"
app:layout_constraintTop_toBottomOf="@id/tv_benefit_button_title"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="@id/tv_benefit_button_title"
android:text="10원 받기"
android:textColor="#1F70FF"/>
</androidx.constraintlayout.widget.ConstraintLayout>
<!-- ... 3개 데이터 ... -->
<androidx.constraintlayout.widget.ConstraintLayout ...>
<ImageView ... />
<TextView ... />
<TextView ... />
</androidx.constraintlayout.widget.ConstraintLayout>
</LinearLayout>
요구사항이 단순했기 때문에, XML 코드만으로 원하는 바를 달성할 수 있었습니다.
데이터 세트의 개수가 엄청나게 적고 정적이다.아이템의 데이터가 정적이다.레이아웃이 엄청나게 단순하다.중복된 코드 작성으로부터 나오는 단점이 있을지라도, 데이터 세트가 대표적인 세 조건을 모두 만족한다면 특수한 뷰를 사용하지 않고도 목적을 어렵지 않게 달성할 수 있을 것입니다.
반대로 만약 요구 사항이 세 조건 중 하나라도 만족하지 못한 상태에서 해당 방법을 사용하면, 개발 소요 시간, 성능, 유지보수 등의 측면에서 악영향이 발생할 것입니다.
먼저 위에서 제시하였던 세 조건 중 데이터 세트의 개수가 많아졌다고 가정해 보겠습니다.
요구사항이 데이터를 수백/수천 개를 보여주는 것인데, XML 코드만을 변경하여 요구사항을 충족하고자 하는 것은 불가능에 가까운 일입니다.
안드로이드 뷰 시스템에서는 동일한 형태의 뷰를 위 아래 연속으로 보여주기 위한 ListView라는 뷰를 제공합니다. 이것과 Adapter 인터페이스를 구현한 객체를 사용하면, 반복적으로 XML 코드를 작성할 필요 없이 데이터 세트를 출력할 수 있습니다.
우선 Adapter와 AdapterView의 개념을 짚고 넘어갈 필요가 있습니다.
An Adapter object acts as a bridge between an AdapterView and the underlying data for that view. The Adapter provides access to the data items. The Adapter is also responsible for making a View for each item in the data set.
공식 문서에 의하면, Adapter는 AdapterView와 데이터 세트 사이의 가교라는 역할을 가지며 아래의 책임들을 가지고 있습니다.
위 설명을 토대로, 안드로이드 뷰 시스템에서 데이터 세트를 뷰로 보여주기 위해서는 Adapter라는 객체가 필요하다는 사실을 알 수 있습니다. 그리고 이러한 사실로 미루어 추측해보면, AdapterView는 Adapter를 사용하여 데이터 세트를 보여주는 데 사용되는 뷰일 것입니다.
Adapter + AdapterView의 이해가 필요한 이유는 본격적으로 사용해 볼 BaseAdapter + ListView와의 관계 때문입니다.

상속과 구현의 특성을 생각해 보면, 중간의 AbsListView와 ListAdapter, SpinnerAdapter는 각각 AdapterView와 Adapter를 상속/구현하여 특정 기능이 추가된 것이라 볼 수 있습니다.
연쇄적으로 ListView와 BaseAdapter 역시 AbsListView와 ListAdapter, SpinnerAdapter를 기반으로 하여 구현된 객체로 볼 수 있습니다.
BaseAdapter는 글자 그대로 기본적인 어댑터로, 커스텀 어댑터를 만들고 싶을 때 해당 추상 클래스를 상속 받는 방식으로 사용할 수 있습니다. 다이어그램으로 알 수 있듯, ListView뿐만 아니라, Spinner 뷰에서 아이템을 보여주는 데에도 활용될 수 있습니다.
AdapterView가 Adapter의 도움을 받아 데이터 세트를 표출하듯, ListView 역시 BaseAdapter를 비롯한 하위 Adapter(BaseAdapter를 상속받는 ArrayAdapter 등)의 도움을 받아 데이터 세트를 표출할 수 있습니다. 앞에서 알아본 Adapter의 책임을 토대로 다시 생각 해보면,
두 가지 책임에 BaseAdapter만의 고유의 책임이 추가된 것으로 생각해볼 수 있습니다. 이것들에 대해서는 차차 알아가보도록 하겠습니다.
[그림 2]와 같은 리스트를 ListView와 BaseAdapter를 활용해 구현해보겠습니다.
① Benefit.kt : 데이터 세트 단일 아이템
data class Benefit(
val id: Long,
val title: String,
val description: String,
)
② item_benefit.xml : 데이터 아이템 레이아웃
ListView와 Adapter를 사용하는 이상, 더 이상 수동으로 모든 데이터 아이템을 일일이 xml 코드로 작성하지 않습니다. 개발자는 아이템 레이아웃을 한 번만 구현함으로써 같은 형태의 아이템을 반복적으로 표출하는 데 사용할 수 있습니다.
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/cl_benefit_button"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingHorizontal="16dp"
android:paddingVertical="8dp">
<ImageView
android:id="@+id/iv_benefit_button"
android:layout_width="0dp"
android:layout_height="64dp"
android:background="#FFAAAAAA"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintDimensionRatio="H,1:1"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/tv_benefit_button_title"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
android:textSize="16sp"
android:textStyle="bold"
app:layout_constraintBottom_toTopOf="@id/tv_benefit_button_description"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/iv_benefit_button"
app:layout_constraintTop_toTopOf="@id/iv_benefit_button"
tools:text="혜택 이름" />
<TextView
android:id="@+id/tv_benefit_button_description"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:textColor="#1F70FF"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="@id/tv_benefit_button_title"
app:layout_constraintTop_toBottomOf="@id/tv_benefit_button_title"
tools:text="혜택 설명" />
</androidx.constraintlayout.widget.ConstraintLayout>
③ BenefitAdapter.kt : BaseAdapter를 상속받은 어댑터
BaseAdapter를 상속받은 클래스는 필수로 4개의 메소드를 구현해야 합니다. 각 메소드의 이름과 Adapter 클래스의 역할 및 책임을 연관지어 생각해보시면 메소드 4개를 구현해야 하는 이유를 쉽게 납득할 수 있을 것입니다. BaseAdapter가 내부적으로 이 메소드를 활용하거나, 또는 개발자가 직접 구현한 메소드를 적절히 호출하며 데이터 세트를 렌더링할 수 있게 됩니다.
class BenefitAdapter(
private val benefits: List<Benefit>,
) : BaseAdapter() {
override fun getCount(): Int = benefits.count()
override fun getItem(position: Int): Benefit = benefits[position]
override fun getItemId(position: Int): Long = benefits[position].id
override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
// TODO 추후 개선 필요
val view = View.inflate(parent.context, R.layout.item_benefit, null)
val benefit = getItem(position)
view.findViewById<TextView>(R.id.tv_benefit_button_title).text = benefit.title
view.findViewById<TextView>(R.id.tv_benefit_button_description).text = benefit.description
return view
}
}
기본적으로 Adapter의 getItem()은 반환 타입이 Object(Kotlin의 Any에 대응)로 설정되어 있습니다. 다만 반환 타입이 Any라는 것은 어느 타입이든 반환할 수 있다는 의미이기 때문에 그대로 사용하는 것은 그리 안전하지 않은 방법입니다.
이를 개선하기 위해
공변성을 활용하여 BaseAdapter에서 해당 메소드를 구현할 때는 정말 필요한 타입만 반환하도록 변경하였습니다.
④ activity_benefits.xml : ListView를 호스팅 할 액티비티의 레이아웃
<ListView> 태그만 사용함으로써 모든 아이템을 띄우기 위해 작성했던 반복적인 코드를 제거하였습니다.
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/ll_benefits"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".BenefitsActivity">
<ListView
android:id="@+id/lv_benefits"
android:layout_width="0dp"
android:layout_height="wrap_content"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
⑤ BenefitsActivity.kt : ListView의 아이템을 띄우기 위한 어댑터 생성
class BenefitsActivity : AppCompatActivity() {
private val adapter = BenefitAdapter(BENEFITS)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_benefits)
val benefitsList = findViewById<ListView>(R.id.lv_benefits)
benefitsList.adapter = adapter
}
companion object {
private val BENEFITS =
listOf(
Benefit(1, "버튼 누르기", "10원 받기"),
Benefit(2, "라이브 쇼핑", "포인트 받기"),
Benefit(3, "만보기", "포인트 받기"),
Benefit(4, "친구와 함께 토스 켜고", "포인트 받기"),
Benefit(5, "행운 퀴즈", "추가 혜택 보기"),
)
}
}
[결과 1]과 유사하지만, 아이템마다 하단에 구분선이 추가된 것을 확인할 수 있습니다.
이는 <ListView> 태그에 footerDividersEnabled 속성을 false로 설정해주고, divider 속성을 @null로 설정하는 것으로 해결할 수 있습니다.
<ListView
...
android:footerDividersEnabled="false"
android:divider="@null" />
참고) 기본적으로 footerDividersEnabled 속성은 true로 설정되어 있습니다.
<!-- When set to false, the ListView will not draw the divider before each footer view.
The default value is true. -->
<attr name="footerDividersEnabled" format="boolean" />
데이터를 화면 높이를 넘어갈 정도로 많이 추가해보도록 하겠습니다.
// BenefitsActivity.kt
private val BENEFITS =
listOf(
Benefit(1, "버튼 누르기", "10원 받기"),
Benefit(2, "라이브 쇼핑", "포인트 받기"),
Benefit(3, "만보기", "포인트 받기"),
Benefit(4, "친구와 함께 토스 켜고", "포인트 받기"),
Benefit(5, "행운 퀴즈1", "추가 혜택 보기"),
Benefit(6, "행운 퀴즈2", "추가 혜택 보기"),
// ...
Benefit(20, "행운 퀴즈16", "추가 혜택 보기"),
)
여러 개의 아이템들이 정상적으로 표출되고, 아이템들이 화면의 범위를 넘어서도 스크롤을 통해 모든 데이터를 확인할 수 있었습니다.
만약 [코드 1]처럼 수많은 아이템들을 xml에 추가했다면, 스크롤이 이뤄지지 않았을 것입니다. ScrollView 내부에 아이템들을 두지 않았기 때문입니다.
반면 ListView는 부모 클래스인 AbsListView가 자체적으로 스크롤 동작을 구현하고 있습니다. 그 덕에 개발자가 직접 ScrollView를 사용하지 않아도 스크롤 기능을 지원할 수 있습니다. 실제로 내부에 OnScrollListener라는 인터페이스를 보유하고 있기도 합니다.
// AbsListView.class
public interface OnScrollListener {
int SCROLL_STATE_FLING = 2;
int SCROLL_STATE_IDLE = 0;
int SCROLL_STATE_TOUCH_SCROLL = 1;
void onScrollStateChanged(AbsListView var1, int var2);
void onScroll(AbsListView var1, int var2, int var3, int var4);
}
ListView의 부모 클래스인 AdapterView를 다룬 공식 문서에는 다음과 같은 내용이 명시되어 있습니다.
When the content for your layout is dynamic or not pre-determined, you can use RecyclerView or a subclass of AdapterView.
통념과 달리 많은 양의 데이터를 보여준다는 내용은 명시적으로 나와있지 않습니다.
AdapterView와 그 자식들의 근본적인 역할은 데이터 세트의 개수 혹은 내용, 아이템 레이아웃 등이 미리 정해져 있지 않은 동적인 경우를 커버하는 것입니다.
[코드 1]에서 일부 데이터 혹은 레이아웃에 동적으로 변경 사항이 발생한다면, 개발자는 개별 뷰의 아이디에 직접 접근하여 값을 변경해주어야 합니다. [코드 1]을 기반으로 혜택의 이름을 동적으로 변경해야 할 일이 한다고 가정하면, 다음과 같은 함수를 만들어 사용하게 될 것입니다.
// BenefitsActivity.kt
private fun alterButtonTitle(
@IdRes titleId: Int,
newTitle: String,
) {
val buttonBenefitTitle = findViewById<TextView>(titleId)
buttonBenefitTitle.text = newTitle
}
이 상태에서 데이터의 수가 많아진다면, 대상 뷰를 식별하고 변경하는 과정에서 큰 혼란이 생기게 될 것입니다. 단순 값 변경(Item Changes)은 어찌저찌 해결한다고 해도, 만약 데이터의 위치가 동적으로 변경(Structural Changes)되는 경우가 생긴다면, [코드 1] 방식으로 대응하는 것은 거의 불가능할 것입니다.
ListView를 활용하면, 이러한 동적인 상황들에 손쉽게 대응할 수 있습니다.
앞서 [코드 2]의 Benefit 클래스에서 id 식별자 프로퍼티를 만들고, 그 식별자를 BenefitAdapter 클래스의 getItemId 메소드에서 활용하고 있었습니다.
또한 BenefitAdapter 클래스의 메소드들에서 매개 변수로 position을 활용해 ListView에서 호스팅하고 있는 데이터 세트의 특정 위치에 접근할 수 있었습니다.
즉 Adapter 차원에서, 사용하면 호스팅되어 있는 아이템의 위치(인덱스)와 단일 데이터의 식별자를 활용해 동적인 데이터 변경에 대응할 수 있는 것입니다.
① 데이터 세트 저장소 생성하기
class BenefitRepository
② 데이터 세트 저장소로 옮기기
class BenefitRepository {
// 데이터 세트가 동적으로 변경되므로 가변으로 설정
var benefits =
listOf(
Benefit(1, "버튼 누르기", "10원 받기"),
// ...
Benefit(20, "행운 퀴즈16", "추가 혜택 보기"),
)
private set // 외부에서는 변경 불가능하도록 설정
}
③ 저장소 객체에 데이터 세트의 내용, 위치 변경 기능 구현하기
class BenefitRepository {
// ...
fun updateBenefitTitle(id: Long, newTitle: String) {
benefits = benefits.map { benefit ->
if (benefit.id == id) benefit.copy(title = newTitle) else benefit
}
}
fun updateBenefitPosition(id: Long, newPosition: Int) {
val position = newPosition.coerceIn(benefits.indices)
val targetBenefit = benefits.find { it.id == id } ?: return
benefits = benefits.filterNot { it.id == id }.let { filteredList ->
filteredList.take(position) + targetBenefit + filteredList.drop(position)
}
}
}
④ 버튼을 클릭하면 데이터 세트의 내용과 위치를 변경하기
activity_benefits.xml
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout ...>
<ListView ... />
<Button
android:id="@+id/btn_alter_position"
android:text="위치 변경"
... />
<Button
android:id="@+id/btn_alter_title"
android:text="제목 변경"
... />
</androidx.constraintlayout.widget.ConstraintLayout>
BenefitsActivity.kt
class BenefitsActivity : AppCompatActivity() {
private val benefitRepository = BenefitRepository()
private val adapter = BenefitAdapter(benefitRepository.benefits)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_benefits)
initializeAdapter()
initializeButtons()
}
private fun initializeAdapter() {
val benefitsList = findViewById<ListView>(R.id.lv_benefits)
benefitsList.adapter = adapter
}
private fun initializeButtons() {
val buttonAlterTitle = findViewById<Button>(R.id.btn_alter_title)
val buttonAlterPosition = findViewById<Button>(R.id.btn_alter_position)
buttonAlterTitle.setOnClickListener {
alterBenefitTitle()
adapter.refreshBenefitData(benefitRepository.benefits)
}
buttonAlterPosition.setOnClickListener {
alterBenefitData()
adapter.refreshBenefitData(benefitRepository.benefits)
}
}
private fun alterBenefitTitle() {
benefitRepository.updateBenefitTitle(
id = 3,
newTitle = "변경 완!",
)
}
private fun alterBenefitData() {
benefitRepository.updateBenefitPosition(
id = 3,
newPosition = 0,
)
}
}
BenefitAdapter.kt
BaseAdapter에서 제공하는 notify~ 메소드를 통해 어댑터에 데이터가 변경되었음을 알립니다.class BenefitAdapter(
// 가변으로 변경
private var benefits: List<Benefit>,
) : BaseAdapter() {
// ... 기존 코드...
// 데이터 변경 반영
fun refreshBenefitData(newBenefits: List<Benefit>) {
benefits = newBenefits
// TODO 개선 필요
notifyDataSetChanged()
}
}
의도한 대로 정상적으로 3번째 아이템의 제목이 변경되고, 3번째 아이템이 첫 순서로 이동하게 됩니다.
[코드 4]에서, 데이터 세트의 변경이 발생하고 UI에 반영되는 과정을 그림과 같이 도식화해볼 수 있습니다.

원본 데이터 세트 변경이 일련의 과정을 모두 거쳐야만 변경 사항을 ListView 상에서 확인할 수 있습니다. 개인적으로는 ③의 과정을 잊고 그대로 앱을 실행시켰는데 의도대로 동작하지 않아 당황했던 경험이 많았습니다. 이 과정을 거치지 않으면 원본 데이터 세트에만 반영이 되고 정작 보여주기 위한 어댑터에는 반영이 되지 않아 변경 사항을 확인할 수 없습니다.
[코드 4]의 어댑터에 변경된 전체 데이터 세트를 매개변수로 받아오는 refreshBenefitData 메소드를 만들고, 내부에서는 데이터 세트 리스트의 값을 변경 후 BaseAdapter 내부의 notifyDataSetChanged() 메소드를 통해 어댑터에 변경사항을 알렸습니다.
notifyDataSetChanged()?
Notifies the attached observers that the underlying data has been changed and any View reflecting the data set should refresh itself.
어댑터에 데이터가 변경되었음을 알리고, 모든 뷰가 갱신되도록 하는 역할을 수행합니다. 메소드 하나만 호출해서 모든 변경 사항을 반영할 수 있다는 것은 장점이자 단점이 될 수 있습니다.
가장 큰 단점은 이 특성으로 인해 상황에 따라서는 성능 상 손해를 볼 수 있다는 것입니다. 어떤 데이터가 변경되었는지 구체적으로 명시하지 않고 모든 뷰를 새로고침하기 때문입니다. BaseAdapter의 해당 메소드는 오버라이드가 가능하긴 하지만, 다양한 변경사항들을 오버라이드로 해결하기란 불가능에 가까울 것입니다. 이것을 어떻게 개선하는지는 다음 편에서 알아보도록 하겠습니다.
편안하게 혜택 목록을 만들었는데, 기획자가 추가로 다음과 같은 요구를 한다고 가정해봅시다.
"혜택 3개마다 아래에 광고를 띄워주세요."
광고 아이템을 직접 XML 코드로 끼워넣는 것은 비효율적일 것입니다. 반면 ListView를 사용하면, 손쉽게 여러 종류의 데이터 세트를 하나의 ListView에 서로 다른 레이아웃으로 표출할 수 있습니다.
이 때 사용할 수 있는 것이 뷰 타입(viewType)입니다. 정수(Int)로 되어있으며, 고유한 레이아웃 유형을 식별하기 위해 사용되는 값입니다. 이 정수값이 얼마인지에 따라, 원하는 위치에 서로 다른 레이아웃으로 표출할 수 있게 됩니다.
여기서는 인터페이스를 통해 두 타입 간 상하위 관계를 형성할 수 있다는 원리를 활용하여 목적을 달성하고자 합니다. 즉 서로 다른 두 타입을 한 타입의 하위로 만들고, 어댑터에 사용하는 데이터 타입은 두 데이터 타입의 상위 타입을 활용하는 것입니다.
BenefitListViewItem.kt
혜택과 광고 두 타입을 아우르기 위한 상위 클래스로, 두 타입은 모두 뷰 타입을 가지고 있어야 하므로 공통 프로퍼티로 viewType를 둡니다.
[코드 3]에서 구현해두었던 Benefit를 BenefitViewItem의 하위로 이동시키고, 같은 계층에 광고 데이터를 표기하기 위한 Advertisement 클래스를 추가합니다.
sealed class BenefitListViewItem(val viewType: Int) {
data class Benefit(
val id: Long,
val title: String,
val description: String,
) : BenefitListViewItem(VIEW_TYPE_BENEFIT)
data class Advertisement(
val id: Long,
val content: String,
) : BenefitListViewItem(VIEW_TYPE_ADVERTISEMENT)
companion object {
const val VIEW_TYPE_BENEFIT = 0
const val VIEW_TYPE_ADVERTISEMENT = 1
}
}
BenefitListItem.kt
어댑터에서 사용될 데이터 세트입니다. 어댑터 내부에서 데이터를 식별하기 위해 사용될 식별자(id)와 BenefitListViewItem로 구성되어 있습니다.
data class BenefitListItem(
val id: Long,
val viewItem: BenefitListViewItem,
)
item_advertisement.xml
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout ...>
<TextView
android:id="@+id/tv_advertisement"
tools:text="광고 보고 가시죠"
... />
</androidx.constraintlayout.widget.ConstraintLayout>
좋은 설계는 아니지만, 편의상 혜택과 광고 아이템 모두 한 곳에서 처리하도록 구현합니다.
BenefitRepository.kt
class BenefitRepository {
var benefits =
listOf(
BenefitListItem(1, BenefitListViewItem.Benefit(1, "버튼 누르기", "10원 받기")),
BenefitListItem(2, BenefitListViewItem.Benefit(2, "라이브 쇼핑", "포인트 받기")),
BenefitListItem(3, BenefitListViewItem.Benefit(3, "만보기", "포인트 받기")),
BenefitListItem(4, BenefitListViewItem.Advertisement(1, "광고 보고 가시죠 1")),
// ...
)
private set
fun updateBenefitTitle(id: Long, newTitle: String) {
benefits = benefits.map { benefit ->
if (benefit.viewItem is BenefitListViewItem.Benefit && benefit.viewItem.id == id) {
benefit.copy(viewItem = benefit.viewItem.copy(title = newTitle))
} else {
benefit
}
}
}
fun updateBenefitPosition(id: Long, newPosition: Int) {
val position = newPosition.coerceIn(benefits.indices)
val targetBenefit = benefits.find { it.id == id } ?: return
benefits = benefits.filterNot { it.id == id }.let { filteredList ->
filteredList.take(position) + targetBenefit + filteredList.drop(position)
}
}
}
class BenefitAdapter(
private var benefits: List<BenefitListItem>,
) : BaseAdapter() {
// getCount, getItemId는 동일
override fun getItem(position: Int): BenefitListViewItem = benefits[position].viewItem
// 추가!
override fun getViewTypeCount(): Int = NUMBER_OF_VIEW_TYPES
// 추가!
override fun getItemViewType(position: Int): Int = benefits[position].viewItem.viewType
override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
// TODO 추후 개선 필요
val view = when (val viewType = getItemViewType(position)) {
BenefitListViewItem.VIEW_TYPE_BENEFIT -> View.inflate(parent.context, R.layout.item_benefit, null)
BenefitListViewItem.VIEW_TYPE_ADVERTISEMENT -> View.inflate(parent.context, R.layout.item_advertisement, null)
else -> throw RuntimeException("Unknown view type")
}
when (val viewItem = getItem(position)) {
is BenefitListViewItem.Benefit -> {
view.findViewById<TextView>(R.id.tv_benefit_button_title).text = viewItem.title
view.findViewById<TextView>(R.id.tv_benefit_button_description).text = viewItem.description
}
is BenefitListViewItem.Advertisement -> {
view.findViewById<TextView>(R.id.tv_advertisement).text = viewItem.content
}
}
return view
}
fun refreshBenefitData(newBenefits: List<BenefitListItem>) {
benefits = newBenefits
// TODO 개선 필요
notifyDataSetChanged()
}
companion object {
private const val NUMBER_OF_VIEW_TYPES = 2
}
}
못 보던 getViewTypeCount(), getItemViewType(position: Int) 메소드가 추가되었습니다. 전자는 말 그대로 데이터 세트가 몇 가지 종류인지를 정의합니다. 후자의 경우 getView()에서 만들어지는 뷰의 타입이 어떤 것인지를 Int 형태로 반환하는 방식으로 정의합니다.
Get the type of View that will be created by getView(int, View, ViewGroup) for the specified item.
이어서 앞서 정의한 뷰 타입에 따라 다른 뷰 객체를 만들어, 타입에 맞는 데이터를 할당하는 방식으로 getView()의 구현을 수정합니다.
이제 광고까지 성공적으로 보여주게 되었습니다.
여러 종류의 데이터 셋을 ListView를 활용하여 보여주는 방법까지 살펴보았습니다. 현재까지 작성한 코드는 링크를 통해 확인해보실 수 있습니다.
하지만 현재까지의 구현은 성능 측면에서 많은 문제점을 가지고 있습니다.
다음 게시글에서는 성능을 개선할 수 있는 방법에 대해 알아보도록 하겠습니다.
https://developer.android.com/reference/android/widget/Adapter
https://developer.android.com/develop/ui/views/layout/declaring-layout#AdapterViews
https://developer.android.com/reference/android/widget/ListView