Hanbit the Developer

[Kotlin] Multiple View Types를 지원하는 RecyclerView 구현 본문

Mobile/Android

[Kotlin] Multiple View Types를 지원하는 RecyclerView 구현

hanbikan 2022. 7. 2. 20:57

서론

결과 미리보기

배경

개발을 하다보면 여러 개의 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

data 패키지 구조

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

 

GitHub - hanbikan/common-recycler-view: A recycler view example for multi view types

A recycler view example for multi view types. Contribute to hanbikan/common-recycler-view development by creating an account on GitHub.

github.com

 

https://developer-munny.tistory.com/m/2

 

[Android/Kotlin] Multi-ViewType을 사용하는 RecyclerView의 구조를 추상화 해보기

수정일: 2021/5/12 - class 이름 변경 RecyclerView를 사용하다보면 하나의 아이템만 보여주는것이 아니라 다양한 형태의 아이템을 보여주고 싶을 때가 있습니다. 여러 타입의 아이템을 보여주는 데에는

developer-munny.tistory.com