일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
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 |
- getChangePayload
- kotlin list
- video caching
- exoplayer cache
- map
- kotlin collection
- 시행착오
- ktor api call
- ExoPlayer
- list map
- ListAdapter
- doc2vec
- FastAPI
- llm
- android exoplayer
- AWS EC2
- 유튜브
- android custom view
- 독서
- build with ai
- Zsh
- ktor client
- android
- ChatGPT
- Python
- 스피너
- DiffUtil.ItemCallback
- ListAdapter DiffUtil
- android ktor
- 안드로이드
- Today
- Total
버튼 수집상
[안드로이드] ListAdapter DiffUtil 제대로 쓰기 - 4 본문
지난 글에 이어서 DiffUtil.ItemCallback의 getChangePayload 함수를 이용해서
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호출) 상태를 읽어야하기 때문.
예시코드대로 짜서 돌리면 아래와 같이 움직인다.
submitList() 호출하고 getChangePayload에서 변경사항 감지해서 UI 부분갱신
RecyclerView를 설정할 때 itemAnimator = null 로 하지 않으면
아래처럼 layout param이 변경될 때의 모션이 보인다.
결론
여러 UI를 getChangePayload로 업데이트 시켜봤다.
그런데 뎁스가 깊은 리스트는 요소를 add / remove 하는 게 좋겠다.
화면에 그려지지 않는 2뎁스, 3뎁스 데이터를 전부 들고 있을 필요가 없어보인다.
getChangePayload로 부분갱신하는 UI는 뎁스가 깊지 않고, 상태변화가 잘 보이는 종류가 좋겠다.
예) 클릭, 찜하기 등
'TIL - 안드로이드' 카테고리의 다른 글
[안드로이드] Retrofit 대신 Ktor로 Api 호출해보기 - 1 (0) | 2023.12.11 |
---|---|
[안드로이드] TextView 원하는 글자만 남기고 말줄임표 보여주기 Custom Ellipsis (1) | 2023.12.07 |
[안드로이드] Process란 (0) | 2023.11.08 |
[안드로이드] 웹뷰 스크롤 멈춘 시점 알기 Detect Android WebView scroll state (0) | 2023.10.27 |
[안드로이드] ExoPlayer 비디오 캐싱하기 - 2 (0) | 2023.09.21 |