안드로이드

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

익세망 2021. 4. 4. 00:14

 

수정일: 2021/5/12 - class 이름 변경


RecyclerView를 사용하다보면 하나의 아이템만 보여주는것이 아니라

다양한 형태의 아이템을 보여주고 싶을 때가 있습니다.

 

여러 타입의 아이템을 보여주는 데에는 여러가지 방법이 있겠지만 ViewType을 활용해서 아이템을 구분하고

이 로직을 나름대로 추상화 해보기로 했습니다.

 

목표는 viewType이 추가되더라도 Adapter를 수정하지 않는 구조를 만드는것입니다.

 

ViewType을 활용한 아이템 분류

다음과 같이 viewType을 나누는 방법이 있습니다.

class SampleAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
    private val itemList = ArrayList<Any>()

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
        return when (viewType) {
            NAME_VIEW_TYPE -> NameViewHolder(viewBind(parent, R.layout.item_text))
            IMAGE_VIEW_TYPE -> ImageViewHolder(viewBind(parent, R.layout.item_number))
            else -> throw Exception("unknown type!!")
        }
    }

    private fun <T : ViewDataBinding> viewBind(parent: ViewGroup, layoutRes: Int): T {
        return DataBindingUtil.inflate(
            LayoutInflater.from(parent.context),
            layoutRes,
            parent,
            false
        )
    }

    override fun getItemCount() = itemList.size

    override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
        when (holder) {
            is ImageViewHolder -> holder.bind(itemList[position] as ImageItem)
            is NameViewHolder -> holder.bind(itemList[position] as NameItem)
        }
    }

    override fun getItemViewType(position: Int): Int {
        return when(itemList[position]) {
            is NameItem -> NAME_VIEW_TYPE
            is ImageItem -> IMAGE_VIEW_TYPE
            else -> throw Exception("unknown type!!")
        }
    }

    companion object {
        const val NAME_VIEW_TYPE = 0
        const val IMAGE_VIEW_TYPE = 1
    }
}

이런식으로 나누어주어도 잘 동작하지만 ViewType이 늘어날 때마다 RecyclerView를 수정할 곳이 많아져,

개발자의 실수로 인한 에러가 생길 가능성이 있습니다.

 

 

Sealed Class를 활용해서 ViewHolder와 Item 묶어내기

컴파일 타임에 상속받은 자손을 알 수 있는 sealed class를 활용해서

Item과 ViewHolder를 제한하는 코드를 작성 해보겠습니다.

 

sealed class SampleItem {
    data class NameItem(val str: String) : SampleItem()
    data class ImageItem(@DrawableRes val imageRes: Int) : SampleItem()
}

sealed class SampleViewHolder(
    binding: ViewDataBinding
) : RecyclerView.ViewHolder(binding.root) {
    data class ImageViewHolder(
        private val binding: ItemNumberBinding
    ) : SampleViewHolder(binding) {
        fun bind(item: SampleItem.ImageItem) {
            binding.imageRes = item.imageRes
        }
    }

    data class NameViewHolder(
        private val binding: ItemTextBinding
    ) : SampleViewHolder(binding) {
        fun bind(item: SampleItem.NameItem) {
            binding.text = item.str
        }
    }
}


class SampleAdapter : RecyclerView.Adapter<SampleViewHolder>() {
    private val itemList = ArrayList<SampleItem>()

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SampleViewHolder {
        return when (viewType) {
            NAME_VIEW_TYPE -> 
            	SampleViewHolder.NameViewHolder(viewBind(parent, R.layout.item_text))
            IMAGE_VIEW_TYPE -> 
            	SampleViewHolder.ImageViewHolder(viewBind(parent, R.layout.item_number))
            else -> throw Exception("unknown type!!")
        }
    }

    private fun <T : ViewDataBinding> viewBind(parent: ViewGroup, layoutRes: Int): T {
        return DataBindingUtil.inflate(
            LayoutInflater.from(parent.context),
            layoutRes,
            parent,
            false
        )
    }

    override fun getItemCount() = itemList.size

    override fun onBindViewHolder(holder: SampleViewHolder, position: Int) {
        when (holder) {
            is SampleViewHolder.ImageViewHolder ->
            	holder.bind(itemList[position] as SampleItem.ImageItem)
            is SampleViewHolder.NameViewHolder ->
            	holder.bind(itemList[position] as SampleItem.NameItem)
        }
    }

    override fun getItemViewType(position: Int): Int {
        return when (itemList[position]) {
            is SampleItem.NameItem -> NAME_VIEW_TYPE
            is SampleItem.ImageItem -> IMAGE_VIEW_TYPE
        }
    }

    companion object {
        const val NAME_VIEW_TYPE = 0
        const val IMAGE_VIEW_TYPE = 1
    }
}

sealed class를 활용하여 각각의 역할에 맞게 구분을 지어주었고 Adapter에서는 모호한 타입이 사라져 몇몇 else 구문이 사라졌습니다.

하지만 Adapter에 ViewType을 추가할 때 수정할 부분은 여전히 많이 존재합니다.

 

 

Generic을 활용하여 onBindViewHolder 추상화 하기

현재 ViewHolder는 SampleViewHolder라는 공통된 조상을 가지고 있습니다.

이를 활용해서 추상 함수를 만들어줍니다.

sealed class SampleViewHolder<E: SampleItem>(
    binding: ViewDataBinding
) : RecyclerView.ViewHolder(binding.root) {
    data class ImageViewHolder(
        private val binding: ItemNumberBinding
    ) : SampleViewHolder<SampleItem.ImageItem>(binding) {
        override fun bind(item: SampleItem.ImageItem) {
            binding.imageRes = item.imageRes
        }
    }

    data class NameViewHolder(
        private val binding: ItemTextBinding
    ) : SampleViewHolder<SampleItem.NameItem>(binding) {
        override fun bind(item: SampleItem.NameItem) {
            binding.text = item.str
        }
    }

    abstract fun bind(item: E)
}

뷰 홀더를 이렇게 수정하게 되면 onBindViewHolder는 다음과 같이 수정할 수 있게 됩니다.

override fun onBindViewHolder(holder: SampleViewHolder<SampleItem>, position: Int) {
    holder.bind(itemList[position])
}

 

 

Enum을 활용해서 getItemViewType 추상화 하기

현재는 item의 종류를 늘리고 getItemViewType에 코드를 추가하지 않아도 컴파일러에서 에러로 표기 해주지 않습니다.

이를 강제하는 코드를 넣어보겠습니다.

enum class SampleViewType {
    NAME, IMAGE
}

sealed class SampleItem(private val sampleViewType: SampleViewType) {
    data class NameItem(val name: String) : SampleItem(SampleViewType.NAME)
    data class ImageItem(@DrawableRes val imageRes: Int) : SampleItem(SampleViewType.IMAGE)

    fun getViewType() = sampleViewType
}

class SampleAdapter : RecyclerView.Adapter<SampleViewHolder<SampleItem>>() {
    private val itemList = ArrayList<SampleItem>()

    @Suppress("UNCHECKED_CAST")
    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SampleViewHolder<SampleItem> {
        return when (SampleViewType.values()[viewType]) {
            SampleViewType.NAME ->
                SampleViewHolder.NameViewHolder(viewBind(parent, R.layout.item_text))
            SampleViewType.IMAGE ->
                SampleViewHolder.ImageViewHolder(viewBind(parent, R.layout.item_number))
        } as SampleViewHolder<SampleItem>
    }

    private fun <T : ViewDataBinding> viewBind(parent: ViewGroup, layoutRes: Int): T {
        return DataBindingUtil.inflate(
            LayoutInflater.from(parent.context),
            layoutRes,
            parent,
            false
        )
    }

    override fun getItemCount() = itemList.size

    override fun onBindViewHolder(holder: SampleViewHolder<SampleItem>, position: Int) {
        holder.bind(itemList[position])
    }

    override fun getItemViewType(position: Int): Int {
        return itemList[position].getViewType().ordinal
    }
}

enum의 아이템 순번을 viewType으로 활용하였습니다. 이제 item이 늘어날 때 마다 enum값을 추가하는것이

강제되므로 viewType을 추가하지 않아서 생기는 문제가 발생하지 않을 것입니다.

UNCHECKED_CAST 어노테이션이 필요한 이유는 링크를 통해 이해하는데 도움이 되었습니다.

 

 

Factory 패턴으로 onCreateViewHolder 추상화 하기

크게 변화되는점은 없지만 ViewHolder를 생성하는 코드를 Factory 클래스로 나누는 작업을 했습니다.

class SampleViewHolderFactory {
    @Suppress("UNCHECKED_CAST")
    fun getViewHolder(parent: ViewGroup, viewType: SampleViewType): SampleViewHolder<SampleItem> {
        return when (viewType) {
            SampleViewType.NAME ->
                SampleViewHolder.NameViewHolder(viewBind(parent, R.layout.item_text))
            SampleViewType.IMAGE ->
                SampleViewHolder.ImageViewHolder(viewBind(parent, R.layout.item_number))
        } as SampleViewHolder<SampleItem>
    }

    private fun <T : ViewDataBinding> viewBind(parent: ViewGroup, layoutRes: Int): T {
        return DataBindingUtil.inflate(
            LayoutInflater.from(parent.context),
            layoutRes,
            parent,
            false
        )
    }
}

ViewGroup 값과 Enum값을 받아오는것을 통해서 ViewHolder를 만들어 주는것으로 수정하면

class SampleAdapter : RecyclerView.Adapter<SampleViewHolder<SampleItem>>() {
    private val itemList = ArrayList<SampleItem>()
    private val sampleViewHolderFactory = SampleViewHolderFactory()

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SampleViewHolder<SampleItem> {
        return sampleViewHolderFactory.getViewHolder(parent, SampleViewType.values()[viewType])
    }

    override fun getItemCount() = itemList.size

    override fun onBindViewHolder(holder: SampleViewHolder<SampleItem>, position: Int) {
        holder.bind(itemList[position])
    }

    override fun getItemViewType(position: Int): Int {
        return itemList[position].getViewType().ordinal
    }
}

최종적으로 Adapter는 이런 형태로 변하게 됩니다.

 

마무리

ViewType이 많아지는 경우를 대비해서 추상화를 진행 해보았습니다.

이렇게 추상화된 Adapter는 수정할 필요가 전혀 없어졌고 UI에 값을 전달하는 역할만을 수행 하고

필요한 기능은 명확한 위치에 추가할 수 있게 구조화 되었습니다.

 

물론 제가 소개드린 방법 보다 좋은 방법이 많을 수 있겠지만 맨 처음 작성했던 방식보다

유지보수나 가독성에 유리 할것이라고 생각합니다.

 

다양한 의견은 언제든지 환영입니다.

전체 코드는 제 GitHub에서 확인 하실 수 있습니다.

 

읽어 주셔서 감사합니다.

반응형