버튼 수집상

[안드로이드] ListAdapter DiffUtil 제대로 쓰기 - 4 본문

TIL - 안드로이드

[안드로이드] ListAdapter DiffUtil 제대로 쓰기 - 4

cocokaribou 2023. 11. 30. 11:10

지난 글 링크

 

[안드로이드] ListAdapter DiffUtil 제대로 쓰기 - 3

지난 글에서 ListAdapter DiffUtil 의 getChangePayload 활용법에 대해 적었다. [안드로이드] ListAdapter DiffUtil 제대로 쓰기 - 2 1편에서 ListAdapter에 submitList를 하면서 기존 리스트를 업데이트할 때 리스트 깊은

collectingbuttons.tistory.com

 

지난 글에 이어서 DiffUtil.ItemCallbackgetChangePayload 함수를 이용해서

expand - collapse 되는 리스트 UI를 만들어보겠다.

 

UI 구조

상위 카테고리 ▲
      |_ 하위 카테고리
      |_ 하위 카테고리
상위 카테고리 ▼
상위 카테고리 ▼
상위 카테고리 ▼


상위 카테고리를 접었다 펼칠 수 있다.
리사이클러뷰를 중첩으로 사용하지 않기 위해 두 개의 뷰홀더를 생성해서 타입으로 제어한다.

즉, 어댑터에 등록하는 리스트는 1뎁스짜리 평평한 리스트다.

 

지난 글에서는 하위 카테고리를 add / remove 하면서 UI를 그렸다.

그랬더니 ListAdapter의 areItemsTheSame에서 false가 나와서 payload 없이 뷰홀더를 다시 그렸다.

override fun areItemsTheSame(oldItem: SimpleCategory, newItem: SimpleCategory): Boolean {
    if (oldItem.title != newItem.title) {
        // 객체의 고유값이 달라지는 경우
        Logger.v("${oldItem.title}(${oldItem.type}) -> ${newItem.title}(${newItem.type})")
    }
    return oldItem.title == newItem.title
}

// list에 요소를 add/remove 하면 여기를 타지 않는다.
override fun getChangePayload(oldItem: SimpleCategory, newItem: SimpleCategory): Any? {
    return if (oldItem.isExpanded == newItem.isExpanded) {
        super.getChangePayload(oldItem, newItem)
    } else {
        newItem.isExpanded
    }
}

areItemsTheSame 쪽 로그는 이렇게 찍힌다.

 V  category2(CATEGORY) -> 1-1(SUB_CATEGORY)
 V  category1(CATEGORY) -> 1-4(SUB_CATEGORY)
 V  category2(CATEGORY) -> 1-2(SUB_CATEGORY)
 V  category3(CATEGORY) -> 1-1(SUB_CATEGORY)
 V  category1(CATEGORY) -> 1-3(SUB_CATEGORY)
 V  category2(CATEGORY) -> 1-3(SUB_CATEGORY)
 V  category3(CATEGORY) -> 1-2(SUB_CATEGORY)
 V  category1(CATEGORY) -> 1-2(SUB_CATEGORY)
 V  category2(CATEGORY) -> 1-4(SUB_CATEGORY)
 V  category1(CATEGORY) -> 1-2(SUB_CATEGORY)
 V  category1(CATEGORY) -> 1-1(SUB_CATEGORY)
 V  category1(CATEGORY) -> 1-1(SUB_CATEGORY)

 

getChangePayload를 불러서 부분 갱신된 값만 받고 싶은데,

아이템을 add/remove하면 부분갱신하기 위해 비교하는 고유값이 달라지면서 (areItemsTheSame) onBindViewHolder를 탔다.

 

그래서 요소 개수로 제어되는 게 아닌,

요소의 상태값으로 제어되는 UI로 다시 구성했다.

 

그림에 그려진 add("1-1") 이 부분은 add(object("1-1")) 요런 식으로 이해해주시길 바란다.

 

데이터 모델 SimpleCategory.kt

// 상위/하위 카테고리 데이터 차이 거의 없음
// 동일한 데이터 모델을 사용한다
data class SimpleCategory(
    // raw data
    var title: String,
    var subCategories: List<SimpleCategory>,

    // UI data
    var id: UUID,
    var type: CategoryType,
    var isExpanded: Boolean = false,
    var upperIndex: Int = -1 // -1일 경우, 상위카테고리
) {
    // 상위카테고리 UI data 할당
    constructor(title: String, subCategories: List<SimpleCategory>) : this(
        title, subCategories, UUID.randomUUID(), CategoryType.CATEGORY, false
    )

    // 하위카테고리 UI data 할당
    constructor(title: String) : this(
        title, listOf(), UUID.randomUUID(), CategoryType.SUB_CATEGORY, false
    )
}

enum class CategoryType {
    CATEGORY,
    SUB_CATEGORY
}

 

모의 데이터 MockRawData.kt

object MockRawData {
    val list: List<SimpleCategory> = listOf(
        SimpleCategory(
            title = "category1", subCategories = listOf(
                SimpleCategory("1-1"),
                SimpleCategory("1-2"),
                SimpleCategory("1-3"),
                SimpleCategory("1-4")
            )
        ),
        SimpleCategory(
            title = "category2", subCategories = listOf(
                SimpleCategory("2-1"),
                SimpleCategory("2-2")
            )
        ),
        SimpleCategory(
            title = "category3", subCategories = listOf(
                SimpleCategory("3-1"),
                SimpleCategory("3-2"),
                SimpleCategory("3-3"),
            )
        ),
    )
}

 

메인 액티비티 MainActivity.kt

class MainActivity : AppCompatActivity(R.layout.actvity_main) {
    private val rv by lazy { 
        findViewById<RecyclerView>(R.id.list).apply { 
            adapter = SimpleAdapter() 
            itemAnimator = null // 아이템 변경시 애니메이션 효과 사라짐
        } 
    }
    private val viewModel by lazy { MainViewModel() }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        initObserve()
    }

    private fun initObserve() {
        // 리스트 초기화 & 업데이트
        viewModel.moduleList.observe(this) { list ->
            (rv.adapter as? SimpleAdapter)?.submitList(list)
        }

        // 클릭 이벤트
        EventBus.clickEvent.observe(this) { event ->
            viewModel.updateList(event.index)
        }
    }
}

메인 뷰모델 MainViewModel.kt

updateList() 함수가 수정되었다.

import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import com.example.diffutilchecker.model.CategoryType
import com.example.diffutilchecker.model.MockRawData
import com.example.diffutilchecker.model.SimpleCategory
import java.util.UUID

class MainViewModel : ViewModel() {

    private var originalList = listOf<SimpleCategory>()
    private val _moduleList = MutableLiveData<List<SimpleCategory>>()
    val moduleList: LiveData<List<SimpleCategory>>
        get() = _moduleList

    init {
        initList()
    }

    private fun initList() {
        originalList = MockRawData.list.flatten() // api에서 받아왔다고 가정
        _moduleList.value = originalList
    }

    // 상위 카테고리, 하위 카테고리를 1뎁스로 add
    fun List<SimpleCategory>.flatten(): List<SimpleCategory> {
        val flattened = mutableListOf<SimpleCategory>()
        forEachIndexed { i, it ->
            flattened.add(it)
            // 하위 카테고리는 upperIndex 추가
            flattened.addAll(it.subCategories.apply { map { it.upperIndex = i } })
        }
        return flattened
    }

    // 💡클릭 업데이트
    fun updateList(id: UUID) {
        // 클릭된 상위카테고리 정보
        val clickedCategory = originalList.find { it.id == id }
        val clickedCategoryIndex = originalList.filter { it.type == CategoryType.CATEGORY }.indexOf(clickedCategory)

        // map은 리스트를 리턴한다. 변수에 새로 할당하는 것 잊지 말기.
        val updatedList = originalList.map { item ->
            item.copy().apply {
                if (this == clickedCategory || upperIndex == clickedCategoryIndex) {
                    // 클릭된 상위 카테고리
                    // 클릭된 상위 카테고리의 하위카테고리
                    // 상태 변경
                    isExpanded = !isExpanded
                }
            }
        }
        _moduleList.value = updatedList
        originalList = updatedList
    }
}

위 코드의 originalList.map 블럭 안에서 요소를 copy()하지 않고 할당한다면,

얕은 복사가 되면서 수정된 리스트와 함께 원본 리스트의 요소도 변경되게 된다.

즉, 변경사항 감지가 안 된다.

// ❌리스트 요소를 카피하지 않고 바로 map할 경우
val updatedList = originalList.map { item ->
   if (item == clickedCategory || item.upperIndex == clickedCategoryIndex) {
       item.isExpanded = !item.isExpanded
   }
   item
}
Logger.v("변경됐을까? -> ${if(originalList == updatedList) "NO" else "YES"}")
// 변경됐을까? -> NO

리스트 어댑터 SimpleAdapter.kt

import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import com.example.diffutilchecker.model.CategoryType
import com.example.diffutilchecker.model.SimpleCategory

class SimpleAdapter : ListAdapter<SimpleCategory, BaseViewHolder>(
    object : DiffUtil.ItemCallback<SimpleCategory>() {
        // 객체 필드 비교 (바뀌지 않는 고유값 비교)
        override fun areItemsTheSame(oldItem: SimpleCategory, newItem: SimpleCategory): Boolean {
            return oldItem.id == newItem.id
        }

        // 객체 주소 비교
        // 객체.copy() 하면 바뀜
        override fun areContentsTheSame(oldItem: SimpleCategory, newItem: SimpleCategory): Boolean {
            return oldItem == newItem
        }

        // 객체 필드값은 같은데 (areItemsTheSame = true)
        // 객체 주소는 다를 때 (areContentsTheSame = false)
        override fun getChangePayload(oldItem: SimpleCategory, newItem: SimpleCategory): Any? {
            // 변경사항 감지되면 원하는 형태로 payload 리턴
            return if (oldItem.isExpanded == newItem.isExpanded) {
                super.getChangePayload(oldItem, newItem)
            } else {
                newItem.isExpanded
            }
        }

    }
) {

    override fun onCreateViewHolder(parent: ViewGroup, typeOrdinal: Int): BaseViewHolder {
        return when (CategoryType.values()[typeOrdinal]) {
            CategoryType.CATEGORY -> {
                CategoryViewHolder(LayoutInflater.from(parent.context).inflate(R.layout.item_category, parent, false))
            }
            CategoryType.SUB_CATEGORY -> {
                SubCategoryViewHolder(LayoutInflater.from(parent.context).inflate(R.layout.item_subcategory, parent, false))
            }
        }
    }

    // itemType 은 enum class ordinal로 리턴
    override fun getItemViewType(position: Int): Int = currentList[position].type.ordinal

    // notify를 호출하거나 areItemsTheSame이 false로 뜰 때 탄다
    // 뷰홀더가 새로 그려진다
    override fun onBindViewHolder(holder: BaseViewHolder, position: Int) {
        holder.onBind(currentList[position])
    }

    // getChangePayload의 리턴값을 받는다
    // 뷰홀더가 새로 그려지지 않는다
    override fun onBindViewHolder(holder: BaseViewHolder, position: Int, payloads: MutableList<Any>) {
        if (payloads.isEmpty()) {
            super.onBindViewHolder(holder, position, payloads)
        } else {
            // 변경사항 payload 수신
            val isExpanded = payloads[0] as? Boolean ?: false
            holder.onExpand(isExpanded)
        }
    }
}

뷰홀더 SimpleViewHolders.kt

import android.view.View
import android.view.ViewGroup
import android.view.ViewGroup.LayoutParams.MATCH_PARENT
import android.widget.TextView
import android.widget.Toast
import androidx.recyclerview.widget.RecyclerView
import com.example.diffutilchecker.model.SimpleCategory

abstract class BaseViewHolder(view: View) : RecyclerView.ViewHolder(view) {
    abstract fun onBind(data: SimpleCategory) // onBindViewHolder 처음 그려질때 호출
    abstract fun onExpand(isExpanded: Boolean) // onBindViewHolder에서 payload 받았을 때 호출
}

// 상위 카테고리 뷰홀더
class CategoryViewHolder(view: View) : BaseViewHolder(view) {
    override fun onBind(data: SimpleCategory) {
        val tv = itemView.findViewById<TextView>(R.id.cate_title)
        tv.text = data.title

        itemView.apply {
            toggleExpand(data.isExpanded)
            setOnClickListener { EventBus.fire(ClickEvent(adapterPosition, data.id)) }
        }
    }

    override fun onExpand(isExpanded: Boolean) {
        itemView.toggleExpand(isExpanded)
    }

    // 상위 카테고리 열리고 닫히는 UI
    private fun View.toggleExpand(isExpanded: Boolean) {
        val bgColor = if (isExpanded) R.color.teal_800 else R.color.teal_700
        setBackgroundColor(context.getColor(bgColor))
    }
}

// 하위 카테고리 뷰홀더
class SubCategoryViewHolder(view: View) : BaseViewHolder(view) {
    override fun onBind(data: SimpleCategory) {
        val tv = itemView.findViewById<TextView>(R.id.sub_cate_title)
        tv.text = data.title

        itemView.apply {
            toggleExpand(data.isExpanded)
            setOnClickListener {
                Toast.makeText(itemView.context, "${data.title} 클릭!", Toast.LENGTH_SHORT).show()
            }
        }
    }

    override fun onExpand(isExpanded: Boolean) {
        itemView.toggleExpand(isExpanded)
    }

    // 하위 카테고리 열리고 닫히는 UI
    // visibility말고 layoutParam을 조정해야 사라지고 나타남
    private fun View.toggleExpand(isExpanded: Boolean) {
        layoutParams =
            if (isExpanded) ViewGroup.LayoutParams(MATCH_PARENT, 45*3)
            else ViewGroup.LayoutParams(0, 0)
    }
}

 

onBindViewHolder 에서도 열리고 닫히는 UI를 세팅하는 이유:

아이템이 많아져서 스크롤이 화면을 넘어갔을 때도(onBindViewHolder호출) 상태를 읽어야하기 때문.

 

onBindViewHolder에서 isExpanded UI 제어안함 / onBindViewHolder에서 isExpanded UI 제어 함

 

 

예시코드대로 짜서 돌리면 아래와 같이 움직인다.

submitList() 호출하고 getChangePayload에서 변경사항 감지해서 UI 부분갱신

 

RecyclerView를 설정할 때 itemAnimator = null 로 하지 않으면

아래처럼 layout param이 변경될 때의 모션이 보인다.

 

결론

여러 UI를 getChangePayload로 업데이트 시켜봤다.

그런데 뎁스가 깊은 리스트는 요소를 add / remove 하는 게 좋겠다.

화면에 그려지지 않는 2뎁스, 3뎁스 데이터를 전부 들고 있을 필요가 없어보인다.

 

getChangePayload로 부분갱신하는 UI는 뎁스가 깊지 않고, 상태변화가 잘 보이는 종류가 좋겠다.

예) 클릭, 찜하기 등

 

728x90