Hanbit the Developer
[Kotlin] Multiple View Types를 지원하는 RecyclerView 구현 본문
서론
결과 미리보기
배경
개발을 하다보면 여러 개의 Recycler View를 만들게 되어 프로젝트 구조가 복잡해지기 마련입니다. 또한 위 사진처럼 하나의 Recycler View에 여러 타입의 뷰를 띄워야 하는 경우도 있습니다. 예를 들면, 네이버 카페 어플에서 글을 본다고 할 때, 사진+제목, 제목, 광고와 같은 타입으로 아이템이 나눠질 수 있습니다. 여기서 각 Recycler View의 아이템들을 여러 타입으로 분류하여 RecyclerView.Adapter를 상속하는 CommonAdapter 클래스 하나만으로 구현하는 방법을 알아보겠습니다.
먼저 서버단에서 데이터를 어떻게 보내야 할지를 정의해야 합니다. data는 CommonItem이라는 객체의 리스트를 담고 있습니다. CommonItem은 Recycler View의 아이템 타입 및 내용을 정의합니다.
{
"data": [
{
"viewType": "ONE_LINE_TEXT",
"viewObject": {
"contents": "제목이 없는 글입니다."
}
},{
"viewType": "TWO_LINE_TEXT",
"viewObject": {
"title": "제목입니다.",
"contents": "안녕하세요. 반갑습니다. 제 이름은 홍길동입니다."
}
},{
"viewType": "ONE_IMAGE",
"viewObject": {
"url": "https://picsum.photos/536/354"
}
}
]
}
일반적인 경우였다면 viewType, viewObject 없이 contents만을 포함하고 있었을 것이지만, 이 경우는 그렇지 않습니다.
*이 글에서 Retrofit2의 내용은 중요하지 않으므로, API 호출 및 객체로의 파싱 과정은 생략하며, Recycler View에 초기값을 주는 것으로 대체합니다.
구현
Item layout
각 뷰타입에 해당하는 레이아웃입니다.
item_one_line_text.xml
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android">
<androidx.cardview.widget.CardView
android:layout_width="match_parent"
android:layout_height="150dp"
android:layout_margin="16dp">
<TextView
android:id="@+id/text_contents"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="16dp"/>
</androidx.cardview.widget.CardView>
</layout>
item_two_line_text.xml
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android">
<androidx.cardview.widget.CardView
android:layout_width="match_parent"
android:layout_height="150dp"
android:layout_margin="16dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:padding="16dp"
android:orientation="vertical">
<TextView
android:id="@+id/text_title"
android:textSize="16dp"
android:layout_width="wrap_content"
android:layout_height="wrap_content"/>
<TextView
android:id="@+id/text_contents"
android:layout_width="wrap_content"
android:layout_height="wrap_content"/>
</LinearLayout>
</androidx.cardview.widget.CardView>
</layout>
item_one_image.xml
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android">
<androidx.cardview.widget.CardView
android:layout_width="match_parent"
android:layout_height="150dp"
android:layout_margin="16dp">
<ImageView
android:id="@+id/image"
android:layout_width="wrap_content"
android:layout_height="wrap_content"/>
</androidx.cardview.widget.CardView>
</layout>
Entity, ViewObject
CommonItem.kt
package com.example.commonrecyclerview.data
/** 모든 유형의 아이템은 CommonItem의 형태로 주어집니다. */
data class CommonItem(
val viewType: String,
val viewObject: ViewObject
)
위에서 다뤘던, 서버로부터의 데이터를 담게 될 Entity를 정의합니다.
아래는 리마인드를 위한 각 아이템의 JSON입니다.
"viewType": "ONE_LINE_TEXT",
"viewObject": {
"contents": "제목이 없는 글입니다."
}
viewobject.ViewObject.kt
package com.example.commonrecyclerview.data.viewobject
/** 새로운 뷰타입이 생길 때마다 자식을 추가해야 합니다. */
open class ViewObject {}
viewobject.OneLineTextViewObject.kt
package com.example.commonrecyclerview.data.viewobject
data class OneLineTextViewObject(
val contents: String
): ViewObject()
viewobject.TwoLineTextViewObject.kt
package com.example.commonrecyclerview.data.viewobject
data class TwoLineTextViewObject(
val title: String,
val contents: String
): ViewObject()
viewobject.OneImageViewObject.kt
package com.example.commonrecyclerview.data.viewobject
data class OneImageViewObject (
val url: String
): ViewObject()
view type마다 viewObject가 함유하고 있는 데이터의 내용이 다르므로, 뷰타입에 대응하는 ViewObject 자식 클래스를 정의해야 합니다.
ViewType.kt
package com.example.commonrecyclerview.utility
/** 새로운 뷰타입이 생길 때마다 업데이트해야 합니다. */
enum class ViewType(name: String) {
ONE_LINE_TEXT("ONE_LINE_TEXT"),
TWO_LINE_TEXT("TWO_LINE_TEXT"),
ONE_IMAGE("ONE_IMAGE")
}
여기서 각 view type들을 함유하는 enum class를 정의합니다. 해당 클래스는 후술하게 될 CommonAdapter.kt에서 사용됩니다. 입력으로 받은 viewType이 "TWO_LINE_TEXT"일 때 1이라는 인덱스를 얻을 수 있습니다.
Recycler View
[⭐️] CommonViewHolder.kt(중요)
package com.example.commonrecyclerview.viewholder
import androidx.databinding.ViewDataBinding
import androidx.recyclerview.widget.RecyclerView
import com.example.commonrecyclerview.data.CommonItem
/** 새로운 뷰타입이 생길 때마다 자식 뷰홀더를 추가해야 합니다. */
abstract class CommonViewHolder(
binding: ViewDataBinding
) : RecyclerView.ViewHolder(binding.root) {
abstract fun bind(item: CommonItem)
}
먼저 CommonViewHolder를 만들고, bind라는 abstract fun을 정의합니다. 뷰홀더에서 bind라는 동작, 즉 데이터가 들어왔을 때 이 값을 뷰에 적용하는 작업을 해야만 하기 때문에 이렇게 추상화하는 것입니다. 여기서 ViewDataBinding을 생성자 인자로 받게 되는데, bind 함수에서 이 데이터바인딩을 통해 값을 뷰에 적용하게끔 설계합니다.
이를 실질적으로 구현하는 자식 클래스는 다음과 같습니다.
OneLineTextViewHolder.kt
package com.example.commonrecyclerview.viewholder
import com.example.commonrecyclerview.data.CommonItem
import com.example.commonrecyclerview.data.viewobject.OneLineTextViewObject
import com.example.commonrecyclerview.data.viewobject.ViewObject
import com.example.commonrecyclerview.databinding.ItemOneLineTextBinding
class OneLineTextViewHolder(
private val binding: ItemOneLineTextBinding
) : CommonViewHolder(binding) {
override fun bind(item: CommonItem) {
val viewObject = item.viewObject as OneLineTextViewObject
binding.textContents.text = viewObject.contents
}
}
TwoLineTextViewHolder.kt
package com.example.commonrecyclerview.viewholder
import com.example.commonrecyclerview.data.CommonItem
import com.example.commonrecyclerview.data.viewobject.TwoLineTextViewObject
import com.example.commonrecyclerview.data.viewobject.ViewObject
import com.example.commonrecyclerview.databinding.ItemTwoLineTextBinding
class TwoLineTextViewHolder(
private val binding: ItemTwoLineTextBinding
) : CommonViewHolder(binding) {
override fun bind(item: CommonItem) {
val viewObject = item.viewObject as TwoLineTextViewObject
binding.textTitle.text = viewObject.title
binding.textContents.text = viewObject.contents
}
}
OneImageViewHolder.kt
package com.example.commonrecyclerview.viewholder
import com.bumptech.glide.Glide
import com.example.commonrecyclerview.data.CommonItem
import com.example.commonrecyclerview.data.viewobject.OneImageViewObject
import com.example.commonrecyclerview.data.viewobject.ViewObject
import com.example.commonrecyclerview.databinding.ItemOneImageBinding
class OneImageViewHolder(
private val binding: ItemOneImageBinding
) : CommonViewHolder(binding) {
override fun bind(item: CommonItem) {
val viewObject = item.viewObject as OneImageViewObject
Glide.with(binding.root)
.load(viewObject.url)
.into(binding.image)
}
}
[⭐️] CommonAdapter.kt(중요)
package com.example.commonrecyclerview.adapter
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import com.example.commonrecyclerview.data.CommonItem
import com.example.commonrecyclerview.utility.CommonViewHolderFactory
import com.example.commonrecyclerview.utility.ViewType
import com.example.commonrecyclerview.viewholder.CommonViewHolder
class CommonAdapter(
private val dataSet: Array<CommonItem>
) : RecyclerView.Adapter<CommonViewHolder>() {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): CommonViewHolder {
return CommonViewHolderFactory.createViewHolder(parent, viewType)
}
override fun onBindViewHolder(holder: CommonViewHolder, position: Int) {
holder.bind(dataSet[position])
}
override fun getItemCount(): Int = dataSet.size
override fun getItemViewType(position: Int): Int {
return ViewType.valueOf(dataSet[position].viewType).ordinal
}
}
먼저 최하단의 getItemViewType 함수를 보면, 현재 아이템의 viewType를 이용하여 해당 viewType의 ordinal(인덱스)을 리턴해줍니다. 그럼 이 정수형의 viewType은 onCreateViewHolder의 인자로 연결됩니다. 예를 들어 "TWO_LINE_TEXT"에 해당하는 아이템의 경우, onCreateViewHolder의 viewType 인자로 1값이 들어가게 됩니다.
onCreateViewHolder에서 이 viewType에 따라 그것에 맞는 ViewHolder를 리턴해야 합니다. 이를 위해서 CommonViewHolderFactory라는 object를 추가합니다.
CommonViewHolderFactory.kt
package com.example.commonrecyclerview.utility
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.databinding.DataBindingUtil
import androidx.databinding.ViewDataBinding
import com.example.commonrecyclerview.R
import com.example.commonrecyclerview.viewholder.CommonViewHolder
import com.example.commonrecyclerview.viewholder.OneImageViewHolder
import com.example.commonrecyclerview.viewholder.OneLineTextViewHolder
import com.example.commonrecyclerview.viewholder.TwoLineTextViewHolder
object CommonViewHolderFactory {
fun createViewHolder(parent: ViewGroup, viewType: Int): CommonViewHolder {
return when(viewType) {
ViewType.ONE_LINE_TEXT.ordinal -> OneLineTextViewHolder(getViewDataBinding(parent, R.layout.item_one_line_text))
ViewType.TWO_LINE_TEXT.ordinal -> TwoLineTextViewHolder(getViewDataBinding(parent, R.layout.item_two_line_text))
else -> OneImageViewHolder(getViewDataBinding(parent, R.layout.item_one_image))
}
}
private fun <T: ViewDataBinding> getViewDataBinding(parent: ViewGroup, layoutRes: Int): T {
return DataBindingUtil.inflate(
LayoutInflater.from(parent.context),
layoutRes,
parent,
false
)
}
}
이를 정리하면 다음과 같습니다.
CommonViewHolderFactory에서 getViewDataBinding라는 함수가 쓰이는데, 이는 단순히 데이터 바인딩을 위한 binding을 쉽게 리턴하기 위한 수단입니다.
onBindViewHolder에서 뷰홀더의 상위 클래스인 CommonViewHolder로 추상화된 bind 함수를 호출함으로써 Recycler View의 구현이 완료됩니다.
Main Activity
activity_main.xml
<?xml version="1.0" encoding="utf-8"?>
<layout 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">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerview_common"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:layoutManager="androidx.recyclerview.widget.StaggeredGridLayoutManager"
app:spanCount="2"
/>
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>
MainActivity.kt
class MainActivity : AppCompatActivity() {
private lateinit var binding: ActivityMainBinding
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = DataBindingUtil.setContentView(this, R.layout.activity_main)
val adapter = CommonAdapter(
arrayOf(
CommonItem(
"ONE_LINE_TEXT",
ViewObject.OneLineTextViewObject("제목이 없는 글입니다.")
), CommonItem(
"TWO_LINE_TEXT",
ViewObject.TwoLineTextViewObject("제목입니다.", "안녕하세요. 반갑습니다. 제 이름은 홍길동입니다.")
), CommonItem(
"ONE_IMAGE",
ViewObject.OneImageViewObject("https://picsum.photos/536/354")
)
)
)
binding.recyclerviewCommon.adapter = adapter
}
}
처음에 가정한 상황에 해당하는 데이터를 기본값으로서 Recycler View에 대입하게 됩니다.
결론
이로써 모든 구현이 완료가 되었습니다.
만약 새 뷰타입을 생성한다고 하면 다음 내용을 수행하면 됩니다.
1. ViewType enum class에 새 ViewType를 넣는다.
2. ViewObject를 상속하는 DAO data class를 추가한다.
3. CommonViewHolder를 상속하는 ViewHolder class를 추가한다.
4. CommonViewHolderFactory에 새 분기문을 추가한다.
또한 새 리싸이클러뷰를 추가한다고 할 때, 이번에 추가한 Common Recycler View를 그대로 사용하면 됩니다.
참조
제가 작성한 코드는 아래 Repo에서 보실 수 있습니다.
https://github.com/hanbikan/common-recycler-view
https://developer-munny.tistory.com/m/2
'Android' 카테고리의 다른 글
[Kotlin] BottomNavigationView with multiple navigation (0) | 2022.07.19 |
---|---|
Android AAR Library 추가 방법 (0) | 2022.07.07 |
JVM, DVM, ART(URL) (0) | 2022.03.27 |
[Kotlin] Recycler View Animation Using Custom Layout Manager (0) | 2022.03.24 |
[Kotlin] Android DataBinding 예제(+ 양방향 데이터 바인딩, 클릭 이벤트, 삼항 연산자) (0) | 2022.02.10 |