[Android] - ListAdapter
Android 프로젝트를 한다면 가장 많이 사용하는 view가 RecyclerView가 아닐까.. 싶을 정도로 굉장히 많이 사용되는거 같습니다
이번 프로젝트를 하면서 어떻게 하면 아이템을 효과적으로 관리할 수 있을까에 대해서 고민해봤고, ListAdapter에 대해서 공부해 봤습니다.
제 목표는 위 사이드바에서 선택한 아이템에 대해서 UI적으로 변화를 주고 싶었습니다.ㅎㅎ
ListAdapter
RecyclerView에서 사용하는 Adapter는
- RecyclerView.adapter
- 와 조금 더 확장된 개념인 ListAdapter가 있습니다.
간략하게 RecyclerView.adapter를 그냥 Adapter라고 부르겠습니다.
Adapter와 ListAdapter의 차이점
아이템이 변화할 때 수동으로 변화를 알려줄 것이냐, 자동으로 그 변화를 감지할 것이냐의 차이입니다.
Adapter의 경우 아이템이 바뀔 경우 notify()와 같이 변경되었음을 수동으로 RecyclerView에게 알려줘야 합니다.
ListAdapter의 경우 내장된 DiffUtil을 통해서 기존의 리스트와 새로운 리스트의 차이를 계산해서 변화된 아이템만 새로 업데이트를 해줍니다.
즉 요약하자면 RecyclerView.Adapter는 데이터의 변화도 수동으로 알려줘야 하고, 데이터셋 전체를 새로 업데이트합니다.
하지만 ListAdapter의 경우는 DiffUtil을 통해서 기존의 리스트와 새로운 리스트를 비교해서 변화된 데이터 그 자체만 업데이트해 줍니다.
실제로 ListAdapter를 사용하면서 데이터셋 전체를 업데이트하는 것과 바뀐 데이터의 일부만을 업데이트하는 것의 차이를 못 느꼈지만, 이번 프로젝트를 해보고 실제 앱을 사용하면서 테스트를 할 경우 사용자의 입장에서 확연하게 느껴진다는 점이었습니다.
그렇다면 DiffUtil은 무엇일까?
Diffutil은 두 데이터셋을 받아서 차이를 계산해 주는 클래스입니다.
이 DiffUtil을 통해서 RecyclerView는 데이터의 전체를 업데이트하는 것이 아닌 변경된 부분만 파악하여 RecyclerView에 반영할 수 있는 것입니다.
DiffUtil의 메서드는 두 가지가 있습니다.
areItemTheSame
두 객체가 동일한 항목을 나타내는지 확인하기 위해 호출된다.
areContentsTheSame
두 항목이 동일한 데이터를 가지고 있는지 확인하기 위해 호출된다.
위 두가지 메서드를 통해서 우리는 RecyclerView에게 변경된 아이템에 대해서 가르쳐줘야 합니다.
과정은 ItemsTheSame을 통해서 두 객체가 동일한지 판단하고, ContentsTheSame을 통해서 동일한 데이터를 가지는지 확인합니다.
RecyclerView에서 사용하는 리스트의 아이템들은 거의 Data class가 사용되기 때문에
두 객체를 비교하는 itemTheSame에는 Data class의 고유한 ID 값을 비교했습니다.
이러한 이유는 Data class는 생성자 중 하나라도 바뀌면 서로 다른 객체라고 판단하기 때문입니다.
두 객체를 == 로 비교해도 되지 않냐?라고 생각하실 분도 있을 텐데 나중에 영상으로 비교해 드리겠습니다.
ContetnsTheSame에는 두 객체의 내용을 비교하는 ==을 사용해서 두 객체의 데이터가 같은지 아닌지를 판단했습니다.
Item의 선택여부에 따라서 배경이 바뀌는 것은 확인했지만, 라디오버튼처럼 다른 버튼이 선택되었다면 해당 버튼에 대한 처리를 해줘야 했습니다.
val updatedSpaces = _uiState.value.spaces.map { it.copy(isSelected = it == space) }
_uiState.update { uiState ->
uiState.copy(
nowSpace = space,
spaces = updatedSpaces,
)
}
따라서 다음과 같이 선택한 Item이 아니라면 모두 isSelected를 false로 처리했습니다.
이제 선택된 아이템에 대해서만 isSelecetd를 true로 했고, 다른 것들은 false니까 잘 되지 않을까??라고 생각했지만..
영상과 같이 데이터가 바뀌지만 화면에서 계속 번쩍번쩍처럼 데이터의 전체가 업데이트되는 것을 확인할 수 있습니다.
이유에 대해서 계속 고민하다가..
companion object {
val DIFF_CALLBACK =
object : DiffUtil.ItemCallback<Space>() {
override fun areItemsTheSame(
oldItem: Space,
newItem: Space,
): Boolean {
return oldItem == newItem
}
override fun areContentsTheSame(
oldItem: Space,
newItem: Space,
): Boolean {
return oldItem == newItem
}
}
}
이유를 알아보니 저는 선택된 노드가 바뀔 때마다 UiState의 space값들을 계속 copy를 했고, 따라서 ItemsTheSame에서 계속 false가 반환돼서 실제로 바뀌지 않는 데이터들까지도 RecyclerView에 새로 그려지는 것이었습니다.
즉 Item에 대해서 고유한 아이디로 처음에 비교를 하고 안의 내용을 비교하는 것이 맞다고 판단했습니다.
val DIFF_CALLBACK =
object : DiffUtil.ItemCallback<Space>() {
override fun areItemsTheSame(
oldItem: Space,
newItem: Space,
): Boolean {
return oldItem.id == newItem.id
}
override fun areContentsTheSame(
oldItem: Space,
newItem: Space,
): Boolean {
return oldItem == newItem
}
}
영상을 확인하면 DiffUtil을 바꾸기 전과 확연하게 부드러워진 것을 확인할 수 있었습니다.
해당 경험을 하면서
초기에는
fun bind(item: Space) {
binding.space = item
itemView.setOnClickListener {
item.isSelected = !item.isSelected
spaceClickListener?.onClickSpace(item)
}
}
다음과 같이 item의 isSelected를 viewHolder에서 바꿔주고, viewModel에서도 업데이트해 줬는데 충돌이 일어났습니다.
영상에서는 확인이 잘 안 되는데 클릭 이벤트에 대해서 선택 처리가 깔끔하지 않았습니다.
아마 bind 내에서 item의 isSelected를 바꾸고, viewModel에서도 또 바꿔서 item의 차이를 계산하는 Diffutil에서 충돌이 일어나서
생긴 문제인 거 같습니다.
따라서 viewHolder 내에서는 item을 그리기만 하고, 데이터의 속성을 바꿔주는 코드를 외부로 분리했고, 성공적인 결과를 얻을 수 있었습니다.
ListAdater는 DataBinding과 같이 쓰면 위력이 더욱 증가합니다.ㅎㅎ
@BindingAdapter("app:sideBarSpaces")
fun RecyclerView.bindSpaces(spaces:List<Space>){
if(this.adapter !=null){
(this.adapter as SideBarSpaceAdapter).submitList(spaces.toMutableList())
}
}
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rv_side_bar_space"
android:layout_width="0dp"
android:layout_height="0dp"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
app:sideBarSpaces="@{vm.uiState.spaces}"
app:layout_constraintBottom_toTopOf="@id/cv_side_bar_profile"
app:layout_constraintEnd_toStartOf="@id/view_side_bar_menu"
app:layout_constraintStart_toStartOf="@id/view_side_bar_space"
app:layout_constraintTop_toBottomOf="@id/imgbtn_side_bar_add_space"
tools:itemCount="5"
tools:listitem="@layout/item_space" />
android:background="@{space.isSelected ? @color/main4 : @color/main3}"
DataBinding을 통해서 uiState의 spacec가 collect 될 때마다 ListAdpater에 List를 반영했고,
각 아이템의 isSelected를 통해서 배경색을 다르게 적용했습니다.
위 DataBinding에서 Adapter를 선언하는 코드는 잘못된 부분인 거 같은데 아직 다르게 구현하는 방법을 찾지는 못한 거 같습니다 ㅠ
느낀 점
처음에는 ListAdapter를 사용하면서 DiffUtil의 원리를 몰라서 notifyDataSetChanged()를 통해서 수동으로 업데이트해 줬습니다.
물론 그 과정에서 매끄러운 화면의 반영은 되지 않아서 고민을 해봤는데, DiffUtil을 통해서 Item이 변화된 부분에 대해서 RecyclerView에 반영할 수 있었고, 깔끔한 방법인 거 같습니다.
참고
https://developer.android.com/reference/androidx/recyclerview/widget/DiffUtil.ItemCallback
DiffUtil.ItemCallback | Android Developers
androidx.appsearch.builtintypes.properties
developer.android.com
https://developer.android.com/reference/androidx/recyclerview/widget/RecyclerView.Adapter
RecyclerView.Adapter | Android Developers
androidx.appsearch.builtintypes.properties
developer.android.com
https://developer.android.com/reference/androidx/recyclerview/widget/ListAdapter
ListAdapter | Android Developers
androidx.appsearch.builtintypes.properties
developer.android.com
https://cliearl.github.io/posts/android/recyclerview-listadapter/
DiffUtil과 ListAdapter 이해하고 RecyclerView에 적용하기
이번 포스팅에서는 RecyclerView에 ListAdapter를 적용하는 법에 대해 알아보도록 하겠습니다. 들어가기 Recyclerview의 데이터가 변하면 Recyclerview Adapter가 제공하는 notifyItem 메소드를 사용해서 ViewHolder
cliearl.github.io