일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | |||
5 | 6 | 7 | 8 | 9 | 10 | 11 |
12 | 13 | 14 | 15 | 16 | 17 | 18 |
19 | 20 | 21 | 22 | 23 | 24 | 25 |
26 | 27 | 28 | 29 | 30 | 31 |
- build with ai
- map
- doc2vec
- llm
- ListAdapter DiffUtil
- ktor client
- DiffUtil.ItemCallback
- video caching
- android ktor
- ktor api call
- ExoPlayer
- Python
- android
- ListAdapter
- AWS EC2
- exoplayer cache
- android exoplayer
- 독서
- 유튜브
- FastAPI
- 안드로이드
- list map
- 시행착오
- kotlin list
- getChangePayload
- 스피너
- android custom view
- ChatGPT
- Zsh
- kotlin collection
- Today
- Total
버튼 수집상
[안드로이드] ListAdapter DiffUtil 제대로 쓰기 - 3 본문
지난 글에서 ListAdapter DiffUtil 의 getChangePayload 활용법에 대해 적었다.
이제 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를 타지 않기 때문에 자연스럽게 업데이트할 수 없다.
다른 방법이 있을까?
'TIL - 안드로이드' 카테고리의 다른 글
[안드로이드] ExoPlayer 비디오 캐싱하기 (0) | 2023.08.09 |
---|---|
[안드로이드] 그림자에 색깔 있는 카드뷰 만들기-1 Custom CardView with shadowColor attribute (0) | 2023.08.01 |
[안드로이드] 중복체크 되는 리스트 Preference 저장하고 테스트하기 (0) | 2023.07.20 |
[안드로이드] ListAdapter DiffUtil 제대로 쓰기 - 2 (0) | 2023.07.13 |
[안드로이드] ListAdapter DiffUtil 제대로 쓰기 - 1 (0) | 2023.07.13 |