Recyclerview에서 뷰의 특정 부분만 업데이트 하기

벼리·2025년 1월 10일
post-thumbnail

들어가면서

체크박스를 클릭할 때 마다, 연관이 없는 뷰가 깜빡이는 현상이 발생하였다.
처음에는 dataBinding을 사용하고 있기 때문에, 뷰 업데이트가 느려서 생기는 문제라 생각했다.

하지만 아이템 전체가 아닌, 아이템 내부의 특정 뷰만 업데이트를 해야했다

ListAdapter는 데이터 단위의 변경을 감지하고 뷰 홀더 단위로 업데이트를 수행한다.
즉, 바뀐 데이터에 대해 뷰 아이템 전체가 변경된다.

밑의 상품 목록에서 첫 번째 상품과는 다르게, 2번째 상품이 깜빡이는 이유가 위와 같다.
버튼 뷰과 가격만 업데이트 되어야 하는데, 이미지도 클릭 이벤트에 영향을 받아 불필요한 업데이트가 진행되고 있다.

그렇다면, 어떻게 특정 뷰만 업데이트 할 수 있을까?

DiffUtil.ItemCallback

DiffUtil.ItemCallback의 주요 메서드는 다음과 같다.

  • areItemsTheSame
    • 두 item이 같은 아이템임을 나타내는 메서드이다. 즉, 특정 값이 같다면 같은 객체라고 인식하는 것이다.
    • 주로 고유한 id를 비교한다.
  • areContentsTheSame
    • 두 item이 같은 data를 가지고 있는지를 비교한다.
    • DiffUtil은 동등성을 비교할 때, equals 메서드 대신 해당 메서드를 사용한다.

  • getChangePayload
    • areItemsTheSame이 true를 반환하고 areContentsTheSame이 false를 반환하면, DiffUtil은 payload에 대한 변화를 얻기 위해 이 메서드를 실행한다.

이 중에서, 우리가 봐야할 것은 getChangePayload이다.

Recyclerview - payload

onBindViewHolder의 파라미터인 payload는 notifyItemChanged 혹은 notifyItemRangeChanged 에서 전달된 payload 객체들의 병합된 리스트다.

즉, 변경 사항을 나타내는 데이터들의 병합된 리스트이다.

payload는 특정 데이터 변경 정보를 전달하기 위한 것이며, ViewHolder가 이전 데이터에 바인딩된 상태에서 변경된 부분만 업데이트할 수 있도록 한다.

적용 방법

만약 체크 여부가 바뀌었을 경우 IsCheckChanged를, 그 이외의 데이터가 바뀌었을 경우 CouponUpdate를 반환한다.

DiffUtil.ItemCallback - getChangePayload

onBindViewHolder의 파라미터로 들어온 payloads의 타입을 검사하여, 각 상황에 따라 뷰를 어떻게 업데이트할지 정의한다.

이렇게 하면, 불필요하게 뷰를 업데이트하는 상황을 방지할 수 있다.

companion object {
    private val diffCallback = object : DiffUtil.ItemCallback<CouponUiModel>() {
        override fun areItemsTheSame(oldItem: CouponUiModel, newItem: CouponUiModel): Boolean {
            return oldItem.id == newItem.id
        }

        override fun areContentsTheSame(oldItem: CouponUiModel, newItem: CouponUiModel): Boolean {
            return oldItem == newItem
        }

        override fun getChangePayload(oldItem: CouponUiModel, newItem: CouponUiModel): Any? {
            return when {
                // 체크 상태가 다르다면, checkbox만 업데이트
                oldItem.isChecked != newItem.isChecked -> CouponPayload.IsCheckChanged(newItem.isChecked)
                // 객체가 동등하지 않다면, item view 전체를 업데이트
                oldItem != newItem -> CouponPayload.CouponUpdate(newItem)
                else -> super.getChangePayload(oldItem, newItem)
            }
        }
    }
}
    
sealed interface CouponPayload {

    data class IsCheckChanged(val isChecked: Boolean) : CouponPayload

    data class CouponUpdate(val couponUiModel: CouponUiModel) : CouponPayload
}

onBindViewHolder


override fun onBindViewHolder(
    holder: CouponViewHolder,
    position: Int,
) {
    holder.onBind(getItem(position))
}

// 파라미터가 다른, 같은 이름의 메서드를 만들 수 있다.
override fun onBindViewHolder(
    holder: CouponViewHolder,
    position: Int,
    payloads: MutableList<Any>
) {
    if (payloads.isNotEmpty()) {
        payloads.forEach { payload ->
            when (payload) {
                is CouponPayload.IsCheckChanged -> holder.updateCheck(payload.isChecked)
                is CouponPayload.CouponUpdate -> holder.onBind(payload.couponUiModel)
            }
        }
    } else {
        onBindViewHolder(holder, position)
    }
}

전체코드

class CouponAdapter(
    private val onClick: CouponClickListener,
) : ListAdapter<CouponUiModel, CouponAdapter.CouponViewHolder>(diffCallback) {
    class CouponViewHolder(
        private val binding: ItemCouponBinding,
        private val onClick: CouponClickListener,
    ) : RecyclerView.ViewHolder(binding.root) {
        fun onBind(item: CouponUiModel) {
            with(binding) {
                tvCouponItemTitle.text = item.title
                tvCouponItemExpireDate.text =
                    itemView.context.getString(R.string.coupon_expire).format(item.expireDate)
                        .format(item.availableStartTime, item.availableEndTime)
                tvCouponItemMinimumOrderPrice.text =
                    itemView.context.getString(R.string.coupon_minimum_amount)
                        .format(item.minimumAmount)
                isChecked = item.isChecked
                couponClickListener = onClick
                couponId = item.id
            }
        }

        fun updateCheck(isChecked: Boolean) {
            binding.isChecked = isChecked
        }
    }

    override fun onCreateViewHolder(
        parent: ViewGroup,
        viewType: Int,
    ): CouponViewHolder {
        val binding = ItemCouponBinding.inflate(LayoutInflater.from(parent.context), parent, false)
        return CouponViewHolder(binding, onClick)
    }

    override fun onBindViewHolder(
        holder: CouponViewHolder,
        position: Int,
    ) {
        holder.onBind(getItem(position))
    }

    override fun onBindViewHolder(
        holder: CouponViewHolder,
        position: Int,
        payloads: MutableList<Any>
    ) {
        if (payloads.isNotEmpty()) {
            payloads.forEach { payload ->
                when (payload) {
                    is CouponPayload.IsCheckChanged -> holder.updateCheck(payload.isChecked)
                    is CouponPayload.CouponUpdate -> holder.onBind(payload.couponUiModel)
                }
            }
        } else {
            onBindViewHolder(holder, position)
        }
    }


    companion object {
        private val diffCallback = object : DiffUtil.ItemCallback<CouponUiModel>() {
            override fun areItemsTheSame(oldItem: CouponUiModel, newItem: CouponUiModel): Boolean {
                return oldItem.id == newItem.id
            }

            override fun areContentsTheSame(oldItem: CouponUiModel, newItem: CouponUiModel): Boolean {
                return oldItem == newItem
            }

            override fun getChangePayload(oldItem: CouponUiModel, newItem: CouponUiModel): Any? {
                return when {
                    oldItem.isChecked != newItem.isChecked -> CouponPayload.IsCheckChanged(newItem.isChecked)
                    oldItem != newItem -> CouponPayload.CouponUpdate(newItem)
                    else -> super.getChangePayload(oldItem, newItem)
                }
            }
        }
    }
}

sealed interface CouponPayload {
    data class IsCheckChanged(val isChecked: Boolean) : CouponPayload

    data class CouponUpdate(val couponUiModel: CouponUiModel) : CouponPayload
}
profile
코딩일기

0개의 댓글