chacha's

♻ RecyclerView 에 🎩Header 추가하기 본문

Android/My Library

♻ RecyclerView 에 🎩Header 추가하기

Cha_Cha 2021. 6. 7. 20:32
 Headers in RecyclerView - Codelabs 
 TrackMySleepQuality with RecyclerView - Github
 를 참고하여 작성한 글입니다. 

RecyclerView는 LayoutManager로부터 레이아웃 로직을 위임받기 때문에 우리가 화면에 표시하려는 데이터에 대해 전혀 알지 못합니다. Adapter가 화면에 데이터를 보일 수 있게 하는 작업을 하기 때문에, Header를 어떻게 보여줄지도 결정합니다. 따라서 우리는 리사이클러뷰가 사용하는 어댑터를 수정하여 Header를 추가해줘야 합니다.

첫 번째 방법

Header를 표시해야 하는지/Item을 표시해야 하는지 인덱스를 체크하고, Header와 Item이 서로 다른 뷰 홀더(ViewHolder)를 사용하도록 하는 것입니다. 

 

두 번째 방법

Header와 Item 데이터를 하나의 리스트로 수정하는 것입니다. 이해하기에는 이 방법이 첫 번째 방법보다 더 쉬울 수 있지만, 여러 데이터를 하나의 리스트로 합치는 방법에 대하여 잘 생각해야 합니다. 

📌 두 가지 방법 모두 각각 장단점이 있습니다. 

2️⃣ 데이터를 하나의 리스트로 통합하여 사용하는 경우, 데이터 리스트는 변경되도 Adapter가 변경하지 않아도 되고, 데이터 리스트의 변경만으로 Header에 관한 로직을 추가할 수 있습니다. 반면에 1️⃣ Header와 Item이 서로 다른 ViewHolder를 사용하는 경우, Header 레이아웃을 좀 더 자유롭게 만들 수 있고 데이터 리스트를 수정하지 않고도 데이터가 뷰에 적용되는 방법을 수정할 수 있습니다.

🎯 이 게시글에서는 첫 번째 방법을 활용하여 RecyclerView에 Header를 생성해줄 것입니다.


 💡 RecyclerView에 Header를 추가해봅시다!

 전체 코드는 Github에서 확인하실 수 있습니다.
 - Add a List Header in RecyclerView
 - Improve Header

1. Adapter 클래스 아래에, sealed class를 추가해줍니다.

 Sealed class 구현 방법 및 예제 - codechacha
 코틀린의 Sealed class - 찰스의 안드로이드
 게시글을 참고하였습니다.
📌 Sealed class가 뭔가요???
Super class를 상속받는 하위 클래스의 종류를 제한하는 특성을 가지는 클래스로 enum과 유사하지만 보다 많은 기능을 제공해주는 클래스입니다.
sealed class DataItem {
    data class SleepNightItem(val sleepNight: SleepNight): DataItem() {
        override val id = sleepNight.nightId
    }

    object Header: DataItem() {
        override val id = Long.MIN_VALUE
    }

    abstract val id: Long
}

2. Adapter 클래스 내부에 Header를 위한 Holder 클래스를 생성합니다.

class TextViewHolder(view: View): RecyclerView.ViewHolder(view) {
    companion object {
        fun from(parent: ViewGroup): TextViewHolder {
            val layoutInflater = LayoutInflater.from(parent.context)
            val view = layoutInflater.inflate(R.layout.header, parent, false)
            return TextViewHolder(view)
        }
    }
}

3. 어떤 타입의 ViewHolder라도 지원할 수 있도록 ViewHolder에 대한 선언을 RecyclerView.ViewHolder로 수정하고, SleepNight 아이템과 Header 모두 적용할 수 있도록 리스트의 type을 DataItem으로 수정합니다.

class SleepNightAdapter(private val clickListener:SleepNightListener)
        : ListAdapter<DataItem, RecyclerView.ViewHolder>(SleepNightDiffCallback())

4. DiffCallbackSleepNight 대신에 DataItem로 다루도록 수정합니다.

class SleepNightDiffCallback : DiffUtil.ItemCallback<DataItem>() {
    override fun areItemsTheSame(oldItem: DataItem, newItem: DataItem): Boolean {
        return oldItem.id == newItem.id
    }

    override fun areContentsTheSame(oldItem: DataItem, newItem: DataItem): Boolean {
        return oldItem == newItem
    }
}

5. 어떤 View type을 return 해야하는지 Adpater가 알기 위해서, Item 리스트 안에 어떤 type의 Item이 있는지 체크하는 로직을 추가해줍니다.

먼저, Adapter 파일 상단에 Header와 SleepNight Item type에 대한 변수를 선언합니다.

private const val ITEM_VIEW_TYPE_HEADER = 0
private const val ITEM_VIEW_TYPE_ITEM = 1

그런 다음 getItemViewType을 오버라이드하고 올바른 Item type을 리턴합니다.

    override fun getItemViewType(position: Int): Int {
        return when (getItem(position)) {
            is DataItem.Header -> ITEM_VIEW_TYPE_HEADER
            is DataItem.SleepNightItem -> ITEM_VIEW_TYPE_ITEM
        }
    }

6. onCreateViewHolder의 리턴 타입을 RecyclerView.ViewHolder로 변경하고, 올바른 Item type을 반환할 수 있도록 로직을 수정합니다.

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
        return when(viewType) {
            ITEM_VIEW_TYPE_HEADER -> TextViewHolder.from(parent)
            ITEM_VIEW_TYPE_ITEM -> ViewHolder.from(parent)
            else -> throw ClassCastException("Unknown viewType $viewType")
        }
    }

7. onBindViewHolder() 에서는 DataItem 내부의 SleepNightViewHolder에게 전달해줍니다.

    override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
        when (holder) {
            is ViewHolder -> {
                val nightItem = getItem(position) as DataItem.SleepNightItem
                holder.bind(nightItem.sleepNight!!, clickListener)
            }
        }
    }

8. List<SleepNight> List<DataItem>으로 변환해 줄 메서드를 선언합니다.

class SleepNightAdapter(private val clickListener:SleepNightListener) : ListAdapter<DataItem,
        RecyclerView.ViewHolder>(SleepNightDiffCallback()) {

    private val adapterScope = CoroutineScope(Dispatchers.Default)
    ...
    fun addHeaderANdSubmitList(list: List<SleepNight>?) {
        adapterScope.launch {
            val items = when (list) {
                null -> listOf(DataItem.Header)
                else -> listOf(DataItem.Header) + list.map { DataItem.SleepNightItem(it) }
            }
            withContext(Dispatchers.Main) {
                submitList(items)
            }
        }
    }
    ...
}

9. Fragment에서 SleepNight 리스트 대신 DataItem 리스트를 전달하기 위해서  AddHeaderAndSubmitList 메서드를 호출합니다.

        sleepTrackerViewModel.nights.observe(viewLifecycleOwner, Observer {
            it?.let {
                adapter.addHeaderANdSubmitList(it)
            }
        })

 📱 실행 결과

 📝 GridLayout에서 Header 구분하기

위의 GridLayout에서는 Header가 SleepNight Item과 같은 너비로 한 span을 차지합니다. Header가 한 줄을 다 사용하게 하기 위해서, Header는 3개의 span을 사용하고 SleepNight는 1개만 사용하도록 하면 아래의 실행 결과를 얻을 수 있습니다. 

        // SleepTrackerFragment.class
        val manager = GridLayoutManager(activity, 3)
        manager.spanSizeLookup = object : GridLayoutManager.SpanSizeLookup() {
            override fun getSpanSize(position: Int): Int = when (position) {
                0 -> 3
                else -> 1
            }
        }
// header.xml
android:textColor="@color/white_text_color"
android:layout_marginStart="16dp"
android:layout_marginTop="16dp"
android:layout_marginEnd="16dp"
android:background="@color/colorAccent"

END

Comments