Skip to content

Troubleshooting ‐ RecyclerView의 View가 스크롤 밖으로 벗어나면 값이 바뀌는 현상

Taewan Park edited this page Dec 13, 2023 · 2 revisions

RecyclerView로 상품 목록 화면을 구현하던중, 신기한 오류가 발생했다.

Screen_Recording_20231208_034614_PriceGuard.mp4

각 View에 있는 알림 토글의 값을 변경하고, 스크롤을 내렸다가 다시 돌아오면 해당 토글의 값이 변경 이전의 값으로 되돌아가는 현상이다.

우선 어떤 식으로 구현했는지 살펴보자.

interface ProductSummaryClickListener {
    fun onClick(productCode: String)

    fun onToggle(productCode: String, checked: Boolean)
}
class ViewHolder(
    private val binding: ItemProductSummaryBinding,
    private val productSummaryClickListener: ProductSummaryClickListener
) :
    RecyclerView.ViewHolder(binding.root) {

    fun bind(item: ProductSummary) {
        with(binding) {
            summary = item
            setViewType(item)
            setClickListener(item.productCode) // <- 토글이 하는 행동을 처리하는 함수
            setGraph(item.priceData)
        }
    }

ProductSummaryClickListener 라는 interface가 선언되어있고, RecyclerView의 ViewHolder에서 인자로 해당 값을 받게 된다. 이 부분은 RecyclerView를 사용하는 Fragment쪽 코드에서 Adapter를 선언할때 넣어준다.

val listener = object : ProductSummaryClickListener {
    override fun onClick(productCode: String) {
        val intent = Intent(context, DetailActivity::class.java)
        intent.putExtra("productCode", productCode)
        startActivity(intent)
    }

    override fun onToggle(productCode: String, checked: Boolean) {
        productListViewModel.updateProductAlarmToggle(productCode, checked)
        if (workRequestSet.contains(productCode)) {
            workRequestSet.remove(productCode)
        } else {
            workRequestSet.add(productCode)
        }
    }
}

//...

val adapter = ProductSummaryAdapter(listener)

이렇게 구현된 상황에서 값을 바꾸고 스크롤을 할때마다 값이 바뀌어, 여기저기 로그를 찍어서 분석해 보았다. binding 로그는 ViewHolder의 bind 함수에서, toggle은 adapter에 넣어줬던 onToggle 함수에 넣어줬다.

IMG_0203

스크롤을 하면서 토글을 변경한 View가 스크롤을 하면서 사라지는 순간, 해당 View의 onToggle이 호출되는 모습을 볼 수 있다. 여기서 RecyclerView가 정확히 어떤 식으로 View가 "Recycle" 되는지 아래 이미지를 통해 간단하게 살펴보자.

image

RecyclerView는 아래로 스크롤 한다고 가정했을 때, 위에 존재하던 곧 사라질 View를 삭제하지 않고 아랫쪽에서 새로 나타나야 하는 파란색 View 위치로 이동시킨다. 해당 위치로 이동을 시키면서 아래쪽의 item에 대한 내용을 채워야하고, 이때 ViewHolder의 bind함수가 실행되게 된다. 여기서 다시 한번 bind 함수에서의 순서를 보면,

fun bind(item: ProductSummary) {
    with(binding) {
        summary = item
        setViewType(item)
        setClickListener(item.productCode) // <- 토글이 하는 행동을 처리하는 함수
        setGraph(item.priceData)
    }
}

binding.summary = item 으로 새로 나타나야 하는 item의 값을 넣어주고, setClickListener를 통해서 토글이 하는 행동을 처리하는 onToggle 함수를 새로운 item에 맞게끔 설정해준다. 정확히 여기서 문제가 발생한다!

위에 존재하던 View의 toggle이 binding.summary = item으로 인해 값이 바뀌게 되고, setClickListener로 새로운 item에 맞는 listener가 들어오기 전에 존재하던 item의 onToggle이 호출되었던 것이다! onToggle을 할때는 당연히 ViewModel에서도 해당 toggle의 state를 변경하기 때문에 스크롤이 된 후에 다시 돌아오면 값이 원래대로 바뀌었던 것이다. 따라서 정확히는 값을 바꾸고 Scroll을 했다가 돌아오면 값이 원래대로 돌아오는 버그가 아닌, 하위 item의 toggle값이 위쪽 View와 다른 값을 가지고 있는 상황이면 그냥 스크롤을 해도 값이 기존값과 다르게 변경되어버리는 버그이다.

해결방법은 알쏭달쏭한 문제 상황에 비교하면 단순하다. item을 View쪽에 binding하기 전에 listener를 null로 초기화해주면 해결된다.

fun bind(item: ProductSummary) {
    with(binding) {
        resetListener()
        summary = item
        setViewType(item)
        setClickListener(item.productCode)
        setGraph(item.priceData)
    }
}

private fun ItemProductSummaryBinding.resetListener() {
    msProduct.setOnCheckedChangeListener(null)
}
screen-20231208-222549.mp4
Clone this wiki locally