버튼 수집상

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

TIL - 안드로이드

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

cocokaribou 2023. 7. 28. 14:19

지난 글에서 ListAdapter DiffUtil 의 getChangePayload 활용법에 대해 적었다.

 

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

1편에서 ListAdapter에 submitList를 하면서 기존 리스트를 업데이트할 때 리스트 깊은 복사deep copy를 해야 하는 이유에 대해 적었다. 이번엔 ListAdapter DiffUtil의 각 함수를 자세히 알아보겠다. 샘플 코드

collectingbuttons.tistory.com

이제 DiffUtil을 활용해서 expand - collapse 되는 리스트 UI를 만들어보겠다.

임의로 만든 모의데이터 기반이라 도움이 될지는 모르겠다.

 

샘플 UI 구조

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

상위 카테고리를 접었다 펼칠 수 있다.

리사이클러뷰를 중첩으로 사용하지 않기 위해 두 개의 뷰홀더를 생성한다.

 

데이터 모델 SimpleCategory.kt

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

    // UI data
    var type: CategoryType,
    var isExpanded: Boolean
) {
    // 상위카테고리 UI data 할당
    constructor(title: String, subCategories: List<SimpleCategory>) : this(
        title, subCategories, CategoryType.CATEGORY, false
    )

    // 하위카테고리 UI data 할당
    constructor(title: String) : this(
        title, listOf(), 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() } }
    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

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 // repository에서 받아왔다고 가정
        _moduleList.value = originalList
    }

    // 클릭 업데이트
    fun updateList(position: Int) {
        // 원본 리스트의 클릭기록 수정
        originalList.mapIndexed { i, it ->
            it.isExpanded = if (i == position) !it.isExpanded else false
        }

        // 열린 하위 카테고리 추가
        var updatedList = mutableListOf<SimpleCategory>()
        originalList.forEachIndexed { i, category ->
            updatedList.add(category)
            if (category.isExpanded) {
                updatedList.addAll(i + 1, category.subCategories.map { sub -> sub.apply { isExpanded = category.isExpanded } })
            }
        }

        // 닫힌 하위 카테고리 삭제
        updatedList = updatedList.filterNot { it.type == CategoryType.SUB_CATEGORY && !it.isExpanded }.toMutableList()

        // 수정된 리스트 원본 리스트에 할당
        // 바뀌는 인덱스 추적 위함
        originalList = updatedList
        _moduleList.value = updatedList
    }
}

어댑터 SimpleAdapter.kt

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

    // 객체 주소 비교
    override fun areContentsTheSame(oldItem: SimpleCategory, newItem: SimpleCategory): Boolean {
        return oldItem.equals(newItem)
    }

    // 객체의 필드는 같은데 주소는 다를 때 호출됨
    override fun getChangePayload(oldItem: SimpleCategory, newItem: SimpleCategory): Any? {
        return if (oldItem.isExpanded == newItem.isExpanded) {
            super.getChangePayload(oldItem, newItem)
        } else {
            newItem
        }
    }

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

    // enum 타입별로 뷰홀더 생성
    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))
            }
        }
    }
    
    // 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 {
            Logger.v("여길 타지 않는다")
        }
    }
}

뷰홀더 SimpleViewHolders.kt

abstract class BaseViewHolder(view: View) : RecyclerView.ViewHolder(view) {
    abstract fun onBind(data: SimpleCategory)
}

// 상위 카테고리 뷰홀더
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.setOnClickListener {
            EventBus.fire(ClickEvent(adapterPosition))
        }
    }
}

// 하위 카테고리 뷰홀더
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.setOnClickListener {
            Toast.makeText(itemView.context, "${data.title} 클릭!", Toast.LENGTH_SHORT ).show()
        }
    }
}

 

상위 카테고리를 펼쳤을 땐 하위 카테고리를 추가하고

상위 카테고리를 접었을 땐 하위 카테고리를 삭제하는 식으로 구현했다.

 

그런데 아이템을 추가/삭제하면 요소의 위치가 달라져서 areItemsTheSame에서 false가 탔다.

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
}

로그 찍은 결과

 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를 타지 않는다.

결국 ListAdapter로 구현한 expand / collapse는 onBindViewHolder를 타면서 뷰홀더가 반짝이게 된다.

ListAdapter 를 RecyclerView.Adapter 로 수정하고 submitList 대신 notifyDataSetChanged를 호출했다.

그러면 뷰홀더가 반짝이는 현상은 사라진다.

 

결론

요소의 개수와 위치가 달라지는 리스트는 ListAdapter의 getChangePayload를 타지 않기 때문에 자연스럽게 업데이트할 수 없다.

다른 방법이 있을까?

 

 

다음 글

 

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

지난 글 링크 [안드로이드] ListAdapter DiffUtil 제대로 쓰기 - 3 지난 글에서 ListAdapter DiffUtil 의 getChangePayload 활용법에 대해 적었다. [안드로이드] ListAdapter DiffUtil 제대로 쓰기 - 2 1편에서 ListAdapter에 su

collectingbuttons.tistory.com

 

728x90